mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 02:46:25 +02:00
basic-block
This commit is contained in:
parent
ab0147d475
commit
66dc065996
18 changed files with 1220 additions and 0 deletions
|
|
@ -44,6 +44,8 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|||
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
|
||||
|
||||
/**
|
||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||
|
|
@ -362,6 +364,19 @@ export async function startServicesWatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
let tracksWatcher: (() => void) | null = null;
|
||||
export function startTracksWatcher(): void {
|
||||
if (tracksWatcher) return;
|
||||
tracksWatcher = trackBus.subscribe((event) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('tracks:events', event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function stopRunsWatcher(): void {
|
||||
if (runsWatcher) {
|
||||
runsWatcher();
|
||||
|
|
@ -758,6 +773,11 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
// Track handler
|
||||
'track:run': async (_event, args) => {
|
||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
||||
},
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startServicesWatcher,
|
||||
startTracksWatcher,
|
||||
startWorkspaceWatcher,
|
||||
stopRunsWatcher,
|
||||
stopServicesWatcher,
|
||||
|
|
@ -22,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
|||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
|
|
@ -228,6 +230,9 @@ app.whenReady().then(async () => {
|
|||
// start services watcher
|
||||
startServicesWatcher();
|
||||
|
||||
// start tracks watcher
|
||||
startTracksWatcher();
|
||||
|
||||
// start gmail sync
|
||||
initGmailSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import TaskList from '@tiptap/extension-task-list'
|
|||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { TrackBlockExtension } from '@/extensions/track-block'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
|
|
@ -638,6 +639,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
|
|
|
|||
336
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
336
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { z } from 'zod'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, ChevronRight, X, Clock, Code2, Check, Play, Loader2 } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
type Tab = 'metadata' | 'instruction' | 'criteria'
|
||||
function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('instruction')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(raw))
|
||||
} catch { return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const matchCriteria = track?.matchCriteria ?? ''
|
||||
const active = track?.active ?? true
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
// Track run status from the global hook
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const runStatus = runState.status
|
||||
const runSummary = runState.summary ?? runState.error ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setRawDraft(raw)
|
||||
setEditingRaw(true)
|
||||
}
|
||||
|
||||
const handleSaveRaw = () => {
|
||||
updateAttributes({ data: rawDraft })
|
||||
setEditingRaw(false)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
|
||||
const handleRun = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (runStatus === 'running' || !trackId || !trackFilePath) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath })
|
||||
} catch (err) {
|
||||
console.error('[TrackBlock] Run failed:', err)
|
||||
}
|
||||
}, [runStatus, trackId, trackFilePath])
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'instruction', label: 'Instruction' },
|
||||
{ key: 'criteria', label: 'Match Criteria' },
|
||||
{ key: 'metadata', label: 'Metadata' },
|
||||
]
|
||||
|
||||
const isRunning = runStatus === 'running'
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="track-block-wrapper" data-type="track-block">
|
||||
<div
|
||||
className={`track-block-card ${!active ? 'track-block-paused' : ''} ${isRunning ? 'track-block-running' : ''}`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="track-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete track block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
{/* Collapsed view */}
|
||||
<div
|
||||
className="track-block-collapsed"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`track-block-chevron ${expanded ? 'track-block-chevron-open' : ''}`}
|
||||
/>
|
||||
<Radio size={14} className="track-block-icon" />
|
||||
<span className="track-block-label">Track</span>
|
||||
{!active && <span className="track-block-badge track-block-badge-paused">paused</span>}
|
||||
<span className="track-block-summary">{truncate(instruction, 60)}</span>
|
||||
{lastRunAt && !isRunning && (
|
||||
<span className="track-block-meta">
|
||||
<Clock size={11} />
|
||||
{formatDateTime(lastRunAt)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && (
|
||||
<span className="track-block-meta track-block-meta-running">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
Running…
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="track-block-run-btn"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning}
|
||||
aria-label="Run track"
|
||||
title="Run track"
|
||||
>
|
||||
{isRunning
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Play size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{runSummary && runStatus !== 'running' && (
|
||||
<div className={`track-block-status-bar ${runStatus === 'error' ? 'track-block-status-error' : 'track-block-status-done'}`}>
|
||||
{runSummary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded view */}
|
||||
{expanded && (
|
||||
<div className="track-block-expanded">
|
||||
<div className="track-block-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-block-tab ${activeTab === tab.key ? 'track-block-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`track-block-tab track-block-tab-raw ${editingRaw ? 'track-block-tab-active' : ''}`}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
<Code2 size={12} />
|
||||
Edit Raw
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingRaw ? (
|
||||
<div className="track-block-raw-editor">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="track-block-textarea"
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancelEdit()
|
||||
}
|
||||
if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleSaveRaw()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="track-block-raw-actions">
|
||||
<button className="track-block-btn track-block-btn-secondary" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="track-block-btn track-block-btn-primary" onClick={handleSaveRaw}>
|
||||
<Check size={12} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="track-block-panel">
|
||||
{activeTab === 'instruction' && (
|
||||
<div className="track-block-panel-text">
|
||||
{instruction
|
||||
? <Streamdown className="track-block-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-block-empty">No instruction set</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'criteria' && (
|
||||
<div className="track-block-panel-text">
|
||||
{matchCriteria
|
||||
? <Streamdown className="track-block-markdown">{matchCriteria}</Streamdown>
|
||||
: <span className="track-block-empty">No match criteria set</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'metadata' && (
|
||||
<div className="track-block-metadata-grid">
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Track ID</span>
|
||||
<span className="track-block-metadata-value"><code>{trackId}</code></span>
|
||||
</div>
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Status</span>
|
||||
<span className="track-block-metadata-value">
|
||||
<span className={`track-block-badge ${active ? 'track-block-badge-active' : 'track-block-badge-paused'}`}>
|
||||
{active ? 'active' : 'paused'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{lastRunAt && (
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Last run</span>
|
||||
<span className="track-block-metadata-value">{formatDateTime(lastRunAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{lastRunId && (
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Run ID</span>
|
||||
<span className="track-block-metadata-value"><code>{lastRunId}</code></span>
|
||||
</div>
|
||||
)}
|
||||
{lastRunSummary && (
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Summary</span>
|
||||
<span className="track-block-metadata-value">{lastRunSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-track')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TrackBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```track\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
72
apps/x/apps/renderer/src/hooks/use-track-status.ts
Normal file
72
apps/x/apps/renderer/src/hooks/use-track-status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TrackEvent } from '@x/shared/dist/track-block.js';
|
||||
|
||||
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface TrackState {
|
||||
status: TrackRunStatus;
|
||||
runId?: string;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Module-level store — shared across all hook consumers, subscribed once
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change
|
||||
let store = new Map<string, TrackState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
|
||||
store = new Map(store);
|
||||
fn(store);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function ensureSubscription() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
|
||||
const key = `${event.trackId}:${event.filePath}`;
|
||||
|
||||
if (event.type === 'track_run_start') {
|
||||
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
|
||||
} else if (event.type === 'track_run_complete') {
|
||||
updateStore(s => s.set(key, {
|
||||
status: event.error ? 'error' : 'done',
|
||||
runId: event.runId,
|
||||
summary: event.summary ?? null,
|
||||
error: event.error ?? null,
|
||||
}));
|
||||
// Auto-clear after 5 seconds
|
||||
setTimeout(() => {
|
||||
updateStore(s => s.delete(key));
|
||||
}, 5000);
|
||||
}
|
||||
}) as (event: z.infer<typeof TrackEvent>) => void);
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
ensureSubscription();
|
||||
listeners.add(onStoreChange);
|
||||
return () => { listeners.delete(onStoreChange); };
|
||||
}
|
||||
|
||||
function getSnapshot(): Map<string, TrackState> {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map of all track run states, keyed by "trackId:filePath".
|
||||
*
|
||||
* Usage in a track block component:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
|
||||
*
|
||||
* Usage for a global indicator:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
|
||||
*/
|
||||
export function useTrackStatus(): Map<string, TrackState> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { isSignedIn } from "../../account/account.js";
|
|||
import { getGatewayProvider } from "../../models/gateway.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
|
||||
// Import paths are computed so esbuild cannot statically resolve them.
|
||||
|
|
@ -1431,4 +1432,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
'update-track-content': {
|
||||
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
|
||||
trackId: z.string().describe("The track block's trackId"),
|
||||
content: z.string().describe("The new content to place inside the track's target region"),
|
||||
}),
|
||||
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
|
||||
try {
|
||||
await updateContent(filePath, trackId, content);
|
||||
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
|
||||
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
23
apps/x/packages/core/src/knowledge/track/bus.ts
Normal file
23
apps/x/packages/core/src/knowledge/track/bus.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { TrackEventType } from '@x/shared/dist/track-block.js';
|
||||
|
||||
type Handler = (event: TrackEventType) => void;
|
||||
|
||||
class TrackBus {
|
||||
private subs: Handler[] = [];
|
||||
|
||||
publish(event: TrackEventType): void {
|
||||
for (const handler of this.subs) {
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(handler: Handler): () => void {
|
||||
this.subs.push(handler);
|
||||
return () => {
|
||||
const idx = this.subs.indexOf(handler);
|
||||
if (idx >= 0) this.subs.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const trackBus = new TrackBus();
|
||||
109
apps/x/packages/core/src/knowledge/track/fileops.ts
Normal file
109
apps/x/packages/core/src/knowledge/track/fileops.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import z from 'zod';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
import { TrackStateSchema } from './types.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
function absPath(filePath: string): string {
|
||||
return path.join(KNOWLEDGE_DIR, filePath);
|
||||
}
|
||||
|
||||
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const blocks: z.infer<typeof TrackStateSchema>[] = [];
|
||||
let i = 0;
|
||||
const contentFenceStartMatcher = /<!--track-target:(\w+)-->/;
|
||||
const contentFenceEndMatcher = /<!--\/track-target:(\w+)-->/;
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim() === '```track') {
|
||||
const fenceStart = i;
|
||||
i++;
|
||||
const blockLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '```') {
|
||||
blockLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
try {
|
||||
const data = parseYaml(blockLines.join('\n'));
|
||||
const result = TrackBlockSchema.safeParse(data);
|
||||
if (result.success) {
|
||||
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} else if (contentFenceStartMatcher.test(lines[i])) {
|
||||
const match = contentFenceStartMatcher.exec(lines[i]);
|
||||
if (match) {
|
||||
const trackId = match[1];
|
||||
// have we already collected this track block?
|
||||
const existingBlock = blocks.find(b => b.track.trackId === trackId);
|
||||
if (!existingBlock) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const contentStart = i + 1;
|
||||
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
|
||||
i++;
|
||||
}
|
||||
const contentEnd = i;
|
||||
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> {
|
||||
const blocks = await fetchAll(filePath);
|
||||
return blocks.find(b => b.track.trackId === trackId) ?? null;
|
||||
}
|
||||
|
||||
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
|
||||
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
const openIdx = content.indexOf(openTag);
|
||||
const closeIdx = content.indexOf(closeTag);
|
||||
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
|
||||
} else {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
|
||||
const contentFence = [openTag, newContent, closeTag];
|
||||
lines.splice(insertAt, 0, ...contentFence);
|
||||
content = lines.join('\n');
|
||||
}
|
||||
await fs.writeFile(absPath(filePath), content, 'utf-8');
|
||||
}
|
||||
|
||||
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
|
||||
const block = await fetch(filepath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filepath}`);
|
||||
}
|
||||
block.track = { ...block.track, ...updates };
|
||||
|
||||
// read file contents
|
||||
let content = await fs.readFile(absPath(filepath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yaml = stringifyYaml(block.track).trimEnd();
|
||||
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
content = lines.join('\n');
|
||||
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
122
apps/x/packages/core/src/knowledge/track/runner.ts
Normal file
122
apps/x/packages/core/src/knowledge/track/runner.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import z from 'zod';
|
||||
import { fetchAll, updateTrackBlock } from './fileops.js';
|
||||
import { createRun, createMessage } from '../../runs/runs.js';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||
import { trackBus } from './bus.js';
|
||||
import type { TrackStateSchema } from './types.js';
|
||||
|
||||
export interface TrackUpdateResult {
|
||||
trackId: string;
|
||||
action: 'replace' | 'no_update';
|
||||
contentBefore: string | null;
|
||||
contentAfter: string | null;
|
||||
summary: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildMessage(filePath: string, track: z.infer<typeof TrackStateSchema>, context?: string): string {
|
||||
const now = new Date();
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
|
||||
|
||||
**Time:** ${localNow} (${tz})
|
||||
|
||||
**Instruction:**
|
||||
${track.track.instruction}
|
||||
|
||||
**Current content:**
|
||||
${track.content || '(empty — first run)'}
|
||||
|
||||
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
|
||||
|
||||
if (context) {
|
||||
msg += `\n\n**Context:**\n${context}`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trigger an update for a specific track block.
|
||||
* Can be called by any trigger system (manual, cron, event matching).
|
||||
*/
|
||||
export async function triggerTrackUpdate(
|
||||
trackId: string,
|
||||
filePath: string,
|
||||
context?: string,
|
||||
): Promise<TrackUpdateResult> {
|
||||
console.log('triggerTrackUpdate', trackId, filePath, context);
|
||||
const tracks = await fetchAll(filePath);
|
||||
const track = tracks.find(t => t.track.trackId === trackId);
|
||||
if (!track) {
|
||||
return { trackId, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
|
||||
}
|
||||
|
||||
const contentBefore = track.content;
|
||||
|
||||
// Emit start event — runId is set after agent run is created
|
||||
const agentRun = await createRun({ agentId: 'track-run' });
|
||||
|
||||
await trackBus.publish({
|
||||
type: 'track_run_start',
|
||||
trackId,
|
||||
filePath,
|
||||
trigger: 'manual',
|
||||
runId: agentRun.id,
|
||||
});
|
||||
|
||||
try {
|
||||
await createMessage(agentRun.id, buildMessage(filePath, track, context));
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
const summary = await extractAgentResponse(agentRun.id);
|
||||
|
||||
const updatedTracks = await fetchAll(filePath);
|
||||
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
|
||||
const didUpdate = contentAfter !== contentBefore;
|
||||
|
||||
// Update track block metadata
|
||||
await updateTrackBlock(filePath, trackId, {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastRunId: agentRun.id,
|
||||
lastRunSummary: summary ?? undefined,
|
||||
});
|
||||
|
||||
await trackBus.publish({
|
||||
type: 'track_run_complete',
|
||||
trackId,
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
summary: summary ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
trackId,
|
||||
action: didUpdate ? 'replace' : 'no_update',
|
||||
contentBefore: contentBefore ?? null,
|
||||
contentAfter: contentAfter ?? null,
|
||||
summary,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
await trackBus.publish({
|
||||
type: 'track_run_complete',
|
||||
trackId,
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
error: msg,
|
||||
});
|
||||
|
||||
return { trackId, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
|
||||
}
|
||||
}
|
||||
9
apps/x/packages/core/src/knowledge/track/types.ts
Normal file
9
apps/x/packages/core/src/knowledge/track/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import z from "zod";
|
||||
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
|
||||
|
||||
export const TrackStateSchema = z.object({
|
||||
track: TrackBlockSchema,
|
||||
fenceStart: z.number(),
|
||||
fenceEnd: z.number(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { TrackEvent } from './track-block.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
|
|
@ -193,6 +194,10 @@ const ipcSchemas = {
|
|||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'tracks:events': {
|
||||
req: TrackEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'models:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -560,6 +565,18 @@ const ipcSchemas = {
|
|||
response: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Track channels
|
||||
'track:run': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
summary: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
// Billing channels
|
||||
'billing:getInfo': {
|
||||
req: z.null(),
|
||||
|
|
|
|||
35
apps/x/packages/shared/src/track-block.ts
Normal file
35
apps/x/packages/shared/src/track-block.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import z from 'zod';
|
||||
|
||||
export const TrackBlockSchema = z.object({
|
||||
trackId: z.string(),
|
||||
instruction: z.string(),
|
||||
matchCriteria: z.string().optional(),
|
||||
active: z.boolean().default(true),
|
||||
lastRunAt: z.string().optional(),
|
||||
lastRunId: z.string().optional(),
|
||||
lastRunSummary: z.string().optional(),
|
||||
});
|
||||
|
||||
// Track bus events
|
||||
export const TrackRunStartEvent = z.object({
|
||||
type: z.literal('track_run_start'),
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
trigger: z.enum(['timed', 'manual', 'event']),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const TrackRunCompleteEvent = z.object({
|
||||
type: z.literal('track_run_complete'),
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
|
||||
|
||||
export type TrackBlock = z.infer<typeof TrackBlockSchema>;
|
||||
export type TrackResult = z.infer<typeof TrackResultSchema>;
|
||||
export type TrackEventType = z.infer<typeof TrackEvent>;
|
||||
3
apps/x/pnpm-lock.yaml
generated
3
apps/x/pnpm-lock.yaml
generated
|
|
@ -265,6 +265,9 @@ importers:
|
|||
use-stick-to-bottom:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(react@19.2.3)
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue