mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
wip
This commit is contained in:
parent
0b859d129e
commit
cb7a2913ce
29 changed files with 1619 additions and 28 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
354
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
354
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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
18
apps/x/changes
Normal 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?
|
||||
|
||||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
40
apps/x/packages/core/src/knowledge/track/events.ts
Normal file
40
apps/x/packages/core/src/knowledge/track/events.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
88
apps/x/packages/core/src/knowledge/track/routing.ts
Normal file
88
apps/x/packages/core/src/knowledge/track/routing.ts
Normal 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));
|
||||
}
|
||||
65
apps/x/packages/core/src/knowledge/track/run-agent.ts
Normal file
65
apps/x/packages/core/src/knowledge/track/run-agent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
123
apps/x/packages/core/src/knowledge/track/runner.ts
Normal file
123
apps/x/packages/core/src/knowledge/track/runner.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
159
apps/x/packages/core/src/knowledge/track/scanner.ts
Normal file
159
apps/x/packages/core/src/knowledge/track/scanner.ts
Normal 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');
|
||||
}
|
||||
3
apps/x/packages/core/src/knowledge/track/service.ts
Normal file
3
apps/x/packages/core/src/knowledge/track/service.ts
Normal 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).
|
||||
15
apps/x/packages/core/src/knowledge/track/types.ts
Normal file
15
apps/x/packages/core/src/knowledge/track/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const ServiceName = z.enum([
|
|||
'email_labeling',
|
||||
'note_tagging',
|
||||
'agent_notes',
|
||||
'tracks',
|
||||
]);
|
||||
|
||||
const ServiceEventBase = z.object({
|
||||
|
|
|
|||
47
apps/x/packages/shared/src/track-block.ts
Normal file
47
apps/x/packages/shared/src/track-block.ts
Normal 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
9
apps/x/pnpm-lock.yaml
generated
|
|
@ -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==}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue