basic-block

This commit is contained in:
Ramnique Singh 2026-04-10 15:51:39 +05:30
parent ab0147d475
commit 66dc065996
18 changed files with 1220 additions and 0 deletions

View file

@ -44,6 +44,8 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
/**
* Convert markdown to a styled HTML document for PDF/DOCX export.
@ -362,6 +364,19 @@ export async function startServicesWatcher(): Promise<void> {
});
}
let tracksWatcher: (() => void) | null = null;
export function startTracksWatcher(): void {
if (tracksWatcher) return;
tracksWatcher = trackBus.subscribe((event) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('tracks:events', event);
}
}
});
}
export function stopRunsWatcher(): void {
if (runsWatcher) {
runsWatcher();
@ -758,6 +773,11 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
// Track handler
'track:run': async (_event, args) => {
const result = await triggerTrackUpdate(args.trackId, args.filePath);
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();

View file

@ -4,6 +4,7 @@ import {
setupIpcHandlers,
startRunsWatcher,
startServicesWatcher,
startTracksWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
@ -22,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
@ -228,6 +230,9 @@ app.whenReady().then(async () => {
// start services watcher
startServicesWatcher();
// start tracks watcher
startTracksWatcher();
// start gmail sync
initGmailSync();

View file

@ -55,6 +55,7 @@
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.1",
"yaml": "^2.8.2",
"zod": "^4.2.1"
},
"devDependencies": {

View file

@ -9,6 +9,7 @@ import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-block'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
@ -638,6 +639,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
ImageBlockExtension,
EmbedBlockExtension,
ChartBlockExtension,

View file

@ -0,0 +1,336 @@
import { z } from 'zod'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Radio, ChevronRight, X, Clock, Code2, Check, Play, Loader2 } from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
function truncate(text: string, maxLen: number): string {
const clean = text.replace(/\s+/g, ' ').trim()
if (clean.length <= maxLen) return clean
return clean.slice(0, maxLen).trimEnd() + '…'
}
type Tab = 'metadata' | 'instruction' | 'criteria'
function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
updateAttributes: (attrs: Record<string, unknown>) => void
extension: { options: { notePath?: string } }
}) {
const raw = node.attrs.data as string
const [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState<Tab>('instruction')
const [editingRaw, setEditingRaw] = useState(false)
const [rawDraft, setRawDraft] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
try {
return TrackBlockSchema.parse(parseYaml(raw))
} catch { return null }
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
const trackId = track?.trackId ?? ''
const instruction = track?.instruction ?? ''
const matchCriteria = track?.matchCriteria ?? ''
const active = track?.active ?? true
const lastRunAt = track?.lastRunAt ?? ''
const lastRunId = track?.lastRunId ?? ''
const lastRunSummary = track?.lastRunSummary ?? ''
const notePath = extension.options.notePath
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
// Track run status from the global hook
const allTrackStatus = useTrackStatus()
const runState = allTrackStatus.get(`${track.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
const runStatus = runState.status
const runSummary = runState.summary ?? runState.error ?? null
useEffect(() => {
if (editingRaw && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [editingRaw])
const handleStartEdit = () => {
setRawDraft(raw)
setEditingRaw(true)
}
const handleSaveRaw = () => {
updateAttributes({ data: rawDraft })
setEditingRaw(false)
}
const handleCancelEdit = () => {
setEditingRaw(false)
}
const handleRun = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation()
if (runStatus === 'running' || !trackId || !trackFilePath) return
try {
await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath })
} catch (err) {
console.error('[TrackBlock] Run failed:', err)
}
}, [runStatus, trackId, trackFilePath])
const tabs: { key: Tab; label: string }[] = [
{ key: 'instruction', label: 'Instruction' },
{ key: 'criteria', label: 'Match Criteria' },
{ key: 'metadata', label: 'Metadata' },
]
const isRunning = runStatus === 'running'
return (
<NodeViewWrapper className="track-block-wrapper" data-type="track-block">
<div
className={`track-block-card ${!active ? 'track-block-paused' : ''} ${isRunning ? 'track-block-running' : ''}`}
onMouseDown={(e) => e.stopPropagation()}
>
<button
className="track-block-delete"
onClick={deleteNode}
aria-label="Delete track block"
>
<X size={14} />
</button>
{/* Collapsed view */}
<div
className="track-block-collapsed"
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
size={14}
className={`track-block-chevron ${expanded ? 'track-block-chevron-open' : ''}`}
/>
<Radio size={14} className="track-block-icon" />
<span className="track-block-label">Track</span>
{!active && <span className="track-block-badge track-block-badge-paused">paused</span>}
<span className="track-block-summary">{truncate(instruction, 60)}</span>
{lastRunAt && !isRunning && (
<span className="track-block-meta">
<Clock size={11} />
{formatDateTime(lastRunAt)}
</span>
)}
{isRunning && (
<span className="track-block-meta track-block-meta-running">
<Loader2 size={11} className="animate-spin" />
Running
</span>
)}
<button
className="track-block-run-btn"
onClick={handleRun}
disabled={isRunning}
aria-label="Run track"
title="Run track"
>
{isRunning
? <Loader2 size={13} className="animate-spin" />
: <Play size={13} />}
</button>
</div>
{/* Status bar */}
{runSummary && runStatus !== 'running' && (
<div className={`track-block-status-bar ${runStatus === 'error' ? 'track-block-status-error' : 'track-block-status-done'}`}>
{runSummary}
</div>
)}
{/* Expanded view */}
{expanded && (
<div className="track-block-expanded">
<div className="track-block-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`track-block-tab ${activeTab === tab.key ? 'track-block-tab-active' : ''}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
<button
className={`track-block-tab track-block-tab-raw ${editingRaw ? 'track-block-tab-active' : ''}`}
onClick={handleStartEdit}
>
<Code2 size={12} />
Edit Raw
</button>
</div>
{editingRaw ? (
<div className="track-block-raw-editor">
<textarea
ref={textareaRef}
className="track-block-textarea"
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={10}
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSaveRaw()
}
}}
/>
<div className="track-block-raw-actions">
<button className="track-block-btn track-block-btn-secondary" onClick={handleCancelEdit}>
Cancel
</button>
<button className="track-block-btn track-block-btn-primary" onClick={handleSaveRaw}>
<Check size={12} />
Save
</button>
</div>
</div>
) : (
<div className="track-block-panel">
{activeTab === 'instruction' && (
<div className="track-block-panel-text">
{instruction
? <Streamdown className="track-block-markdown">{instruction}</Streamdown>
: <span className="track-block-empty">No instruction set</span>}
</div>
)}
{activeTab === 'criteria' && (
<div className="track-block-panel-text">
{matchCriteria
? <Streamdown className="track-block-markdown">{matchCriteria}</Streamdown>
: <span className="track-block-empty">No match criteria set</span>}
</div>
)}
{activeTab === 'metadata' && (
<div className="track-block-metadata-grid">
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Track ID</span>
<span className="track-block-metadata-value"><code>{trackId}</code></span>
</div>
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Status</span>
<span className="track-block-metadata-value">
<span className={`track-block-badge ${active ? 'track-block-badge-active' : 'track-block-badge-paused'}`}>
{active ? 'active' : 'paused'}
</span>
</span>
</div>
{lastRunAt && (
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Last run</span>
<span className="track-block-metadata-value">{formatDateTime(lastRunAt)}</span>
</div>
)}
{lastRunId && (
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Run ID</span>
<span className="track-block-metadata-value"><code>{lastRunId}</code></span>
</div>
)}
{lastRunSummary && (
<div className="track-block-metadata-row">
<span className="track-block-metadata-label">Summary</span>
<span className="track-block-metadata-value">{lastRunSummary}</span>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</NodeViewWrapper>
)
}
export const TrackBlockExtension = Node.create({
name: 'trackBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
notePath: undefined as string | undefined,
}
},
addAttributes() {
return {
data: {
default: '',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-track')) {
return { data: code.textContent || '' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TrackBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```track\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,72 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track-block.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
export interface TrackState {
status: TrackRunStatus;
runId?: string;
summary?: string | null;
error?: string | null;
}
// Module-level store — shared across all hook consumers, subscribed once
// We replace the Map on every mutation so useSyncExternalStore detects the change
let store = new Map<string, TrackState>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
}
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
const key = `${event.trackId}:${event.filePath}`;
if (event.type === 'track_run_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'track_run_complete') {
updateStore(s => s.set(key, {
status: event.error ? 'error' : 'done',
runId: event.runId,
summary: event.summary ?? null,
error: event.error ?? null,
}));
// Auto-clear after 5 seconds
setTimeout(() => {
updateStore(s => s.delete(key));
}, 5000);
}
}) as (event: z.infer<typeof TrackEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
ensureSubscription();
listeners.add(onStoreChange);
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, TrackState> {
return store;
}
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
*
* Usage in a track block component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const trackStatus = useTrackStatus();
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
*/
export function useTrackStatus(): Map<string, TrackState> {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -612,6 +612,382 @@
color: color-mix(in srgb, var(--foreground) 38%, transparent);
}
/* Track block styles */
.tiptap-editor .ProseMirror .track-block-wrapper {
margin: 8px 0;
}
.tiptap-editor .ProseMirror .track-block-card {
position: relative;
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 8px;
background-color: color-mix(in srgb, var(--primary) 4%, transparent);
cursor: default;
transition: background-color 0.15s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-card.track-block-paused {
border-left-color: color-mix(in srgb, var(--foreground) 25%, transparent);
background-color: color-mix(in srgb, var(--muted) 30%, transparent);
opacity: 0.7;
}
.tiptap-editor .ProseMirror .track-block-card:hover {
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
}
.tiptap-editor .ProseMirror .track-block-wrapper.ProseMirror-selectednode .track-block-card {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
/* Delete button */
.tiptap-editor .ProseMirror .track-block-delete {
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
z-index: 1;
}
.tiptap-editor .ProseMirror .track-block-card:hover .track-block-delete {
opacity: 1;
}
.tiptap-editor .ProseMirror .track-block-delete:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
/* Collapsed row */
.tiptap-editor .ProseMirror .track-block-collapsed {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 32px 10px 12px;
cursor: pointer;
min-width: 0;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-chevron {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .track-block-chevron-open {
transform: rotate(90deg);
}
.tiptap-editor .ProseMirror .track-block-icon {
color: var(--primary);
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--primary);
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-summary {
font-size: 13px;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.tiptap-editor .ProseMirror .track-block-meta {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
flex-shrink: 0;
white-space: nowrap;
}
/* Re-run button */
.tiptap-editor .ProseMirror .track-block-run-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 35%, transparent);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s ease, color 0.1s ease, background-color 0.1s ease;
}
.tiptap-editor .ProseMirror .track-block-card:hover .track-block-run-btn {
opacity: 1;
}
.tiptap-editor .ProseMirror .track-block-run-btn:hover {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
.tiptap-editor .ProseMirror .track-block-run-btn:disabled {
opacity: 1;
color: var(--primary);
cursor: default;
background: none;
}
/* Running state */
.tiptap-editor .ProseMirror .track-block-card.track-block-running {
border-left-color: color-mix(in srgb, var(--primary) 70%, transparent);
animation: track-pulse 2s ease-in-out infinite;
}
@keyframes track-pulse {
0%, 100% { background-color: color-mix(in srgb, var(--primary) 4%, transparent); }
50% { background-color: color-mix(in srgb, var(--primary) 10%, transparent); }
}
.tiptap-editor .ProseMirror .track-block-meta-running {
color: var(--primary);
font-weight: 500;
}
/* Status bar */
.tiptap-editor .ProseMirror .track-block-status-bar {
padding: 6px 14px;
font-size: 12px;
line-height: 1.4;
border-top: 1px solid var(--border);
animation: track-status-fade-in 0.15s ease;
}
@keyframes track-status-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.tiptap-editor .ProseMirror .track-block-status-done {
color: color-mix(in srgb, var(--foreground) 65%, transparent);
}
.tiptap-editor .ProseMirror .track-block-status-error {
color: var(--destructive, #ef4444);
}
/* Badges */
.tiptap-editor .ProseMirror .track-block-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-badge-active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.tiptap-editor .ProseMirror .track-block-badge-paused {
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
/* Expanded area */
.tiptap-editor .ProseMirror .track-block-expanded {
border-top: 1px solid var(--border);
}
/* Tabs */
.tiptap-editor .ProseMirror .track-block-tabs {
display: flex;
gap: 0;
padding: 0 12px;
border-bottom: 1px solid var(--border);
}
.tiptap-editor .ProseMirror .track-block-tab {
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.1s ease, border-color 0.1s ease;
white-space: nowrap;
}
.tiptap-editor .ProseMirror .track-block-tab:hover {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .track-block-tab-active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tiptap-editor .ProseMirror .track-block-tab-raw {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Tab panel content */
.tiptap-editor .ProseMirror .track-block-panel {
padding: 12px 14px;
}
.tiptap-editor .ProseMirror .track-block-panel-text {
font-size: 13px;
line-height: 1.6;
color: var(--foreground);
white-space: pre-wrap;
user-select: text;
}
.tiptap-editor .ProseMirror .track-block-empty {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
font-style: italic;
}
.tiptap-editor .ProseMirror .track-block-markdown {
font-size: 13px;
line-height: 1.6;
user-select: text;
}
.tiptap-editor .ProseMirror .track-block-markdown > *:first-child {
margin-top: 0;
}
.tiptap-editor .ProseMirror .track-block-markdown > *:last-child {
margin-bottom: 0;
}
/* Metadata grid */
.tiptap-editor .ProseMirror .track-block-metadata-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.tiptap-editor .ProseMirror .track-block-metadata-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.tiptap-editor .ProseMirror .track-block-metadata-label {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
min-width: 70px;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-metadata-value {
font-size: 13px;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .track-block-metadata-value code {
font-size: 12px;
padding: 1px 5px;
border-radius: 3px;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
font-family: var(--font-mono, ui-monospace, monospace);
}
/* Raw editor */
.tiptap-editor .ProseMirror .track-block-raw-editor {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tiptap-editor .ProseMirror .track-block-textarea {
width: 100%;
font-size: 12px;
font-family: var(--font-mono, ui-monospace, monospace);
line-height: 1.5;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
color: var(--foreground);
resize: vertical;
}
.tiptap-editor .ProseMirror .track-block-textarea:focus {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
.tiptap-editor .ProseMirror .track-block-raw-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.tiptap-editor .ProseMirror .track-block-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.1s ease;
}
.tiptap-editor .ProseMirror .track-block-btn-secondary {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .track-block-btn-secondary:hover {
background: color-mix(in srgb, var(--foreground) 14%, transparent);
}
.tiptap-editor .ProseMirror .track-block-btn-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.tiptap-editor .ProseMirror .track-block-btn-primary:hover {
background: color-mix(in srgb, var(--primary) 85%, black);
}
/* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper,
.tiptap-editor .ProseMirror .embed-block-wrapper,

View file

@ -11,6 +11,7 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { buildCopilotAgent } from "../application/assistant/agent.js";
import { buildTrackRunAgent } from "../knowledge/track/run-agent.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
@ -372,6 +373,10 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return buildCopilotAgent();
}
if (id === "track-run") {
return buildTrackRunAgent();
}
if (id === 'note_creation') {
const raw = getNoteCreationRaw();
let agent: z.infer<typeof Agent> = {

View file

@ -25,6 +25,7 @@ import { isSignedIn } from "../../account/account.js";
import { getGatewayProvider } from "../../models/gateway.js";
import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js";
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
// Parser libraries are loaded dynamically inside parseFile.execute()
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
// Import paths are computed so esbuild cannot statically resolve them.
@ -1431,4 +1432,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
isAvailable: async () => isComposioConfigured(),
},
'update-track-content': {
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId"),
content: z.string().describe("The new content to place inside the track's target region"),
}),
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
try {
await updateContent(filePath, trackId, content);
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
};

View file

@ -0,0 +1,23 @@
import type { TrackEventType } from '@x/shared/dist/track-block.js';
type Handler = (event: TrackEventType) => void;
class TrackBus {
private subs: Handler[] = [];
publish(event: TrackEventType): void {
for (const handler of this.subs) {
handler(event);
}
}
subscribe(handler: Handler): () => void {
this.subs.push(handler);
return () => {
const idx = this.subs.indexOf(handler);
if (idx >= 0) this.subs.splice(idx, 1);
};
}
}
export const trackBus = new TrackBus();

View file

@ -0,0 +1,109 @@
import z from 'zod';
import fs from 'fs/promises';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { WorkDir } from '../../config/config.js';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { TrackStateSchema } from './types.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return [];
}
const lines = content.split('\n');
const blocks: z.infer<typeof TrackStateSchema>[] = [];
let i = 0;
const contentFenceStartMatcher = /<!--track-target:(\w+)-->/;
const contentFenceEndMatcher = /<!--\/track-target:(\w+)-->/;
while (i < lines.length) {
if (lines[i].trim() === '```track') {
const fenceStart = i;
i++;
const blockLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
blockLines.push(lines[i]);
i++;
}
try {
const data = parseYaml(blockLines.join('\n'));
const result = TrackBlockSchema.safeParse(data);
if (result.success) {
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
}
} catch { /* skip */ }
} else if (contentFenceStartMatcher.test(lines[i])) {
const match = contentFenceStartMatcher.exec(lines[i]);
if (match) {
const trackId = match[1];
// have we already collected this track block?
const existingBlock = blocks.find(b => b.track.trackId === trackId);
if (!existingBlock) {
i++;
continue;
}
const contentStart = i + 1;
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
i++;
}
const contentEnd = i;
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
}
}
i++;
}
return blocks;
}
export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const blocks = await fetchAll(filePath);
return blocks.find(b => b.track.trackId === trackId) ?? null;
}
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
let content = await fs.readFile(absPath(filePath), 'utf-8');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
const openIdx = content.indexOf(openTag);
const closeIdx = content.indexOf(closeTag);
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
} else {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const lines = content.split('\n');
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
const contentFence = [openTag, newContent, closeTag];
lines.splice(insertAt, 0, ...contentFence);
content = lines.join('\n');
}
await fs.writeFile(absPath(filePath), content, 'utf-8');
}
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
const block = await fetch(filepath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filepath}`);
}
block.track = { ...block.track, ...updates };
// read file contents
let content = await fs.readFile(absPath(filepath), 'utf-8');
const lines = content.split('\n');
const yaml = stringifyYaml(block.track).trimEnd();
const yamlLines = yaml ? yaml.split('\n') : [];
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
content = lines.join('\n');
await fs.writeFile(absPath(filepath), content, 'utf-8');
}

View file

@ -0,0 +1,65 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
import { WorkDir } from '../../config/config.js';
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that updates a specific section of a knowledge note.
You will receive a message containing a track instruction, the current content of the target region, and optionally some context. Your job is to follow the instruction and produce updated content.
# Background Mode
You are running as a background task there is no user present.
- Do NOT ask clarifying questions make reasonable assumptions
- Be concise and action-oriented just do the work
# The Knowledge Graph
The knowledge graph is stored as plain markdown in \`${WorkDir}/knowledge/\` (inside the workspace). It's organized into:
- **People/** Notes on individuals
- **Organizations/** Notes on companies
- **Projects/** Notes on initiatives
- **Topics/** Notes on recurring themes
Use workspace tools to search and read the knowledge graph for context.
# How to Access the Knowledge Graph
**CRITICAL:** Always include \`knowledge/\` in paths.
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\`
- \`workspace-readdir("knowledge/People")\`
**NEVER** use an empty path or root path.
# How to Write Your Result
Use the \`update-track-content\` tool to write your result. The message will tell you the file path and track ID.
- Produce the COMPLETE replacement content (not a diff)
- Preserve existing content that's still relevant
- Write in a clear, concise style appropriate for personal notes
# Web Search
You have access to \`web-search\` for tracks that need external information (news, trends, current events). Use it when the track instruction requires information beyond the knowledge graph.
# After You're Done
End your response with a brief summary of what you did (1-2 sentences).
`;
export function buildTrackRunAgent(): z.infer<typeof Agent> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
tools[name] = { type: 'builtin', name };
}
return {
name: 'track-run',
description: 'Background agent that updates track block content',
instructions: TRACK_RUN_INSTRUCTIONS,
tools,
};
}

View file

@ -0,0 +1,122 @@
import z from 'zod';
import { fetchAll, updateTrackBlock } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
import { trackBus } from './bus.js';
import type { TrackStateSchema } from './types.js';
export interface TrackUpdateResult {
trackId: string;
action: 'replace' | 'no_update';
contentBefore: string | null;
contentAfter: string | null;
summary: string | null;
error?: string;
}
// ---------------------------------------------------------------------------
// Agent run
// ---------------------------------------------------------------------------
function buildMessage(filePath: string, track: z.infer<typeof TrackStateSchema>, context?: string): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
**Time:** ${localNow} (${tz})
**Instruction:**
${track.track.instruction}
**Current content:**
${track.content || '(empty — first run)'}
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
if (context) {
msg += `\n\n**Context:**\n${context}`;
}
return msg;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Trigger an update for a specific track block.
* Can be called by any trigger system (manual, cron, event matching).
*/
export async function triggerTrackUpdate(
trackId: string,
filePath: string,
context?: string,
): Promise<TrackUpdateResult> {
console.log('triggerTrackUpdate', trackId, filePath, context);
const tracks = await fetchAll(filePath);
const track = tracks.find(t => t.track.trackId === trackId);
if (!track) {
return { trackId, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
}
const contentBefore = track.content;
// Emit start event — runId is set after agent run is created
const agentRun = await createRun({ agentId: 'track-run' });
await trackBus.publish({
type: 'track_run_start',
trackId,
filePath,
trigger: 'manual',
runId: agentRun.id,
});
try {
await createMessage(agentRun.id, buildMessage(filePath, track, context));
await waitForRunCompletion(agentRun.id);
const summary = await extractAgentResponse(agentRun.id);
const updatedTracks = await fetchAll(filePath);
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
const didUpdate = contentAfter !== contentBefore;
// Update track block metadata
await updateTrackBlock(filePath, trackId, {
lastRunAt: new Date().toISOString(),
lastRunId: agentRun.id,
lastRunSummary: summary ?? undefined,
});
await trackBus.publish({
type: 'track_run_complete',
trackId,
filePath,
runId: agentRun.id,
summary: summary ?? undefined,
});
return {
trackId,
action: didUpdate ? 'replace' : 'no_update',
contentBefore: contentBefore ?? null,
contentAfter: contentAfter ?? null,
summary,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await trackBus.publish({
type: 'track_run_complete',
trackId,
filePath,
runId: agentRun.id,
error: msg,
});
return { trackId, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
}
}

View file

@ -0,0 +1,9 @@
import z from "zod";
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
export const TrackStateSchema = z.object({
track: TrackBlockSchema,
fenceStart: z.number(),
fenceEnd: z.number(),
content: z.string(),
});

View file

@ -9,6 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
export * as serviceEvents from './service-events.js'
export * as inlineTask from './inline-task.js';
export * as blocks from './blocks.js';
export * as trackBlock from './track-block.js';
export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export { PrefixLogger };

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { TrackEvent } from './track-block.js';
import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
@ -193,6 +194,10 @@ const ipcSchemas = {
req: ServiceEvent,
res: z.null(),
},
'tracks:events': {
req: TrackEvent,
res: z.null(),
},
'models:list': {
req: z.null(),
res: z.object({
@ -560,6 +565,18 @@ const ipcSchemas = {
response: z.string().nullable(),
}),
},
// Track channels
'track:run': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
summary: z.string().optional(),
error: z.string().optional(),
}),
},
// Billing channels
'billing:getInfo': {
req: z.null(),

View file

@ -0,0 +1,35 @@
import z from 'zod';
export const TrackBlockSchema = z.object({
trackId: z.string(),
instruction: z.string(),
matchCriteria: z.string().optional(),
active: z.boolean().default(true),
lastRunAt: z.string().optional(),
lastRunId: z.string().optional(),
lastRunSummary: z.string().optional(),
});
// Track bus events
export const TrackRunStartEvent = z.object({
type: z.literal('track_run_start'),
trackId: z.string(),
filePath: z.string(),
trigger: z.enum(['timed', 'manual', 'event']),
runId: z.string(),
});
export const TrackRunCompleteEvent = z.object({
type: z.literal('track_run_complete'),
trackId: z.string(),
filePath: z.string(),
runId: z.string(),
error: z.string().optional(),
summary: z.string().optional(),
});
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
export type TrackBlock = z.infer<typeof TrackBlockSchema>;
export type TrackResult = z.infer<typeof TrackResultSchema>;
export type TrackEventType = z.infer<typeof TrackEvent>;

3
apps/x/pnpm-lock.yaml generated
View file

@ -265,6 +265,9 @@ importers:
use-stick-to-bottom:
specifier: ^1.1.1
version: 1.1.1(react@19.2.3)
yaml:
specifier: ^2.8.2
version: 2.8.2
zod:
specifier: ^4.2.1
version: 4.2.1