From e54b5cd27fdf09c2c222e2ea69562ed3ca841cf9 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 11:59:37 +0530 Subject: [PATCH] Background agents (#530) a common place to track and add background agents --- apps/x/apps/main/src/ipc.ts | 23 + apps/x/apps/renderer/src/App.tsx | 171 +++++- .../src/components/background-agents-view.tsx | 250 ++++++++ .../src/components/sidebar-content.tsx | 20 + .../skills/background-agents/skill.ts | 555 ------------------ .../src/application/assistant/skills/index.ts | 7 - .../assistant/skills/tracks/skill.ts | 14 + .../core/src/knowledge/track/fileops.ts | 118 +++- apps/x/packages/shared/src/ipc.ts | 29 + 9 files changed, 596 insertions(+), 591 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/background-agents-view.tsx delete mode 100644 apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 056bb4c3..682d46e6 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,6 +52,8 @@ import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; import { fetchYaml, + listNotesWithTracks, + setNoteTracksActive, updateTrackBlock, replaceTrackBlockYaml, deleteTrackBlock, @@ -135,6 +137,14 @@ function resolveShellPath(filePath: string): string { return workspace.resolveWorkspacePath(filePath); } +function toKnowledgeTrackPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized.startsWith('knowledge/')) { + throw new Error('Track note path must be within knowledge/') + } + return normalized.slice('knowledge/'.length) +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -832,6 +842,19 @@ export function setupIpcHandlers() { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, + 'track:setNoteActive': async (_event, args) => { + try { + const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active); + if (!note) return { success: false, error: 'No track blocks found in note' }; + return { success: true, note }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:listNotes': async () => { + const notes = await listNotesWithTracks(); + return { notes }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 07f91e0b..c2e35cb2 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; +import { BackgroundAgentsView } from '@/components/background-agents-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -142,6 +143,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' +const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -271,6 +273,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH +const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -327,6 +330,24 @@ const buildSuggestedTopicExplorePrompt = ({ ].join('\n') } +const buildBackgroundAgentSetupPrompt = () => [ + 'Help me set up a background agent.', + 'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.', + 'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.', + 'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.', + 'Start with a short, plain-English explanation of what a background agent is.', + 'Do not make the explanation too terse.', + 'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.', + 'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.', + 'In the first reply, tell me that you will create this in my Tasks folder by default.', + 'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.', + 'Then ask only what I want it to monitor or update and how often I want it to run.', + 'Keep it concise and friendly, but not abrupt.', + 'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.', + 'Do not create or modify anything yet.', + 'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.', +].join('\n') + const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -508,6 +529,7 @@ type ViewState = | { type: 'graph' } | { type: 'task'; name: string } | { type: 'suggested-topics' } + | { type: 'background-agents' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -521,12 +543,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=&... + * Shape: rowboat://open?type=&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph * task: ?type=task&name=daily-brief * suggested-topics: ?type=suggested-topics + * background-agents: ?type=background-agents */ function parseDeepLink(input: string): ViewState | null { const SCHEME = 'rowboat://' @@ -551,6 +574,8 @@ function parseDeepLink(input: string): ViewState | null { } case 'suggested-topics': return { type: 'suggested-topics' } + case 'background-agents': + return { type: 'background-agents' } default: return null } @@ -656,7 +681,13 @@ function App() { const [isGraphOpen, setIsGraphOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) - const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null) + const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false) + const [expandedFrom, setExpandedFrom] = useState<{ + path: string | null + graph: boolean + suggestedTopics: boolean + backgroundAgents: boolean + } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], @@ -977,6 +1008,7 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' + if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2660,6 +2692,8 @@ function App() { if (existingTab) { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(path) return } @@ -2667,6 +2701,8 @@ function App() { setFileTabs(prev => [...prev, { id, path }]) setActiveFileTabId(id) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2685,16 +2721,26 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + return + } + if (isBackgroundAgentsTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) return } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) @@ -2723,6 +2769,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2736,13 +2783,21 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + } else if (isBackgroundAgentsTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2773,8 +2828,13 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + setExpandedFrom({ + path: selectedPath, + graph: isGraphOpen, + suggestedTopics: isSuggestedTopicsOpen, + backgroundAgents: isBackgroundAgentsOpen, + }) } else { setExpandedFrom(null) } @@ -2782,7 +2842,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + setIsBackgroundAgentsOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2897,27 +2958,40 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + setExpandedFrom({ + path: selectedPath, + graph: isGraphOpen, + suggestedTopics: isSuggestedTopicsOpen, + backgroundAgents: isBackgroundAgentsOpen, + }) } dismissBrowserOverlay() setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, dismissBrowserOverlay]) + setIsBackgroundAgentsOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + } else if (expandedFrom.backgroundAgents) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -2927,11 +3001,12 @@ function App() { const currentViewState = React.useMemo(() => { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (isBackgroundAgentsOpen) return { type: 'background-agents' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -2988,6 +3063,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureBackgroundAgentsFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -2997,6 +3083,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3011,6 +3098,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3023,6 +3111,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3035,8 +3124,20 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) ensureSuggestedTopicsFileTab() return + case 'background-agents': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) + ensureBackgroundAgentsFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3045,6 +3146,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3052,7 +3154,7 @@ function App() { } return } - }, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3374,7 +3476,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3452,15 +3554,17 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen ? SUGGESTED_TOPICS_TAB_PATH + : isBackgroundAgentsOpen + ? BACKGROUND_AGENTS_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3515,7 +3619,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3540,7 +3644,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3662,14 +3766,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4253,7 +4357,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4270,7 +4374,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4303,7 +4407,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4314,7 +4418,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4338,14 +4442,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4375,6 +4479,8 @@ function App() { onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} + isBackgroundAgentsOpen={isBackgroundAgentsOpen} + onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && ( + +

+ Notes that contain track blocks. Toggle a note inactive to pause every background agent in it. +

+ +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+
+ +
+

{error}

+
+ ) : notes.length === 0 ? ( +
+
+ +
+

+ No notes with background agents yet. +

+
+ ) : ( +
+ + + + + + + + + + + {notes.map((note) => { + const isUpdating = updatingPaths.has(note.path) + return ( + + + + + + + ) + })} + +
NoteCreated dateLast ranState
+
+
+ + + {note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'} + +
+
+ {stripKnowledgePrefix(note.path)} +
+
+
+ {formatDateLabel(note.createdAt)} + + {formatDateTimeLabel(note.lastRunAt)} + +
+ {isUpdating ? ( + + ) : ( +
+
+
+ )} +
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 9c50c334..dc49307c 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -214,6 +214,8 @@ type SidebarContentPanelProps = { onToggleBrowser?: () => void isSuggestedTopicsOpen?: boolean onOpenSuggestedTopics?: () => void + isBackgroundAgentsOpen?: boolean + onOpenBackgroundAgents?: () => void } & React.ComponentProps const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -491,6 +493,8 @@ export function SidebarContentPanel({ onToggleBrowser, isSuggestedTopicsOpen = false, onOpenSuggestedTopics, + isBackgroundAgentsOpen = false, + onOpenBackgroundAgents, ...props }: SidebarContentPanelProps) { const { activeSection, setActiveSection } = useSidebarSection() @@ -506,6 +510,7 @@ export function SidebarContentPanel({ const isMeetingQuickActionSelected = isMeetingActionActive const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen + const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen const handleRowboatLogin = useCallback(async () => { try { @@ -679,6 +684,21 @@ export function SidebarContentPanel({ Suggested Topics )} + {onOpenBackgroundAgents && ( + + )} diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts deleted file mode 100644 index b8e481b6..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts +++ /dev/null @@ -1,555 +0,0 @@ -export const skill = String.raw` -# Background Agents - -Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace. - -## Core Concepts - -**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. - -- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter -- Agents configure a model, tools (in frontmatter), and instructions (in the body) -- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** -- **"Workflows" are just agents that orchestrate other agents** by having them as tools -- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root - -## How multi-agent workflows work - -1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + ` -2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below) -3. The orchestrator calls other agents as tools when needed -4. Data flows through tool call parameters and responses - -## Scheduling Background Agents - -Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root. - -### Schedule Configuration File - -` + "```json" + ` -{ - "agents": { - "agent_name": { - "schedule": { ... }, - "enabled": true - } - } -} -` + "```" + ` - -### Schedule Types - -**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat). - -**1. Cron Schedule** - Runs at exact times defined by cron expression -` + "```json" + ` -{ - "schedule": { - "type": "cron", - "expression": "0 8 * * *" - }, - "enabled": true -} -` + "```" + ` - -Common cron expressions: -- ` + "`*/5 * * * *`" + ` - Every 5 minutes -- ` + "`0 8 * * *`" + ` - Every day at 8am -- ` + "`0 9 * * 1`" + ` - Every Monday at 9am -- ` + "`0 0 1 * *`" + ` - First day of every month at midnight - -**2. Window Schedule** - Runs once during a time window -` + "```json" + ` -{ - "schedule": { - "type": "window", - "cron": "0 0 * * *", - "startTime": "08:00", - "endTime": "10:00" - }, - "enabled": true -} -` + "```" + ` - -The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am"). - -**3. Once Schedule** - Runs exactly once at a specific time -` + "```json" + ` -{ - "schedule": { - "type": "once", - "runAt": "2024-02-05T10:30:00" - }, - "enabled": true -} -` + "```" + ` - -Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix). - -### Starting Message - -You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `. - -` + "```json" + ` -{ - "schedule": { "type": "cron", "expression": "0 8 * * *" }, - "enabled": true, - "startingMessage": "Please summarize my emails from the last 24 hours" -} -` + "```" + ` - -### Description - -You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI. - -` + "```json" + ` -{ - "schedule": { "type": "cron", "expression": "0 8 * * *" }, - "enabled": true, - "description": "Summarizes emails and calendar events every morning" -} -` + "```" + ` - -### Complete Schedule Example - -` + "```json" + ` -{ - "agents": { - "daily_digest": { - "schedule": { - "type": "cron", - "expression": "0 8 * * *" - }, - "enabled": true, - "description": "Daily email and calendar summary", - "startingMessage": "Summarize my emails and calendar for today" - }, - "morning_briefing": { - "schedule": { - "type": "window", - "cron": "0 0 * * *", - "startTime": "07:00", - "endTime": "09:00" - }, - "enabled": true, - "description": "Morning news and updates briefing" - }, - "one_time_setup": { - "schedule": { - "type": "once", - "runAt": "2024-12-01T12:00:00" - }, - "enabled": true, - "description": "One-time data migration task" - } - } -} -` + "```" + ` - -### Schedule State (Read-Only) - -**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. - -The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root: -- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) -- ` + "`lastRunAt`" + `: When the agent last ran -- ` + "`nextRunAt`" + `: When the agent will run next -- ` + "`lastError`" + `: Error message if the last run failed -- ` + "`runCount`" + `: Total number of runs - -When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `. - -## Agent File Format - -Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. - -### Basic Structure -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - tool_key: - type: builtin - name: tool_name ---- -# Instructions - -Your detailed instructions go here in Markdown format. -` + "```" + ` - -### Frontmatter Fields -- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') -- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json -- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions - -### Instructions (Body) -The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. - -### Naming Rules -- Agent filename determines the agent name (without .md extension) -- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent" -- Use lowercase with underscores for multi-word names -- No spaces or special characters in names -- **The agent name in agent-schedule.json must match the filename** (without .md) - -### Agent Format Example -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query ---- -# Web Search Agent - -You are a web search agent. When asked a question: - -1. Use the search tool to find relevant information -2. Summarize the results clearly -3. Cite your sources - -Be concise and accurate. -` + "```" + ` - -## Tool Types & Schemas - -Tools in agents must follow one of three types. Each has specific required fields. - -### 1. Builtin Tools -Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) - -**YAML Schema:** -` + "```yaml" + ` -tool_key: - type: builtin - name: tool_name -` + "```" + ` - -**Required fields:** -- ` + "`type`" + `: Must be "builtin" -- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile") - -**Example:** -` + "```yaml" + ` -bash: - type: builtin - name: executeCommand -` + "```" + ` - -**Available builtin tools:** -- ` + "`executeCommand`" + ` - Execute shell commands -- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations -- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations -- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management -- ` + "`analyzeAgent`" + ` - Analyze agent structure -- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management -- ` + "`loadSkill`" + ` - Load skill guidance - -### 2. MCP Tools -Tools from external MCP servers (APIs, databases, web scraping, etc.) - -**YAML Schema:** -` + "```yaml" + ` -tool_key: - type: mcp - name: tool_name_from_server - description: What the tool does - mcpServerName: server_name_from_config - inputSchema: - type: object - properties: - param: - type: string - description: Parameter description - required: - - param -` + "```" + ` - -**Required fields:** -- ` + "`type`" + `: Must be "mcp" -- ` + "`name`" + `: Exact tool name from MCP server -- ` + "`description`" + `: What the tool does (helps agent understand when to use it) -- ` + "`mcpServerName`" + `: Server name from config/mcp.json -- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters - -**Example:** -` + "```yaml" + ` -search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query -` + "```" + ` - -**Important:** -- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server -- Copy the schema exactly—don't modify property types or structure -- Only include ` + "`required`" + ` array if parameters are mandatory - -### 3. Agent Tools (for chaining agents) -Reference other agents as tools to build multi-agent workflows - -**YAML Schema:** -` + "```yaml" + ` -tool_key: - type: agent - name: target_agent_name -` + "```" + ` - -**Required fields:** -- ` + "`type`" + `: Must be "agent" -- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory) - -**Example:** -` + "```yaml" + ` -summariser: - type: agent - name: summariser_agent -` + "```" + ` - -**How it works:** -- Use ` + "`type: agent`" + ` to call other agents as tools -- The target agent will be invoked with the parameters you pass -- Results are returned as tool output -- This is how you build multi-agent workflows -- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `) - -## Complete Multi-Agent Workflow Example - -**Email digest workflow** - This is all done through agents calling other agents: - -**1. Task-specific agent** (` + "`agents/email_reader.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - read_file: - type: builtin - name: workspace-readFile - list_dir: - type: builtin - name: workspace-readdir ---- -# Email Reader Agent - -Read emails from the gmail_sync folder and extract key information. -Look for unread or recent emails and summarize the sender, subject, and key points. -Don't ask for human input. -` + "```" + ` - -**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - email_reader: - type: agent - name: email_reader - write_file: - type: builtin - name: workspace-writeFile ---- -# Daily Summary Agent - -1. Use the email_reader tool to get email summaries -2. Create a consolidated daily digest -3. Save the digest to ~/Desktop/daily_digest.md - -Don't ask for human input. -` + "```" + ` - -Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions. - -**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - daily_summary: - type: agent - name: daily_summary - search: - type: mcp - name: search - mcpServerName: exa - description: Search the web for news - inputSchema: - type: object - properties: - query: - type: string - description: Search query ---- -# Morning Briefing Workflow - -Create a morning briefing: - -1. Get email digest using daily_summary -2. Search for relevant news using the search tool -3. Compile a comprehensive morning briefing - -Execute these steps in sequence. Don't ask for human input. -` + "```" + ` - -**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `: -` + "```json" + ` -{ - "agents": { - "morning_briefing": { - "schedule": { - "type": "cron", - "expression": "0 7 * * *" - }, - "enabled": true, - "startingMessage": "Create my morning briefing for today" - } - } -} -` + "```" + ` - -This schedules the morning briefing workflow to run every day at 7am local time. - -## Naming and organization rules -- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- When referencing an agent as a tool, use its filename without extension -- When scheduling an agent, use its filename without extension in agent-schedule.json -- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users - -## Best practices for background agents -1. **Single responsibility**: Each agent should do one specific thing well -2. **Clear delegation**: Agent instructions should explicitly say when to call other agents -3. **Autonomous operation**: Add "Don't ask for human input" for background agents -4. **Data passing**: Make it clear what data to extract and pass between agents -5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") -6. **Orchestration**: Create a top-level agent that coordinates the workflow -7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks -8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene -9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations -10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md" - -## Validation & Best Practices - -### CRITICAL: Schema Compliance -- Agent files MUST be valid Markdown with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent") -- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema -- Agent tools MUST reference existing agent files -- Invalid agents will fail to load and prevent workflow execution - -### File Creation/Update Process -1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter -2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + ` -3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent -4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `) -5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + ` - -### Common Validation Errors to Avoid - -❌ **WRONG - Missing frontmatter delimiters:** -` + "```markdown" + ` -model: gpt-5.1 -# My Agent -Instructions here -` + "```" + ` - -❌ **WRONG - Invalid YAML indentation:** -` + "```markdown" + ` ---- -tools: -bash: - type: builtin ---- -` + "```" + ` -(bash should be indented under tools) - -❌ **WRONG - Invalid tool type:** -` + "```yaml" + ` -tools: - tool1: - type: custom - name: something -` + "```" + ` -(type must be builtin, mcp, or agent) - -❌ **WRONG - Unquoted strings containing colons:** -` + "```yaml" + ` -tools: - search: - description: Number of results (default: 8) -` + "```" + ` -(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `) - -❌ **WRONG - MCP tool missing required fields:** -` + "```yaml" + ` -tools: - search: - type: mcp - name: firecrawl_search -` + "```" + ` -(Missing: description, mcpServerName, inputSchema) - -✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 ---- -# Simple Agent - -Do simple tasks as instructed. -` + "```" + ` - -✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string ---- -# Search Agent - -Use the search tool to find information on the web. -` + "```" + ` - -## Capabilities checklist -1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing -2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes -3. Validate YAML frontmatter syntax before creating/updating agents -4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update -5. When creating multi-agent workflows, create an orchestrator agent -6. Add other agents as tools with ` + "`type: agent`" + ` for chaining -7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations -8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) -9. Confirm work done and outline next steps once changes are complete -`; - -export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 6d3cdc5b..f4ba9b1d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -7,7 +7,6 @@ import draftEmailsSkill from "./draft-emails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; -import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; import appNavigationSkill from "./app-navigation/skill.js"; @@ -65,12 +64,6 @@ const definitions: SkillDefinition[] = [ summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.", content: organizeFilesSkill, }, - { - id: "background-agents", - title: "Background Agents", - summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.", - content: backgroundAgentsSkill, - }, { id: "builtin-tools", title: "Builtin Tools Reference", diff --git a/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts b/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts index 17521806..c9624c66 100644 --- a/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts @@ -349,6 +349,20 @@ In that flow: 6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. 7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed. +### Background agent setup flow + +Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet. + +In this flow, treat "background agent" and "track block" as the same feature. The user-facing term can stay "background agent", but the implementation is a track block inside a note. Do **not** claim these are different systems, and do **not** redirect the user toward standalone agent files or ` + "`" + `agent-schedule.json` + "`" + ` unless they explicitly ask for that architecture. + +In that flow: +1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run. +2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder or there is a real ambiguity you cannot resolve. +3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder. +4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists. +5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup instead of bouncing back to ask. +6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. + ## The Exact Text to Insert Write it verbatim like this (including the blank line between fence and target): diff --git a/apps/x/packages/core/src/knowledge/track/fileops.ts b/apps/x/packages/core/src/knowledge/track/fileops.ts index bd731823..b7ade8de 100644 --- a/apps/x/packages/core/src/knowledge/track/fileops.ts +++ b/apps/x/packages/core/src/knowledge/track/fileops.ts @@ -70,6 +70,122 @@ export async function fetch(filePath: string, trackId: string): Promise b.track.trackId === trackId) ?? null; } +type TrackNoteSummary = { + path: string; + trackCount: number; + createdAt: string | null; + lastRunAt: string | null; + isActive: boolean; +}; + +async function summarizeTrackNote( + filePath: string, + tracks: z.infer[], +): Promise { + if (tracks.length === 0) return null; + + const stats = await fs.stat(absPath(filePath)); + const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs; + + let latestRunAt: string | null = null; + let latestRunMs = -1; + for (const { track } of tracks) { + if (!track.lastRunAt) continue; + const candidateMs = Date.parse(track.lastRunAt); + if (Number.isNaN(candidateMs) || candidateMs <= latestRunMs) continue; + latestRunMs = candidateMs; + latestRunAt = track.lastRunAt; + } + + return { + path: `knowledge/${filePath}`, + trackCount: tracks.length, + createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null, + lastRunAt: latestRunAt, + isActive: tracks.every(({ track }) => track.active !== false), + }; +} + +export async function listNotesWithTracks(): Promise { + async function walk(relativeDir = ''): Promise { + const dirPath = absPath(relativeDir); + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + + const childRelPath = relativeDir + ? path.posix.join(relativeDir, entry.name) + : entry.name; + + if (entry.isDirectory()) { + files.push(...await walk(childRelPath)); + continue; + } + + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + files.push(childRelPath); + } + } + + return files; + } catch { + return []; + } + } + + const markdownFiles = await walk(); + const notes = await Promise.all(markdownFiles.map(async (relativePath) => { + try { + const tracks = await fetchAll(relativePath); + return await summarizeTrackNote(relativePath, tracks); + } catch { + return null; + } + })); + + return notes + .filter((note): note is TrackNoteSummary => note !== null) + .sort((a, b) => { + const aName = path.basename(a.path, '.md').toLowerCase(); + const bName = path.basename(b.path, '.md').toLowerCase(); + if (aName !== bName) return aName.localeCompare(bName); + return a.path.localeCompare(b.path); + }); +} + +export async function setNoteTracksActive(filePath: string, active: boolean): Promise { + return withFileLock(absPath(filePath), async () => { + const blocks = await fetchAll(filePath); + if (blocks.length === 0) return null; + + const alreadyMatches = blocks.every(({ track }) => (track.active !== false) === active); + if (alreadyMatches) { + return summarizeTrackNote(filePath, blocks); + } + + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const lines = content.split('\n'); + const updatedBlocks = blocks + .map((block) => ({ + ...block, + track: { ...block.track, active }, + })) + .sort((a, b) => b.fenceStart - a.fenceStart); + + for (const block of updatedBlocks) { + const yaml = stringifyYaml(block.track).trimEnd(); + const yamlLines = yaml ? yaml.split('\n') : []; + lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); + } + + await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + return summarizeTrackNote(filePath, updatedBlocks); + }); +} + /** * Fetch a track block and return its canonical YAML string (or null if not found). * Useful for IPC handlers that need to return the fresh YAML without taking a @@ -196,4 +312,4 @@ export async function deleteTrackBlock(filePath: string, trackId: string): Promi await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); }); -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 605b26d9..9e62f3d9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -662,6 +662,35 @@ const ipcSchemas = { error: z.string().optional(), }), }, + 'track:setNoteActive': { + req: z.object({ + path: RelPath, + active: z.boolean(), + }), + res: z.object({ + success: z.boolean(), + note: z.object({ + path: RelPath, + trackCount: z.number().int().positive(), + createdAt: z.string().nullable(), + lastRunAt: z.string().nullable(), + isActive: z.boolean(), + }).optional(), + error: z.string().optional(), + }), + }, + 'track:listNotes': { + req: z.null(), + res: z.object({ + notes: z.array(z.object({ + path: RelPath, + trackCount: z.number().int().positive(), + createdAt: z.string().nullable(), + lastRunAt: z.string().nullable(), + isActive: z.boolean(), + })), + }), + }, // Embedded browser (WebContentsView) channels 'browser:setBounds': { req: z.object({