This commit is contained in:
Ramnique Singh 2026-04-09 18:26:31 +05:30
parent 0b859d129e
commit cb7a2913ce
29 changed files with 1619 additions and 28 deletions

View file

@ -44,6 +44,7 @@ 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';
/**
* Convert markdown to a styled HTML document for PDF/DOCX export.
@ -755,6 +756,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

@ -22,6 +22,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";

View file

@ -54,6 +54,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'
@ -467,6 +468,10 @@ export function MarkdownEditor({
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)
// @track mention state
const [activeTrackMention, setActiveTrackMention] = useState<RowboatMentionMatch | null>(null)
const [trackAnchorTop, setTrackAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
// @ mention autocomplete state (analogous to wiki-link state)
const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null)
const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null)
@ -569,6 +574,7 @@ export function MarkdownEditor({
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
ImageBlockExtension,
EmbedBlockExtension,
ChartBlockExtension,
@ -791,6 +797,8 @@ export function MarkdownEditor({
if (!selection.empty) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
setActiveTrackMention(null)
setTrackAnchorTop(null)
return
}
@ -798,40 +806,62 @@ export function MarkdownEditor({
if ($from.parent.type.spec.code) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
setActiveTrackMention(null)
setTrackAnchorTop(null)
return
}
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
const textBefore = text.slice(0, $from.parentOffset)
// Helper to compute anchor position
const computeAnchor = () => {
const wrapper = wrapperRef.current
if (!wrapper) return null
const coords = editor.view.coordsAtPos(selection.from)
const wrapperRect = wrapper.getBoundingClientRect()
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
const pmRect = proseMirrorEl?.getBoundingClientRect()
return {
top: coords.top - wrapperRect.top + wrapper.scrollTop,
left: pmRect ? pmRect.left - wrapperRect.left : 0,
width: pmRect ? pmRect.width : wrapperRect.width,
}
}
// Match @rowboat at a word boundary (preceded by nothing or whitespace)
const match = textBefore.match(/(^|\s)@rowboat$/)
if (!match) {
const rowboatMatch = textBefore.match(/(^|\s)@rowboat$/)
if (rowboatMatch) {
setActiveTrackMention(null)
setTrackAnchorTop(null)
const triggerStart = textBefore.length - '@rowboat'.length
const from = selection.from - (textBefore.length - triggerStart)
const to = selection.from
setActiveRowboatMention({ range: { from, to } })
setRowboatAnchorTop(computeAnchor())
return
}
// Match @track at a word boundary
const trackMatch = textBefore.match(/(^|\s)@track$/)
if (trackMatch) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
const triggerStart = textBefore.length - '@track'.length
const from = selection.from - (textBefore.length - triggerStart)
const to = selection.from
setActiveTrackMention({ range: { from, to } })
setTrackAnchorTop(computeAnchor())
return
}
const triggerStart = textBefore.length - '@rowboat'.length
const from = selection.from - (textBefore.length - triggerStart)
const to = selection.from
setActiveRowboatMention({ range: { from, to } })
const wrapper = wrapperRef.current
if (!wrapper) {
setRowboatAnchorTop(null)
return
}
const coords = editor.view.coordsAtPos(selection.from)
const wrapperRect = wrapper.getBoundingClientRect()
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
const pmRect = proseMirrorEl?.getBoundingClientRect()
setRowboatAnchorTop({
top: coords.top - wrapperRect.top + wrapper.scrollTop,
left: pmRect ? pmRect.left - wrapperRect.left : 0,
width: pmRect ? pmRect.width : wrapperRect.width,
})
// No match
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
setActiveTrackMention(null)
setTrackAnchorTop(null)
}, [editor])
// Detect @ trigger for autocomplete popover (similar to [[ detection)
@ -1213,6 +1243,45 @@ export function MarkdownEditor({
setRowboatAnchorTop(null)
}, [editor, rowboatBlockEdit])
const handleTrackSubmit = useCallback(async (instruction: string) => {
if (!editor || !activeTrackMention || !notePath) return
// Remove the @track text from the editor
editor.chain().focus().deleteRange(activeTrackMention.range).run()
setActiveTrackMention(null)
// Create a background copilot run
const run = await window.ipc.invoke('runs:create', { agentId: 'copilot' })
const message = [
`[TRACK REQUEST] Create a track block in this note.`,
``,
`First, load the "create-track" skill for guidance. Then create the track block.`,
``,
`User's request: "${instruction}"`,
`Note path: ${notePath}`,
].join('\n')
await window.ipc.invoke('runs:createMessage', {
runId: run.id,
message: [
{ type: 'attachment', path: notePath, filename: notePath.split('/').pop() ?? 'note.md', mimeType: 'text/markdown' },
{ type: 'text', text: message },
] as unknown as string,
})
// Wait for run completion to dismiss spinner
return new Promise<void>((resolve) => {
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
const e = event as { type: string; runId: string }
if (e.runId === run.id && (e.type === 'run-processing-end' || e.type === 'error' || e.type === 'run-stopped')) {
cleanup()
resolve()
}
}) as (event: null) => void)
})
}, [editor, activeTrackMention, notePath])
const handleScroll = useCallback(() => {
updateWikiLinkState()
updateAtMentionState()
@ -1445,6 +1514,17 @@ export function MarkdownEditor({
setRowboatAnchorTop(null)
}}
/>
<RowboatMentionPopover
open={Boolean(activeTrackMention && trackAnchorTop)}
anchor={trackAnchorTop}
prefix="@track"
loadingText="Setting up track..."
onAdd={handleTrackSubmit}
onClose={() => {
setActiveTrackMention(null)
setTrackAnchorTop(null)
}}
/>
</div>
</div>
)

View file

@ -5,12 +5,14 @@ interface RowboatMentionPopoverProps {
open: boolean
anchor: { top: number; left: number; width: number } | null
initialText?: string
prefix?: string
loadingText?: string
onAdd: (instruction: string) => void | Promise<void>
onRemove?: () => void
onClose: () => void
}
export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
export function RowboatMentionPopover({ open, anchor, initialText = '', prefix = '@rowboat', loadingText, onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
@ -64,7 +66,7 @@ export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, o
>
<div className="relative border border-input rounded-md bg-popover shadow-sm">
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">{prefix}</span>
<textarea
ref={textareaRef}
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
@ -100,7 +102,12 @@ export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, o
disabled={!text.trim() || loading}
onClick={() => void handleSubmit()}
>
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
{loading ? (
<>
<Loader2 className="size-3 animate-spin" />
{loadingText && <span className="ml-1">{loadingText}</span>}
</>
) : 'Add'}
</button>
</div>
</div>

View file

@ -0,0 +1,354 @@
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'
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'
type RunStatus = 'idle' | 'running' | 'done' | 'error'
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 [runStatus, setRunStatus] = useState<RunStatus>('idle')
const [runSummary, setRunSummary] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const parsed = useMemo(() => {
try {
const data = parseYaml(raw)
if (data && typeof data === 'object') return data as Record<string, unknown>
} catch { /* ignore */ }
return null
}, [raw])
const trackId = (parsed?.trackId as string) ?? ''
const instruction = (parsed?.instruction as string) ?? ''
const matchCriteria = (parsed?.matchCriteria as string) ?? ''
const active = (parsed?.active as boolean) ?? true
const lastRunAt = (parsed?.lastRunAt as string) ?? ''
const notePath = extension.options.notePath
// Listen to service events for this track's run status
useEffect(() => {
if (!trackId || !notePath) return
const cleanup = window.ipc.on('services:events', ((event: unknown) => {
const e = event as { service?: string; type?: string; config?: Record<string, unknown>; summary?: Record<string, unknown>; outcome?: string }
if (e.service !== 'tracks') return
const eventTrackId = e.config?.trackId ?? e.summary?.trackId
const eventFilePath = e.config?.filePath ?? e.summary?.filePath
if (eventTrackId !== trackId || eventFilePath !== notePath) return
if (e.type === 'run_start') {
setRunStatus('running')
setRunSummary(null)
} else if (e.type === 'run_complete') {
setRunStatus(e.outcome === 'error' ? 'error' : 'done')
// Auto-dismiss after a few seconds
setTimeout(() => {
setRunStatus('idle')
setRunSummary(null)
}, 5000)
}
}) as (event: null) => void)
return cleanup
}, [trackId, notePath])
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 || !notePath) return
setRunStatus('running')
setRunSummary(null)
try {
const trackFilePath = notePath.replace(/^knowledge\//, '')
const result = await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath })
setRunSummary(result.summary ?? result.error ?? null)
setRunStatus(result.success ? 'done' : 'error')
setTimeout(() => {
setRunStatus('idle')
setRunSummary(null)
}, 5000)
} catch (err) {
console.error('[TrackBlock] Run failed:', err)
setRunStatus('error')
setTimeout(() => { setRunStatus('idle'); setRunSummary(null) }, 5000)
}
}, [runStatus, trackId, notePath])
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>
)}
</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

@ -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,

18
apps/x/changes Normal file
View file

@ -0,0 +1,18 @@
- in ParsedTrack schema, not sure why we need startLine and endLine. We should discard this as this information might be outdated by the time we're ready to replace content
- also, what does blockData contain?
- i want you to plan the "scanner" from scratch and not look at inline tasks. Basically:
- have a function that will use fs.readdir(recursive, withfiletypes), that will only pick .md files. send each filepath to extractTracks(filepath)
- extractTracks(filepath) reads the file line-by-line. If the current line is exactly "```track", then read content until ending code-fence is read. Attempt to parse as track block json (zod parse)
- write the code for these functions (high-level) in the plan
- writeTrackResult signature should be (filePath, trackId, content). This function should auto-find a target output region with the id related to track id. If not found, it should create it just below the ending code-fence of the respective track (write code for this fund function in the plan)
- not sure why we need updateTrackBlockData?? I'm guessing track can have a lastRunAt time that can be auto-updated by writeTrackResult itself?
furthermore I'm not sure how the worker logic is being planned out here. What I had in mind was that the sync scripts (which exist today, like the Gmail sync) create event files on this. For example, we can have an events directory inside the work directory (which is a daughter of work). Inside the events directory, there should be two subdirectories: pending and done.
So, for example, when the Gmail sync script runs and finishes, let's say it has found 10 emails, it fetched 10 emails, it should create an event file for each of those emails. 10 event files lined up in the pending directory.
Now, on the other hand, there is a worker loop which polls the pending directory. By the way, the files that were created should use the monotonically increasing ID. So that they are sorted by time. We are already using that ID for generating run IDs, so just check in the code there is a class to do that.
So, anyway, back to the worker. The worker loop wakes up, does a read directory call on this pending directory, picks let's say 5 files at one go, then for each of those files, I think what it should do is concurrently fire off promises. Each of those callbacks basically does a do pass LLM thing. That is the architecture I had in mind. Can you revise it accordingly or let me know if you see any issues?

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

@ -21,3 +21,31 @@ export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
tools,
};
}
const BACKGROUND_PREAMBLE = `# Background Mode
You are running as a background task there is no user present to answer questions.
Rules for background mode:
- Do NOT ask clarifying questions make reasonable assumptions
- Do NOT use executeCommand shell commands are not available in this context
- DO use workspace tools freely (readFile, writeFile, edit, grep, glob, etc.)
- DO use loadSkill if you need specialized guidance
- Be concise and action-oriented just do the work
`;
export async function buildCopilotBackgroundAgent(): Promise<z.infer<typeof Agent>> {
const backgroundTools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
backgroundTools[name] = { type: "builtin", name };
}
const instructions = await buildCopilotInstructions();
return {
name: "copilot-background",
description: "Copilot running in background mode (no user interaction)",
instructions: BACKGROUND_PREAMBLE + instructions,
tools: backgroundTools,
};
}

View file

@ -0,0 +1,75 @@
import { toJSONSchema } from 'zod';
import { trackBlock } from '@x/shared';
const trackBlockJsonSchema = JSON.stringify(toJSONSchema(trackBlock.TrackBlockSchema), null, 2);
const skill = `
# Create Track Block Skill
You are creating a track block in the user's note. Track blocks define sections that automatically
update themselves when relevant events arrive (emails, meetings, messages).
## Track Block Format
The track block is a code fence with language "track" containing YAML that matches this schema:
\`\`\`json
${trackBlockJsonSchema}
\`\`\`
Example:
\`\`\`track
trackId: trk_acme_updates
instruction: |
Maintain a summary of the latest developments with Acme Corp,
including deal status, key decisions, action items, and meeting outcomes.
matchCriteria: |
Acme Corp: emails, meetings, deal progress, decisions,
action items, status changes
active: true
\`\`\`
## Rules
- Use workspace-edit to insert the track block at the appropriate position in the note
- Do NOT use executeCommand shell commands are not available in this context
- Do NOT ask clarifying questions make reasonable assumptions about what to track
- Generate a descriptive trackId (e.g., "trk_acme_deal", "trk_hiring_pipeline"). Lowercase with underscores.
- Write matchCriteria BROADLY better to over-match than under-match
- Write instruction SPECIFICALLY it controls exactly what content is produced
- Read the note first to understand context and find the right insertion point
- Do NOT create the target region (<!--track-target:...-->) the system creates it automatically
- The content inside the code fence MUST be valid YAML, not JSON
- Use YAML pipe \`|\` for instruction and matchCriteria so they can span multiple lines for readability
## Examples
User: "track updates for Acme Corp"
\`\`\`track
trackId: trk_acme_updates
instruction: |
Maintain a running summary of the latest developments with Acme Corp,
including deal status, key decisions, action items, and meeting outcomes.
matchCriteria: |
Acme Corp: emails, meetings, deal progress, decisions,
action items, status changes
active: true
\`\`\`
User: "monitor hiring pipeline"
\`\`\`track
trackId: trk_hiring_pipeline
instruction: |
Track the current state of the hiring pipeline including open roles,
candidates in progress, interview outcomes, and offers.
matchCriteria: |
Hiring: candidates, interviews, offers, job postings,
recruiting updates, new hires
active: true
\`\`\`
`;
export default skill;

View file

@ -12,6 +12,7 @@ import createPresentationsSkill from "./create-presentations/skill.js";
import appNavigationSkill from "./app-navigation/skill.js";
import composioIntegrationSkill from "./composio-integration/skill.js";
import createTrackSkill from "./create-track/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -96,6 +97,12 @@ const definitions: SkillDefinition[] = [
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
content: appNavigationSkill,
},
{
id: "create-track",
title: "Create Track Block",
summary: "Create a track block in a knowledge note that auto-updates with incoming events (emails, meetings, messages).",
content: createTrackSkill,
},
];
const skillEntries = definitions.map((definition) => ({

View file

@ -23,6 +23,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 { writeTrackResult } from "../../knowledge/track/scanner.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.
@ -1324,4 +1325,21 @@ 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 writeTrackResult(filePath, trackId, content);
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

@ -6,6 +6,7 @@ import container from '../../di/container.js';
import { IGranolaConfigRepo } from './repo.js';
import { serviceLogger } from '../../services/service_logger.js';
import { limitEventItems } from '../limit_event_items.js';
import { writeEventFile } from '../track/events.js';
import {
GetDocumentsResponse,
SyncState,
@ -433,6 +434,13 @@ async function syncNotes(): Promise<void> {
fs.writeFileSync(filePath, markdown);
await writeEventFile({
source: 'meeting',
type: lastSyncedAt ? 'meeting.updated' : 'meeting.synced',
createdAt: docDate.toISOString(),
payload: markdown,
});
if (lastSyncedAt) {
console.log(`[Granola] Updated: ${filename}`);
updatedCount++;

View file

@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { writeEventFile } from './track/events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
@ -228,7 +229,6 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
const result = await saveEvent(event, syncDir);
const attachmentsSaved = await processAttachments(drive, event, syncDir);
currentEventIds.add(event.id);
if (result.changed) {
await ensureRun();
changedTitles.push(result.title);
@ -243,6 +243,16 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
await ensureRun();
attachmentCount += attachmentsSaved;
}
if (result.changed) {
const eventTime = event.start?.dateTime || event.start?.date || new Date().toISOString();
await writeEventFile({
source: 'calendar',
type: result.isNew ? 'event.created' : 'event.updated',
createdAt: eventTime,
payload: JSON.stringify(event, null, 2),
});
}
}
}
}
@ -505,6 +515,14 @@ async function performSyncComposio() {
} else {
updatedCount++;
}
const eventTime = new Date().toISOString();
await writeEventFile({
source: 'calendar',
type: saveResult.isNew ? 'event.created' : 'event.updated',
createdAt: eventTime,
payload: JSON.stringify(event, null, 2),
});
}
}
}

View file

@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js';
import { FirefliesClientFactory } from './fireflies-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { writeEventFile } from './track/events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
@ -583,6 +584,13 @@ async function syncMeetings() {
fs.writeFileSync(filePath, markdown);
console.log(`[Fireflies] Saved: ${filename}`);
await writeEventFile({
source: 'meeting',
type: 'meeting.synced',
createdAt: meetingDate.toISOString(),
payload: markdown,
});
syncedIds.add(meetingId);
newCount++;
processedInBatch++;

View file

@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogle } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { writeEventFile } from './track/events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
@ -172,6 +173,13 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
console.log(`Synced Thread: ${subject} (${threadId})`);
await writeEventFile({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
} catch (error) {
console.error(`Error processing thread ${threadId}:`, error);
}
@ -596,6 +604,13 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
newestDate = tryParseDate(parsed.date);
await writeEventFile({
source: 'gmail',
type: 'email.synced',
createdAt: newestDate?.toISOString() ?? new Date().toISOString(),
payload: mdContent,
});
} else {
const firstParsed = parseMessageData(messages[0]);
let mdContent = `# ${firstParsed.subject}\n\n`;
@ -617,6 +632,13 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
await writeEventFile({
source: 'gmail',
type: 'email.synced',
createdAt: newestDate?.toISOString() ?? new Date().toISOString(),
payload: mdContent,
});
}
if (!newestDate) return null;

View file

@ -0,0 +1,40 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import container from '../../di/container.js';
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
const PENDING_DIR = path.join(WorkDir, 'events', 'pending');
/**
* Write a KnowledgeEvent to the events/pending/ directory.
* Filename is a monotonically increasing ID so events sort by creation order.
* Call this function in chronological order (oldest event first) within a sync batch
* to ensure correct ordering.
*/
export async function writeEventFile(event: Omit<KnowledgeEvent, 'id'>): Promise<void> {
fs.mkdirSync(PENDING_DIR, { recursive: true });
const idGen = container.resolve<IMonotonicallyIncreasingIdGenerator>('idGenerator');
const id = await idGen.next();
const fullEvent: KnowledgeEvent = { id, ...event };
const filePath = path.join(PENDING_DIR, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(fullEvent, null, 2), 'utf-8');
}
/**
* Create a synthetic re-run event targeting a specific track block.
* This bypasses Pass 1 routing and goes straight to Pass 2 for the target track.
*/
export async function createRerunEvent(trackId: string, filePath: string): Promise<void> {
await writeEventFile({
source: 'timer',
type: 'manual.rerun',
createdAt: new Date().toISOString(),
payload: `Manual re-run triggered for track ${trackId}`,
targetTrackId: trackId,
targetFilePath: filePath,
});
}

View file

@ -0,0 +1,88 @@
import { generateObject } from 'ai';
import { trackBlock } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import container from '../../di/container.js';
import type { IModelConfigRepo } from '../../models/repo.js';
import { createProvider } from '../../models/models.js';
import { isSignedIn } from '../../account/account.js';
import { getGatewayProvider } from '../../models/gateway.js';
import type { ParsedTrack } from './types.js';
const BATCH_SIZE = 20;
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
You will receive an event (something that happened an email, meeting, message, etc.) and a list of track blocks. Each track block has:
- trackId: a unique identifier
- matchCriteria: a description of what kinds of signals are relevant to this track
Your job is to identify which track blocks MIGHT be relevant to this event.
Rules:
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
- Prefer false positives over false negatives. It is much better to include a track that turns out to be irrelevant than to miss one that was relevant.
- Only exclude tracks that are CLEARLY and OBVIOUSLY irrelevant to the event.
- Do not attempt to judge whether the event contains enough information to update the track. That is handled by a later stage.
- Return an empty list only if no tracks are relevant at all.`;
async function resolveModel() {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
const modelId = config.knowledgeGraphModel
|| (signedIn ? 'gpt-5.4' : config.model);
return provider.languageModel(modelId);
}
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
const trackList = batch
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n matchCriteria: ${t.matchCriteria}`)
.join('\n\n');
return `## Event
Source: ${event.source}
Type: ${event.type}
Time: ${event.createdAt}
${event.payload}
## Track Blocks
${trackList}`;
}
export async function findCandidates(
event: KnowledgeEvent,
allTracks: ParsedTrack[],
): Promise<ParsedTrack[]> {
// Short-circuit for targeted re-runs — skip LLM routing entirely
if (event.targetTrackId) {
const target = allTracks.find(t => t.trackId === event.targetTrackId);
return target ? [target] : [];
}
const filtered = allTracks.filter(t =>
t.active && t.instruction && t.matchCriteria
);
if (filtered.length === 0) return [];
const model = await resolveModel();
const candidateIds = new Set<string>();
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
const batch = filtered.slice(i, i + BATCH_SIZE);
const { object } = await generateObject({
model,
system: ROUTING_SYSTEM_PROMPT,
prompt: buildRoutingPrompt(event, batch),
schema: trackBlock.Pass1OutputSchema,
});
object.candidateTrackIds.forEach(id => candidateIds.add(id));
}
return filtered.filter(t => candidateIds.has(t.trackId));
}

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,123 @@
import { extractTracks } from './scanner.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
import { WorkDir } from '../../config/config.js';
import { serviceLogger } from '../../services/service_logger.js';
import type { ParsedTrack } 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(track: ParsedTrack, context?: string): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const workspaceRelPath = track.filePath.replace(WorkDir, '').replace(/^\//, '');
let msg = `Update track **${track.trackId}** in \`${workspaceRelPath}\`.
**Time:** ${localNow} (${tz})
**Instruction:**
${track.instruction}
**Current content:**
${track.currentContent || '(empty — first run)'}
Use \`update-track-content\` with filePath=\`${workspaceRelPath}\` and trackId=\`${track.trackId}\`.`;
if (context) {
msg += `\n\n**Context:**\n${context}`;
}
return msg;
}
async function run(track: ParsedTrack, context?: string): Promise<string | null> {
const agentRun = await createRun({ agentId: 'track-run' });
await createMessage(agentRun.id, buildMessage(track, context));
await waitForRunCompletion(agentRun.id);
return extractAgentResponse(agentRun.id);
}
// ---------------------------------------------------------------------------
// 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 extractTracks(filePath);
const track = tracks.find(t => t.trackId === trackId);
if (!track) {
return { trackId, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
}
const contentBefore = track.currentContent;
const svc = await serviceLogger.startRun({
service: 'tracks',
message: `Running track ${trackId}`,
trigger: 'manual',
config: { trackId, filePath },
});
try {
const summary = await run(track, context);
const updatedTracks = await extractTracks(filePath);
const contentAfter = updatedTracks.find(t => t.trackId === trackId)?.currentContent;
const didUpdate = contentAfter !== contentBefore;
await serviceLogger.log({
type: 'run_complete',
service: svc.service,
runId: svc.runId,
level: 'info',
message: `Track ${trackId} ${didUpdate ? 'updated' : 'unchanged'}`,
durationMs: Date.now() - svc.startedAt,
outcome: didUpdate ? 'ok' : 'idle',
summary: { trackId, filePath, action: didUpdate ? 'replace' : 'no_update' },
});
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 serviceLogger.log({
type: 'run_complete',
service: svc.service,
runId: svc.runId,
level: 'error',
message: `Track ${trackId} failed: ${msg}`,
durationMs: Date.now() - svc.startedAt,
outcome: 'error',
summary: { trackId, filePath, error: msg },
});
return { trackId, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
}
}

View file

@ -0,0 +1,159 @@
import fs from 'fs/promises';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { trackBlock } from '@x/shared';
import { WorkDir } from '../../config/config.js';
import { commitAll } from '../version_history.js';
import type { ParsedTrack, TrackBlockLocation } from './types.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
// ---------------------------------------------------------------------------
// Block location helpers
// ---------------------------------------------------------------------------
export function findTrackBlock(lines: string[], trackId: string): TrackBlockLocation | null {
let i = 0;
while (i < lines.length) {
if (lines[i].trim() === '```track') {
const fenceStart = i;
const contentStart = i + 1;
i++;
const jsonLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
jsonLines.push(lines[i]);
i++;
}
const fenceEnd = i;
try {
const data = parseYaml(jsonLines.join('\n'));
if (data && typeof data === 'object' && data.trackId === trackId) {
return { fenceStart, contentStart, fenceEnd, data };
}
} catch { /* skip malformed blocks */ }
}
i++;
}
return null;
}
export function findAllTrackBlocks(lines: string[]): TrackBlockLocation[] {
const blocks: TrackBlockLocation[] = [];
let i = 0;
while (i < lines.length) {
if (lines[i].trim() === '```track') {
const fenceStart = i;
const contentStart = i + 1;
i++;
const jsonLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
jsonLines.push(lines[i]);
i++;
}
const fenceEnd = i;
try {
const data = parseYaml(jsonLines.join('\n'));
if (data && typeof data === 'object') {
blocks.push({ fenceStart, contentStart, fenceEnd, data });
}
} catch { /* skip */ }
}
i++;
}
return blocks;
}
// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------
export async function scanAllTracks(): Promise<ParsedTrack[]> {
if (!(await fs.stat(absPath(''))).isDirectory()) return [];
const results: ParsedTrack[] = [];
const entries = await fs.readdir(absPath(''), { recursive: true, withFileTypes: true });
for await (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const filePath = path.join(entry.parentPath, entry.name);
results.push(...(await extractTracks(filePath)));
}
return results;
}
export async function extractTracks(filePath: string): Promise<ParsedTrack[]> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return [];
}
const lines = content.split('\n');
const blocks = findAllTrackBlocks(lines);
return blocks
.map(block => {
const parsed = trackBlock.TrackBlockSchema.safeParse(block.data);
if (!parsed.success) return null;
return {
...parsed.data,
filePath,
currentContent: readTargetContent(content, parsed.data.trackId),
};
})
.filter((t): t is ParsedTrack => t !== null);
}
// ---------------------------------------------------------------------------
// Target region read/write
// ---------------------------------------------------------------------------
export function readTargetContent(content: string, trackId: string): string | null {
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) return null;
return content.slice(openIdx + openTag.length, closeIdx).trim();
}
export async function writeTrackResult(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) {
// Replace existing target region content
content = content.slice(0, openIdx + openTag.length)
+ '\n' + newContent + '\n'
+ content.slice(closeIdx);
} else {
// No target region — find the track's closing code fence and insert after it
const lines = content.split('\n');
const loc = findTrackBlock(lines, trackId);
if (!loc) return;
lines.splice(loc.fenceEnd + 1, 0, '', openTag, newContent, closeTag);
content = lines.join('\n');
}
// Update lastRunAt in the track block JSON
const lines = content.split('\n');
const loc = findTrackBlock(lines, trackId);
if (loc) {
loc.data.lastRunAt = new Date().toISOString();
lines.splice(loc.contentStart, loc.fenceEnd - loc.contentStart, stringifyYaml(loc.data).trimEnd());
content = lines.join('\n');
}
await fs.writeFile(absPath(filePath), content, 'utf-8');
commitAll('Track update: ' + trackId, 'Tracks');
}

View file

@ -0,0 +1,3 @@
// Track service — currently unused.
// Will be repurposed for event-matching and cron trigger systems.
// The core execution engine is in runner.ts (triggerTrackUpdate).

View file

@ -0,0 +1,15 @@
export interface ParsedTrack {
trackId: string;
instruction: string;
matchCriteria: string;
active: boolean;
filePath: string;
currentContent: string | null;
}
export interface TrackBlockLocation {
fenceStart: number;
contentStart: number;
fenceEnd: number;
data: Record<string, unknown>;
}

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

@ -559,6 +559,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

@ -10,6 +10,7 @@ export const ServiceName = z.enum([
'email_labeling',
'note_tagging',
'agent_notes',
'tracks',
]);
const ServiceEventBase = z.object({

View file

@ -0,0 +1,47 @@
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(),
});
export const TrackResultSchema = z.object({
trackId: z.string(),
action: z.enum(['no_update', 'replace']),
contentBefore: z.string().nullable(),
contentAfter: z.string().nullable(),
summary: z.string().optional(),
error: z.string().optional(),
});
export const KnowledgeEventSchema = z.object({
id: z.string(),
source: z.enum(['gmail', 'slack', 'calendar', 'meeting', 'voice', 'timer']),
type: z.string(),
createdAt: z.string(),
payload: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
targetTrackId: z.string().optional(),
targetFilePath: z.string().optional(),
candidateTrackIds: z.array(z.string()).optional(),
trackResults: z.array(TrackResultSchema).optional(),
error: z.string().optional(),
});
export const Pass1OutputSchema = z.object({
candidateTrackIds: z.array(z.string()),
});
export const Pass2OutputSchema = z.object({
action: z.enum(['no_update', 'replace']),
content: z.string().nullable(),
});
export type TrackBlock = z.infer<typeof TrackBlockSchema>;
export type TrackResult = z.infer<typeof TrackResultSchema>;
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
export type Pass2Output = z.infer<typeof Pass2OutputSchema>;

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

@ -262,6 +262,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
@ -3596,6 +3599,7 @@ packages:
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@ -4987,6 +4991,7 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.0:
@ -4995,7 +5000,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
@ -7182,7 +7187,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
temp@0.9.4:
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}