mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 16:22:40 +02:00
Background agents (#530)
a common place to track and add background agents
This commit is contained in:
parent
7b119fbfcd
commit
e54b5cd27f
9 changed files with 596 additions and 591 deletions
|
|
@ -52,6 +52,8 @@ import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||||
import { API_URL } from '@x/core/dist/config/env.js';
|
import { API_URL } from '@x/core/dist/config/env.js';
|
||||||
import {
|
import {
|
||||||
fetchYaml,
|
fetchYaml,
|
||||||
|
listNotesWithTracks,
|
||||||
|
setNoteTracksActive,
|
||||||
updateTrackBlock,
|
updateTrackBlock,
|
||||||
replaceTrackBlockYaml,
|
replaceTrackBlockYaml,
|
||||||
deleteTrackBlock,
|
deleteTrackBlock,
|
||||||
|
|
@ -135,6 +137,14 @@ function resolveShellPath(filePath: string): string {
|
||||||
return workspace.resolveWorkspacePath(filePath);
|
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 InvokeChannels = ipc.InvokeChannels;
|
||||||
type IPCChannels = ipc.IPCChannels;
|
type IPCChannels = ipc.IPCChannels;
|
||||||
|
|
||||||
|
|
@ -832,6 +842,19 @@ export function setupIpcHandlers() {
|
||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
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 handler
|
||||||
'billing:getInfo': async () => {
|
'billing:getInfo': async () => {
|
||||||
return await getBillingInfo();
|
return await getBillingInfo();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba
|
||||||
import { useDebounce } from './hooks/use-debounce';
|
import { useDebounce } from './hooks/use-debounce';
|
||||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||||
|
import { BackgroundAgentsView } from '@/components/background-agents-view';
|
||||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
|
|
@ -142,6 +143,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
|
||||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
||||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||||
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
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 BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||||
|
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
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 isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||||
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_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 isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||||
|
|
||||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||||
|
|
@ -327,6 +330,24 @@ const buildSuggestedTopicExplorePrompt = ({
|
||||||
].join('\n')
|
].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<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||||
if (!usage) return null
|
if (!usage) return null
|
||||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||||
|
|
@ -508,6 +529,7 @@ type ViewState =
|
||||||
| { type: 'graph' }
|
| { type: 'graph' }
|
||||||
| { type: 'task'; name: string }
|
| { type: 'task'; name: string }
|
||||||
| { type: 'suggested-topics' }
|
| { type: 'suggested-topics' }
|
||||||
|
| { type: 'background-agents' }
|
||||||
|
|
||||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
if (a.type !== b.type) return false
|
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
|
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
|
||||||
* malformed or names an unknown target.
|
* malformed or names an unknown target.
|
||||||
*
|
*
|
||||||
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics>&...
|
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|background-agents>&...
|
||||||
* file: ?type=file&path=knowledge/foo.md
|
* file: ?type=file&path=knowledge/foo.md
|
||||||
* chat: ?type=chat&runId=abc123 (runId optional)
|
* chat: ?type=chat&runId=abc123 (runId optional)
|
||||||
* graph: ?type=graph
|
* graph: ?type=graph
|
||||||
* task: ?type=task&name=daily-brief
|
* task: ?type=task&name=daily-brief
|
||||||
* suggested-topics: ?type=suggested-topics
|
* suggested-topics: ?type=suggested-topics
|
||||||
|
* background-agents: ?type=background-agents
|
||||||
*/
|
*/
|
||||||
function parseDeepLink(input: string): ViewState | null {
|
function parseDeepLink(input: string): ViewState | null {
|
||||||
const SCHEME = 'rowboat://'
|
const SCHEME = 'rowboat://'
|
||||||
|
|
@ -551,6 +574,8 @@ function parseDeepLink(input: string): ViewState | null {
|
||||||
}
|
}
|
||||||
case 'suggested-topics':
|
case 'suggested-topics':
|
||||||
return { type: 'suggested-topics' }
|
return { type: 'suggested-topics' }
|
||||||
|
case 'background-agents':
|
||||||
|
return { type: 'background-agents' }
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -656,7 +681,13 @@ function App() {
|
||||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||||
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = 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<Record<string, BaseConfig>>({})
|
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|
@ -977,6 +1008,7 @@ function App() {
|
||||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||||
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
|
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 === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||||
|
|
@ -2660,6 +2692,8 @@ function App() {
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
setActiveFileTabId(existingTab.id)
|
setActiveFileTabId(existingTab.id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2667,6 +2701,8 @@ function App() {
|
||||||
setFileTabs(prev => [...prev, { id, path }])
|
setFileTabs(prev => [...prev, { id, path }])
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
}, [fileTabs, dismissBrowserOverlay])
|
}, [fileTabs, dismissBrowserOverlay])
|
||||||
|
|
||||||
|
|
@ -2685,16 +2721,26 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isBackgroundAgentsTabPath(tab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(tab.path)
|
setSelectedPath(tab.path)
|
||||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||||
|
|
||||||
|
|
@ -2723,6 +2769,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const idx = prev.findIndex(t => t.id === tabId)
|
const idx = prev.findIndex(t => t.id === tabId)
|
||||||
|
|
@ -2736,13 +2783,21 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
} else if (isBackgroundAgentsTabPath(newActiveTab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
} else {
|
} else {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(newActiveTab.path)
|
setSelectedPath(newActiveTab.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2773,8 +2828,13 @@ function App() {
|
||||||
dismissBrowserOverlay()
|
dismissBrowserOverlay()
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
// Left-pane "new chat" should always open full chat view.
|
// Left-pane "new chat" should always open full chat view.
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
|
||||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
setExpandedFrom({
|
||||||
|
path: selectedPath,
|
||||||
|
graph: isGraphOpen,
|
||||||
|
suggestedTopics: isSuggestedTopicsOpen,
|
||||||
|
backgroundAgents: isBackgroundAgentsOpen,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
}
|
}
|
||||||
|
|
@ -2782,7 +2842,8 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(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.
|
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||||
const handleNewChatTabInSidebar = useCallback(() => {
|
const handleNewChatTabInSidebar = useCallback(() => {
|
||||||
|
|
@ -2897,27 +2958,40 @@ function App() {
|
||||||
|
|
||||||
const handleOpenFullScreenChat = useCallback(() => {
|
const handleOpenFullScreenChat = useCallback(() => {
|
||||||
// Remember where we came from so the close button can return
|
// Remember where we came from so the close button can return
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
|
||||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
setExpandedFrom({
|
||||||
|
path: selectedPath,
|
||||||
|
graph: isGraphOpen,
|
||||||
|
suggestedTopics: isSuggestedTopicsOpen,
|
||||||
|
backgroundAgents: isBackgroundAgentsOpen,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
dismissBrowserOverlay()
|
dismissBrowserOverlay()
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, dismissBrowserOverlay])
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay])
|
||||||
|
|
||||||
const handleCloseFullScreenChat = useCallback(() => {
|
const handleCloseFullScreenChat = useCallback(() => {
|
||||||
if (expandedFrom) {
|
if (expandedFrom) {
|
||||||
if (expandedFrom.graph) {
|
if (expandedFrom.graph) {
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
} else if (expandedFrom.suggestedTopics) {
|
} else if (expandedFrom.suggestedTopics) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
|
} else if (expandedFrom.backgroundAgents) {
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
} else if (expandedFrom.path) {
|
} else if (expandedFrom.path) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setSelectedPath(expandedFrom.path)
|
setSelectedPath(expandedFrom.path)
|
||||||
}
|
}
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
|
|
@ -2927,11 +3001,12 @@ function App() {
|
||||||
|
|
||||||
const currentViewState = React.useMemo<ViewState>(() => {
|
const currentViewState = React.useMemo<ViewState>(() => {
|
||||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||||
|
if (isBackgroundAgentsOpen) return { type: 'background-agents' }
|
||||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||||
if (isGraphOpen) return { type: 'graph' }
|
if (isGraphOpen) return { type: 'graph' }
|
||||||
return { type: 'chat', runId }
|
return { type: 'chat', runId }
|
||||||
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
}, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||||
|
|
||||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||||
const last = stack[stack.length - 1]
|
const last = stack[stack.length - 1]
|
||||||
|
|
@ -2988,6 +3063,17 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
}, [fileTabs])
|
}, [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) => {
|
const applyViewState = useCallback(async (view: ViewState) => {
|
||||||
switch (view.type) {
|
switch (view.type) {
|
||||||
case 'file':
|
case 'file':
|
||||||
|
|
@ -2997,6 +3083,7 @@ function App() {
|
||||||
// visible in the middle pane.
|
// visible in the middle pane.
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||||
// Only exit chat-only maximize, because that would hide the selected file.
|
// Only exit chat-only maximize, because that would hide the selected file.
|
||||||
|
|
@ -3011,6 +3098,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
ensureGraphFileTab()
|
ensureGraphFileTab()
|
||||||
|
|
@ -3023,6 +3111,7 @@ function App() {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(view.name)
|
setSelectedBackgroundTask(view.name)
|
||||||
|
|
@ -3035,8 +3124,20 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
ensureSuggestedTopicsFileTab()
|
ensureSuggestedTopicsFileTab()
|
||||||
return
|
return
|
||||||
|
case 'background-agents':
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
setExpandedFrom(null)
|
||||||
|
setIsRightPaneMaximized(false)
|
||||||
|
setSelectedBackgroundTask(null)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(true)
|
||||||
|
ensureBackgroundAgentsFileTab()
|
||||||
|
return
|
||||||
case 'chat':
|
case 'chat':
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3045,6 +3146,7 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsBackgroundAgentsOpen(false)
|
||||||
if (view.runId) {
|
if (view.runId) {
|
||||||
await loadRun(view.runId)
|
await loadRun(view.runId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3052,7 +3154,7 @@ function App() {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
}, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||||
|
|
||||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||||
const current = currentViewState
|
const current = currentViewState
|
||||||
|
|
@ -3374,7 +3476,7 @@ function App() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
// 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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||||
|
|
@ -3452,15 +3554,17 @@ function App() {
|
||||||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||||
const mod = e.metaKey || e.ctrlKey
|
const mod = e.metaKey || e.ctrlKey
|
||||||
if (!mod) return
|
if (!mod) return
|
||||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
|
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen)
|
||||||
const targetPane: ShortcutPane = rightPaneAvailable
|
const targetPane: ShortcutPane = rightPaneAvailable
|
||||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||||
: 'left'
|
: 'left'
|
||||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
|
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen)
|
||||||
const selectedKnowledgePath = isGraphOpen
|
const selectedKnowledgePath = isGraphOpen
|
||||||
? GRAPH_TAB_PATH
|
? GRAPH_TAB_PATH
|
||||||
: isSuggestedTopicsOpen
|
: isSuggestedTopicsOpen
|
||||||
? SUGGESTED_TOPICS_TAB_PATH
|
? SUGGESTED_TOPICS_TAB_PATH
|
||||||
|
: isBackgroundAgentsOpen
|
||||||
|
? BACKGROUND_AGENTS_TAB_PATH
|
||||||
: selectedPath
|
: selectedPath
|
||||||
const targetFileTabId = activeFileTabId ?? (
|
const targetFileTabId = activeFileTabId ?? (
|
||||||
selectedKnowledgePath
|
selectedKnowledgePath
|
||||||
|
|
@ -3515,7 +3619,7 @@ function App() {
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleTabKeyDown)
|
document.addEventListener('keydown', handleTabKeyDown)
|
||||||
return () => document.removeEventListener('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') => {
|
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
|
|
@ -3540,7 +3644,7 @@ function App() {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -3662,14 +3766,14 @@ function App() {
|
||||||
},
|
},
|
||||||
openGraph: () => {
|
openGraph: () => {
|
||||||
// From chat-only landing state, open graph directly in full knowledge view.
|
// 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)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
void navigateToView({ type: 'graph' })
|
void navigateToView({ type: 'graph' })
|
||||||
},
|
},
|
||||||
openBases: () => {
|
openBases: () => {
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -4253,7 +4357,7 @@ function App() {
|
||||||
const selectedTask = selectedBackgroundTask
|
const selectedTask = selectedBackgroundTask
|
||||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||||
: null
|
: null
|
||||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
|
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen)
|
||||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||||
const openMarkdownTabs = React.useMemo(() => {
|
const openMarkdownTabs = React.useMemo(() => {
|
||||||
|
|
@ -4270,7 +4374,7 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen) {
|
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) {
|
||||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -4303,7 +4407,7 @@ function App() {
|
||||||
onNewChat: handleNewChatTab,
|
onNewChat: handleNewChatTab,
|
||||||
onSelectRun: (runIdToLoad) => {
|
onSelectRun: (runIdToLoad) => {
|
||||||
cancelRecordingIfActive()
|
cancelRecordingIfActive()
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4314,7 +4418,7 @@ function App() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
// 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))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||||
loadRun(runIdToLoad)
|
loadRun(runIdToLoad)
|
||||||
return
|
return
|
||||||
|
|
@ -4338,14 +4442,14 @@ function App() {
|
||||||
} else {
|
} else {
|
||||||
// Only one tab, reset it to new chat
|
// Only one tab, reset it to new chat
|
||||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
void navigateToView({ type: 'chat', runId: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (runId === runIdToDelete) {
|
} 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))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4375,6 +4479,8 @@ function App() {
|
||||||
onToggleBrowser={handleToggleBrowser}
|
onToggleBrowser={handleToggleBrowser}
|
||||||
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||||
|
isBackgroundAgentsOpen={isBackgroundAgentsOpen}
|
||||||
|
onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })}
|
||||||
/>
|
/>
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -4394,7 +4500,7 @@ function App() {
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
>
|
>
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={fileTabs}
|
tabs={fileTabs}
|
||||||
activeTabId={activeFileTabId ?? ''}
|
activeTabId={activeFileTabId ?? ''}
|
||||||
|
|
@ -4402,7 +4508,7 @@ function App() {
|
||||||
getTabId={(t) => t.id}
|
getTabId={(t) => t.id}
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
@ -4455,7 +4561,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4470,7 +4576,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4485,7 +4591,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4518,6 +4624,15 @@ function App() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : isBackgroundAgentsOpen ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
<BackgroundAgentsView
|
||||||
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
|
onAddNewBackgroundAgent={() => {
|
||||||
|
submitFromPalette(buildBackgroundAgentSetupPrompt(), null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<BasesView
|
<BasesView
|
||||||
|
|
|
||||||
250
apps/x/apps/renderer/src/components/background-agents-view.tsx
Normal file
250
apps/x/apps/renderer/src/components/background-agents-view.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Bot, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
type BackgroundAgentNote = {
|
||||||
|
path: string
|
||||||
|
trackCount: number
|
||||||
|
createdAt: string | null
|
||||||
|
lastRunAt: string | null
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackgroundAgentsViewProps = {
|
||||||
|
onOpenNote: (path: string) => void
|
||||||
|
onAddNewBackgroundAgent: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeLabel(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never'
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return 'Never'
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
|
||||||
|
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: BackgroundAgentsViewProps) {
|
||||||
|
const [notes, setNotes] = useState<BackgroundAgentNote[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const loadNotes = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('track:listNotes', null)
|
||||||
|
setNotes(result.notes)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load background agent notes:', err)
|
||||||
|
setError('Could not load background agents.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadNotes()
|
||||||
|
}, [loadNotes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const scheduleReload = () => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null
|
||||||
|
void loadNotes()
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'created':
|
||||||
|
case 'changed':
|
||||||
|
case 'deleted':
|
||||||
|
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
|
||||||
|
break
|
||||||
|
case 'moved':
|
||||||
|
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
|
||||||
|
scheduleReload()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'bulkChanged':
|
||||||
|
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
|
||||||
|
scheduleReload()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanupTracks = window.ipc.on('tracks:events', () => {
|
||||||
|
scheduleReload()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupWorkspace()
|
||||||
|
cleanupTracks()
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [loadNotes])
|
||||||
|
|
||||||
|
const handleToggleState = useCallback(async (note: BackgroundAgentNote, active: boolean) => {
|
||||||
|
setUpdatingPaths((prev) => new Set(prev).add(note.path))
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('track:setNoteActive', {
|
||||||
|
path: note.path,
|
||||||
|
active,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success || !result.note) {
|
||||||
|
throw new Error(result.error ?? 'Failed to update background agent state')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedNote = result.note
|
||||||
|
setNotes((prev) => prev.map((entry) => (
|
||||||
|
entry.path === note.path ? updatedNote : entry
|
||||||
|
)))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update background agent note state:', err)
|
||||||
|
toast(err instanceof Error ? err.message : 'Failed to update background agent state', 'error')
|
||||||
|
} finally {
|
||||||
|
setUpdatingPaths((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.path)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="size-5 text-primary" />
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Background agents</h2>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={onAddNewBackgroundAgent}>
|
||||||
|
Add new background agent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Notes that contain track blocks. Toggle a note inactive to pause every background agent in it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<Bot className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-3">
|
||||||
|
<Bot className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No notes with background agents yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/60 bg-muted/30 text-left">
|
||||||
|
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created date</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
|
||||||
|
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{notes.map((note) => {
|
||||||
|
const isUpdating = updatingPaths.has(note.path)
|
||||||
|
return (
|
||||||
|
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenNote(note.path)}
|
||||||
|
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
|
||||||
|
title={note.path}
|
||||||
|
>
|
||||||
|
{wikiLabel(note.path)}
|
||||||
|
</button>
|
||||||
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{stripKnowledgePrefix(note.path)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-foreground/80">
|
||||||
|
{formatDateLabel(note.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-foreground/80">
|
||||||
|
{formatDateTimeLabel(note.lastRunAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isUpdating ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="size-4 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
checked={note.isActive}
|
||||||
|
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
<span className="min-w-16 text-xs font-medium text-foreground/80">
|
||||||
|
{note.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -214,6 +214,8 @@ type SidebarContentPanelProps = {
|
||||||
onToggleBrowser?: () => void
|
onToggleBrowser?: () => void
|
||||||
isSuggestedTopicsOpen?: boolean
|
isSuggestedTopicsOpen?: boolean
|
||||||
onOpenSuggestedTopics?: () => void
|
onOpenSuggestedTopics?: () => void
|
||||||
|
isBackgroundAgentsOpen?: boolean
|
||||||
|
onOpenBackgroundAgents?: () => void
|
||||||
} & React.ComponentProps<typeof Sidebar>
|
} & React.ComponentProps<typeof Sidebar>
|
||||||
|
|
||||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||||
|
|
@ -491,6 +493,8 @@ export function SidebarContentPanel({
|
||||||
onToggleBrowser,
|
onToggleBrowser,
|
||||||
isSuggestedTopicsOpen = false,
|
isSuggestedTopicsOpen = false,
|
||||||
onOpenSuggestedTopics,
|
onOpenSuggestedTopics,
|
||||||
|
isBackgroundAgentsOpen = false,
|
||||||
|
onOpenBackgroundAgents,
|
||||||
...props
|
...props
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
const { activeSection, setActiveSection } = useSidebarSection()
|
const { activeSection, setActiveSection } = useSidebarSection()
|
||||||
|
|
@ -506,6 +510,7 @@ export function SidebarContentPanel({
|
||||||
const isMeetingQuickActionSelected = isMeetingActionActive
|
const isMeetingQuickActionSelected = isMeetingActionActive
|
||||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
|
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
|
||||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||||
|
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
|
||||||
|
|
||||||
const handleRowboatLogin = useCallback(async () => {
|
const handleRowboatLogin = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -679,6 +684,21 @@ export function SidebarContentPanel({
|
||||||
<span>Suggested Topics</span>
|
<span>Suggested Topics</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onOpenBackgroundAgents && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenBackgroundAgents}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||||
|
isBackgroundAgentsQuickActionSelected
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bot className="size-4" />
|
||||||
|
<span>Background agents</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -7,7 +7,6 @@ import draftEmailsSkill from "./draft-emails/skill.js";
|
||||||
import mcpIntegrationSkill from "./mcp-integration/skill.js";
|
import mcpIntegrationSkill from "./mcp-integration/skill.js";
|
||||||
import meetingPrepSkill from "./meeting-prep/skill.js";
|
import meetingPrepSkill from "./meeting-prep/skill.js";
|
||||||
import organizeFilesSkill from "./organize-files/skill.js";
|
import organizeFilesSkill from "./organize-files/skill.js";
|
||||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
|
||||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||||
|
|
||||||
import appNavigationSkill from "./app-navigation/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.",
|
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,
|
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",
|
id: "builtin-tools",
|
||||||
title: "Builtin Tools Reference",
|
title: "Builtin Tools Reference",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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
|
## The Exact Text to Insert
|
||||||
|
|
||||||
Write it verbatim like this (including the blank line between fence and target):
|
Write it verbatim like this (including the blank line between fence and target):
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,122 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer<
|
||||||
return blocks.find(b => b.track.trackId === trackId) ?? null;
|
return blocks.find(b => 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<typeof TrackStateSchema>[],
|
||||||
|
): Promise<TrackNoteSummary | null> {
|
||||||
|
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<TrackNoteSummary[]> {
|
||||||
|
async function walk(relativeDir = ''): Promise<string[]> {
|
||||||
|
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<TrackNoteSummary | null> {
|
||||||
|
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).
|
* 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
|
* 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');
|
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,35 @@ const ipcSchemas = {
|
||||||
error: z.string().optional(),
|
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
|
// Embedded browser (WebContentsView) channels
|
||||||
'browser:setBounds': {
|
'browser:setBounds': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue