From 0f051ea4675030fbdf80da0095780d9dbfe522b2 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 21 Apr 2026 13:02:44 +0530 Subject: [PATCH 01/76] fix: duplicate navigation button --- apps/x/apps/renderer/src/App.tsx | 45 +++----------------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f933b604..602c0956 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -127,8 +127,8 @@ const TITLEBAR_BUTTON_PX = 32 const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_HEADER_GAP_PX = 8 const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 -const TITLEBAR_BUTTONS_COLLAPSED = 4 -const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3 +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 BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' @@ -506,22 +506,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } -/** Sidebar toggle + utility buttons (fixed position, top-left) */ +/** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, leftInsetPx, }: { - onNavigateBack: () => void - onNavigateForward: () => void - canNavigateBack: boolean - canNavigateForward: boolean leftInsetPx: number }) { - const { toggleSidebar, state } = useSidebar() - const isCollapsed = state === "collapsed" + const { toggleSidebar } = useSidebar() return (
) } @@ -4756,10 +4723,6 @@ function App() { )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} { void navigateBack() }} - onNavigateForward={() => { void navigateForward() }} - canNavigateBack={canNavigateBack} - canNavigateForward={canNavigateForward} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} /> From c81d3cb27b73e1b485a9ade643ac1f794f1b73a2 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:36:00 +0530 Subject: [PATCH 02/76] surface silent runtime failures as error events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentRuntime.trigger() wrapped its body in try/finally with no outer catch. An inner catch around the streamAgent for-await only handled AbortError and rethrew everything else. Call sites fire-and-forget trigger (runs.ts:26,60,72), so any thrown error became an unhandled promise rejection. The finally still ran and published run-processing-end, but nothing told the renderer why — the chat showed the spinner, then an empty assistant bubble. Provider misconfig, invalid API keys, unknown model ids, streamText setup throws, runsRepo.fetch or loadAgent failing, and provider auth/rate-limit rejections on the first chunk all hit this path on a first message. All invisible. Add a top-level catch that formats the error to a string and emits a {type: "error"} RunEvent via the existing runsRepo/bus path. The renderer already renders those as a chat bubble plus toast (App.tsx:2069) — no UI work needed. No changes to the abort path: user-initiated stops still flow through the existing inner catch and the signal.aborted branch that emits run-stopped. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/packages/core/src/agents/runtime.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index f978449b..81421358 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -194,6 +194,19 @@ export class AgentRuntime implements IAgentRuntime { await this.runsRepo.appendEvents(runId, [stoppedEvent]); await this.bus.publish(stoppedEvent); } + } catch (error) { + console.error(`Run ${runId} failed:`, error); + const message = error instanceof Error + ? (error.stack || error.message || error.name) + : typeof error === "string" ? error : JSON.stringify(error); + const errorEvent: z.infer = { + runId, + type: "error", + error: message, + subflow: [], + }; + await this.runsRepo.appendEvents(runId, [errorEvent]); + await this.bus.publish(errorEvent); } finally { this.abortRegistry.cleanup(runId); await this.runsLock.release(runId); From 15567cd1dd47190ff165a76b28dd888e83e942a3 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:38:19 +0530 Subject: [PATCH 03/76] let tool failures be observed by the model instead of killing the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamAgent executed tools with no try/catch around the call. A throw from execTool or from a subflow agent streamed up through streamAgent, out of trigger's inner catch (which rethrows non-abort errors), and into the new top-level catch that the previous commit added. That surfaces the failure — but it ends the run. One misbehaving tool took down the whole conversation. Wrap the tool-execution block in a try/catch. On abort, rethrow so the existing AbortError path still fires. On any other error, convert the exception into a tool-result payload ({ success: false, error, toolName }) and keep going. The model then sees a tool-result message saying the tool failed with a specific message and can apologize, retry with different arguments, pick a different tool, or explain to the user — the normal recovery moves it already knows how to make. No change to happy-path tool execution, no change to abort handling, no change to subflow agent semantics (subflows that themselves error are treated identically to regular tool errors at the call site). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/packages/core/src/agents/runtime.ts | 51 ++++++++++++++-------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 81421358..ae69d60c 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -955,27 +955,40 @@ export async function* streamAgent({ subflow: [], }); let result: unknown = null; - if (agent.tools![toolCall.toolName].type === "agent") { - const subflowState = state.subflowStates[toolCallId]; - for await (const event of streamAgent({ - state: subflowState, - idGenerator, - runId, - messageQueue, - modelConfigRepo, - signal, - abortRegistry, - })) { - yield* processEvent({ - ...event, - subflow: [toolCallId, ...event.subflow], - }); + try { + if (agent.tools![toolCall.toolName].type === "agent") { + const subflowState = state.subflowStates[toolCallId]; + for await (const event of streamAgent({ + state: subflowState, + idGenerator, + runId, + messageQueue, + modelConfigRepo, + signal, + abortRegistry, + })) { + yield* processEvent({ + ...event, + subflow: [toolCallId, ...event.subflow], + }); + } + if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { + result = subflowState.finalResponse(); + } + } else { + result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); } - if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { - result = subflowState.finalResponse(); + } catch (error) { + if ((error instanceof Error && error.name === "AbortError") || signal.aborted) { + throw error; } - } else { - result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); + const message = error instanceof Error ? (error.message || error.name) : String(error); + _logger.log('tool failed', message); + result = { + success: false, + error: message, + toolName: toolCall.toolName, + }; } const resultPayload = result === undefined ? null : result; const resultMsg: z.infer = { From 5c4aa772556d1f2431ec2fe17815979314861613 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:26:01 +0530 Subject: [PATCH 04/76] freeze model + provider per run at creation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model dropdown was broken in two ways: it wrote to ~/.rowboat/config/models.json (the BYOK creds file, stamped with a fake `flavor: 'openrouter'` to satisfy zod when signed in), and the runtime ignored that write entirely for signed-in users because `streamAgent` hard-coded `gpt-5.4`. Model selection was also globally scoped, so every chat shared one brain. This change moves model + provider out of the global config and onto the run itself, resolved once at runs:create and frozen for the run's lifetime. ## Resolution `runsCore.createRun` resolves per-field, falling through: run.model = opts.model ?? agent.model ?? defaults.model run.provider = opts.provider ?? agent.provider ?? defaults.provider A new `core/models/defaults.ts` is the only place in the codebase that branches on signed-in state. `getDefaultModelAndProvider()` returns name strings; `resolveProviderConfig(name)` does the name → full LlmProvider lookup at runtime. `createProvider` learns about `flavor: 'rowboat'` so the gateway is just another flavor. `provider` is stored as a name (e.g. `"rowboat"`, `"openai"`), not a full LlmProvider object. API keys never get written into the JSONL log; rotating a key in models.json applies to existing runs without re-creation. Cost: deleting a provider from settings breaks runs that referenced it (clear error surfaced via `resolveProviderConfig`). ## Runtime `streamAgent` no longer resolves anything — it reads `state.runModel` / `state.runProvider`, looks up the provider config, instantiates. Subflows inherit the parent run's pair, so KG / inline-task subagents run on whatever the main run resolved to at creation. The `knowledgeGraphAgents` array, `isKgAgent`, and the per-agent default constants are gone. KG / inline-task / pre-built agents declare their preferred model in YAML frontmatter (claude-haiku-4.5 / claude-sonnet-4.6) — used at resolution time when those agents are themselves the top-level agent of a run (background triggers, scheduled tasks, etc.). ## Standalone callers Non-run LLM call sites (summarize_meeting, track/routing, builtin-tools parseFile) and `agent-schedule/runner` were branching on signed-in independently. They all route through `getDefaultModelAndProvider` + `resolveProviderConfig` + `createProvider` now; `agent-schedule/runner` switched from raw `runsRepo.create` to `runsCore.createRun` so resolution applies to scheduled-agent runs too. ## UI `chat-input-with-mentions` stops calling `models:saveConfig`. The dropdown notifies the parent via `onSelectedModelChange` ({provider, model} as names); App.tsx stashes selection per-tab and passes it to the next `runs:create`. When a run already exists, the input fetches it and renders a static label — model can't change mid-run. ## Legacy runs A lenient zod schema in `repo.ts` (`StartEvent.extend(...optional)` plus `RunEvent.or(LegacyStartEvent)`) parses pre-existing runs. `repo.fetch` fills missing model/provider from current defaults and returns the strict canonical `Run` type. No file-rewriting migration; no impact on the canonical schema in `@x/shared`. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/apps/renderer/src/App.tsx | 18 +++ .../components/chat-input-with-mentions.tsx | 142 +++++++----------- .../renderer/src/components/chat-sidebar.tsx | 5 +- .../core/src/agent-schedule/runner.ts | 5 +- apps/x/packages/core/src/agents/runtime.ts | 42 +++--- .../core/src/application/lib/builtin-tools.ts | 13 +- .../core/src/knowledge/agent_notes_agent.ts | 1 + .../core/src/knowledge/inline_task_agent.ts | 2 +- .../core/src/knowledge/labeling_agent.ts | 2 +- .../core/src/knowledge/note_creation.ts | 2 +- .../core/src/knowledge/note_tagging_agent.ts | 2 +- .../core/src/knowledge/summarize_meeting.ts | 17 +-- .../core/src/knowledge/track/routing.ts | 17 +-- apps/x/packages/core/src/models/defaults.ts | 53 +++++++ apps/x/packages/core/src/models/gateway.ts | 2 +- apps/x/packages/core/src/models/models.ts | 7 +- .../core/src/pre_built/email-draft.md | 2 +- .../core/src/pre_built/meeting-prep.md | 2 +- apps/x/packages/core/src/runs/repo.ts | 64 ++++++-- apps/x/packages/core/src/runs/runs.ts | 14 +- apps/x/packages/shared/src/models.ts | 11 +- apps/x/packages/shared/src/runs.ts | 12 +- 22 files changed, 256 insertions(+), 179 deletions(-) create mode 100644 apps/x/packages/core/src/models/defaults.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 602c0956..de75fb4a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -817,6 +817,7 @@ function App() { const chatTabIdCounterRef = useRef(0) const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const chatDraftsRef = useRef(new Map()) + const selectedModelByTabRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) @@ -2165,8 +2166,10 @@ function App() { let isNewRun = false let newRunCreatedAt: string | null = null if (!currentRunId) { + const selected = selectedModelByTabRef.current.get(submitTabId) const run = await window.ipc.invoke('runs:create', { agentId, + ...(selected ? { model: selected.model, provider: selected.provider } : {}), }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2471,6 +2474,7 @@ function App() { return next }) chatDraftsRef.current.delete(tabId) + selectedModelByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setToolOpenByTab((prev) => { if (!(tabId in prev)) return prev @@ -4644,6 +4648,13 @@ function App() { runId={tabState.runId} initialDraft={chatDraftsRef.current.get(tab.id)} onDraftChange={(text) => setChatDraftForTab(tab.id, text)} + onSelectedModelChange={(m) => { + if (m) { + selectedModelByTabRef.current.set(tab.id, m) + } else { + selectedModelByTabRef.current.delete(tab.id) + } + }} isRecording={isActive && isRecording} recordingText={isActive ? voice.interimText : undefined} recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} @@ -4697,6 +4708,13 @@ function App() { onPresetMessageConsumed={() => setPresetMessage(undefined)} getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)} onDraftChangeForTab={setChatDraftForTab} + onSelectedModelChangeForTab={(tabId, m) => { + if (m) { + selectedModelByTabRef.current.set(tabId, m) + } else { + selectedModelByTabRef.current.delete(tabId) + } + }} pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 37d8d053..0d2eb13d 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -69,13 +69,16 @@ const providerDisplayNames: Record = { rowboat: 'Rowboat', } +type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + interface ConfiguredModel { - flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + provider: ProviderName + model: string +} + +export interface SelectedModel { + provider: string model: string - apiKey?: string - baseURL?: string - headers?: Record - knowledgeGraphModel?: string } function getAttachmentIcon(kind: AttachmentIconKind) { @@ -120,6 +123,8 @@ interface ChatInputInnerProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ + onSelectedModelChange?: (model: SelectedModel | null) => void } function ChatInputInner({ @@ -145,6 +150,7 @@ function ChatInputInner({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -155,10 +161,27 @@ function ChatInputInner({ const [configuredModels, setConfiguredModels] = useState([]) const [activeModelKey, setActiveModelKey] = useState('') + const [lockedModel, setLockedModel] = useState(null) const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + // When a run exists, freeze the dropdown to the run's resolved model+provider. + useEffect(() => { + if (!runId) { + setLockedModel(null) + return + } + let cancelled = false + window.ipc.invoke('runs:fetch', { runId }).then((run) => { + if (cancelled) return + if (run.provider && run.model) { + setLockedModel({ provider: run.provider, model: run.model }) + } + }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) + return () => { cancelled = true } + }, [runId]) + // Check Rowboat sign-in state useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -176,42 +199,20 @@ function ChatInputInner({ return cleanup }, []) - // Load model config (gateway when signed in, local config when BYOK) + // Load the list of models the user can choose from. + // Signed-in: gateway model list. Signed-out: providers configured in models.json. const loadModelConfig = useCallback(async () => { try { if (isRowboatConnected) { - // Fetch gateway models const listResult = await window.ipc.invoke('models:list', null) const rowboatProvider = listResult.providers?.find( (p: { id: string }) => p.id === 'rowboat' ) const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( - (m: { id: string }) => ({ flavor: 'rowboat', model: m.id }) + (m: { id: string }) => ({ provider: 'rowboat', model: m.id }) ) - - // Read current default from config - let defaultModel = '' - try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - defaultModel = parsed?.model || '' - } catch { /* no config yet */ } - - if (defaultModel) { - models.sort((a, b) => { - if (a.model === defaultModel) return -1 - if (b.model === defaultModel) return 1 - return 0 - }) - } - setConfiguredModels(models) - const activeKey = defaultModel - ? `rowboat/${defaultModel}` - : models[0] ? `rowboat/${models[0].model}` : '' - if (activeKey) setActiveModelKey(activeKey) } else { - // BYOK: read from local models.json const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) const models: ConfiguredModel[] = [] @@ -223,32 +224,12 @@ function ChatInputInner({ const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] for (const model of allModels) { if (model) { - models.push({ - flavor: flavor as ConfiguredModel['flavor'], - model, - apiKey: (e.apiKey as string) || undefined, - baseURL: (e.baseURL as string) || undefined, - headers: (e.headers as Record) || undefined, - knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, - }) + models.push({ provider: flavor as ProviderName, model }) } } } } - const defaultKey = parsed?.provider?.flavor && parsed?.model - ? `${parsed.provider.flavor}/${parsed.model}` - : '' - models.sort((a, b) => { - const aKey = `${a.flavor}/${a.model}` - const bKey = `${b.flavor}/${b.model}` - if (aKey === defaultKey) return -1 - if (bKey === defaultKey) return 1 - return 0 - }) setConfiguredModels(models) - if (defaultKey) { - setActiveModelKey(defaultKey) - } } } catch { // No config yet @@ -284,40 +265,15 @@ function ChatInputInner({ checkSearch() }, [isActive, isRowboatConnected]) - const handleModelChange = useCallback(async (key: string) => { - const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + // Selecting a model affects only the *next* run created from this tab. + // Once a run exists, model is frozen on the run and the dropdown is read-only. + const handleModelChange = useCallback((key: string) => { + if (lockedModel) return + const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key) if (!entry) return setActiveModelKey(key) - - try { - if (entry.flavor === 'rowboat') { - // Gateway model — save with valid Zod flavor, no credentials - await window.ipc.invoke('models:saveConfig', { - provider: { flavor: 'openrouter' as const }, - model: entry.model, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) - } else { - // BYOK — preserve full provider config - const providerModels = configuredModels - .filter((m) => m.flavor === entry.flavor) - .map((m) => m.model) - await window.ipc.invoke('models:saveConfig', { - provider: { - flavor: entry.flavor, - apiKey: entry.apiKey, - baseURL: entry.baseURL, - headers: entry.headers, - }, - model: entry.model, - models: providerModels, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) - } - } catch { - toast.error('Failed to switch model') - } - }, [configuredModels]) + onSelectedModelChange?.({ provider: entry.provider, model: entry.model }) + }, [configuredModels, lockedModel, onSelectedModelChange]) // Restore the tab draft when this input mounts. useEffect(() => { @@ -555,7 +511,14 @@ function ChatInputInner({ ) )}
- {configuredModels.length > 0 && ( + {lockedModel ? ( + + {lockedModel.model} + + ) : configuredModels.length > 0 ? ( @@ -571,18 +534,18 @@ function ChatInputInner({ {configuredModels.map((m) => { - const key = `${m.flavor}/${m.model}` + const key = `${m.provider}/${m.model}` return ( {m.model} - {providerDisplayNames[m.flavor] || m.flavor} + {providerDisplayNames[m.provider] || m.provider} ) })} - )} + ) : null} {onToggleTts && ttsAvailable && (
@@ -729,6 +692,7 @@ export interface ChatInputWithMentionsProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onSelectedModelChange?: (model: SelectedModel | null) => void } export function ChatInputWithMentions({ @@ -757,6 +721,7 @@ export function ChatInputWithMentions({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputWithMentionsProps) { return ( @@ -783,6 +748,7 @@ export function ChatInputWithMentions({ ttsMode={ttsMode} onToggleTts={onToggleTts} onTtsModeChange={onTtsModeChange} + onSelectedModelChange={onSelectedModelChange} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index e51d7c8f..852993a2 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -26,7 +26,7 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { @@ -158,6 +158,7 @@ interface ChatSidebarProps { onPresetMessageConsumed?: () => void getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void + onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] @@ -211,6 +212,7 @@ export function ChatSidebar({ onPresetMessageConsumed, getInitialDraft, onDraftChangeForTab, + onSelectedModelChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -662,6 +664,7 @@ export function ChatSidebar({ runId={tabState.runId} initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} + onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} isRecording={isActive && isRecording} recordingText={isActive ? recordingText : undefined} recordingState={isActive ? recordingState : undefined} diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts index 4eab6081..5fca6878 100644 --- a/apps/x/packages/core/src/agent-schedule/runner.ts +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -8,6 +8,7 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; import { MessageEvent } from "@x/shared/dist/runs.js"; +import { createRun } from "../runs/runs.js"; import z from "zod"; const DEFAULT_STARTING_MESSAGE = "go"; @@ -162,8 +163,8 @@ async function runAgent( }); try { - // Create a new run - const run = await runsRepo.create({ agentId: agentName }); + // Create a new run via core (resolves agent + default model+provider). + const run = await createRun({ agentId: agentName }); console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); // Add the starting message as a user message diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index ae69d60c..6c84ac8b 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -16,8 +16,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu import container from "../di/container.js"; import { IModelConfigRepo } from "../models/repo.js"; import { createProvider } from "../models/models.js"; -import { isSignedIn } from "../account/account.js"; -import { getGatewayProvider } from "../models/gateway.js"; +import { resolveProviderConfig } from "../models/defaults.js"; import { IAgentsRepo } from "./repo.js"; import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { IBus } from "../application/lib/bus.js"; @@ -649,6 +648,8 @@ export class AgentState { runId: string | null = null; agent: z.infer | null = null; agentName: string | null = null; + runModel: string | null = null; + runProvider: string | null = null; messages: z.infer = []; lastAssistantMsg: z.infer | null = null; subflowStates: Record = {}; @@ -762,13 +763,18 @@ export class AgentState { case "start": this.runId = event.runId; this.agentName = event.agentName; + this.runModel = event.model; + this.runProvider = event.provider; break; case "spawn-subflow": // Seed the subflow state with its agent so downstream loadAgent works. + // Subflows inherit the parent run's model+provider — there's one pair per run. if (!this.subflowStates[event.toolCallId]) { this.subflowStates[event.toolCallId] = new AgentState(); } this.subflowStates[event.toolCallId].agentName = event.agentName; + this.subflowStates[event.toolCallId].runModel = this.runModel; + this.subflowStates[event.toolCallId].runProvider = this.runProvider; break; case "message": this.messages.push(event.message); @@ -857,35 +863,23 @@ export async function* streamAgent({ yield event; } - const modelConfig = await modelConfigRepo.getConfig(); - if (!modelConfig) { - throw new Error("Model config not found"); - } - // set up agent const agent = await loadAgent(state.agentName!); // set up tools const tools = await buildTools(agent); - // set up provider + model - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(modelConfig.provider); - const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"]; - const isKgAgent = knowledgeGraphAgents.includes(state.agentName!); - const isInlineTaskAgent = state.agentName === "inline_task_agent"; - const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model; - const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel; - const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel; - const modelId = isInlineTaskAgent - ? defaultInlineTaskModel - : (isKgAgent && modelConfig.knowledgeGraphModel) - ? modelConfig.knowledgeGraphModel - : isKgAgent ? defaultKgModel : defaultModel; + // model+provider were resolved and frozen on the run at runs:create time. + // Look up the named provider's current credentials from models.json and + // instantiate the LLM client. No selection happens here. + if (!state.runModel || !state.runProvider) { + throw new Error(`Run ${runId} is missing model/provider on its start event`); + } + const modelId = state.runModel; + const providerConfig = await resolveProviderConfig(state.runProvider); + const provider = createProvider(providerConfig); const model = provider.languageModel(modelId); - logger.log(`using model: ${modelId}`); + logger.log(`using model: ${modelId} (provider: ${state.runProvider})`); let loopCounter = 0; let voiceInput = false; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index a2b68427..52083277 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -21,9 +21,8 @@ import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/d import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; -import { IModelConfigRepo } from "../../models/repo.js"; +import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js"; import { isSignedIn } from "../../account/account.js"; -import { getGatewayProvider } from "../../models/gateway.js"; import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; @@ -746,13 +745,9 @@ export const BuiltinTools: z.infer = { const base64 = buffer.toString('base64'); - // Resolve model config from DI container - const modelConfigRepo = container.resolve('modelConfigRepo'); - const modelConfig = await modelConfigRepo.getConfig(); - const provider = await isSignedIn() - ? await getGatewayProvider() - : createProvider(modelConfig.provider); - const model = provider.languageModel(modelConfig.model); + const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); const userPrompt = prompt || 'Convert this file to well-structured markdown.'; diff --git a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts index 58aa22a7..d7087405 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts @@ -1,5 +1,6 @@ export function getRaw(): string { return `--- +model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index d25ff74b..9c3e2568 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -13,7 +13,7 @@ export function getRaw(): string { const defaultEndISO = defaultEnd.toISOString(); return `--- -model: gpt-5.2 +model: anthropic/claude-sonnet-4.6 tools: ${toolEntries} --- diff --git a/apps/x/packages/core/src/knowledge/labeling_agent.ts b/apps/x/packages/core/src/knowledge/labeling_agent.ts index d28649b1..bb4a6efe 100644 --- a/apps/x/packages/core/src/knowledge/labeling_agent.ts +++ b/apps/x/packages/core/src/knowledge/labeling_agent.ts @@ -2,7 +2,7 @@ import { renderTagSystemForEmails } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 1740bdb7..283c77ec 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -3,7 +3,7 @@ import { renderNoteEffectRules } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts index 0dc581f1..71b10910 100644 --- a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts +++ b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts @@ -2,7 +2,7 @@ import { renderTagSystemForNotes } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index 30e3c5d4..a10aac28 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -1,11 +1,8 @@ import fs from 'fs'; import path from 'path'; import { generateText } from 'ai'; -import container from '../di/container.js'; -import type { IModelConfigRepo } from '../models/repo.js'; import { createProvider } from '../models/models.js'; -import { isSignedIn } from '../account/account.js'; -import { getGatewayProvider } from '../models/gateway.js'; +import { getDefaultModelAndProvider, resolveProviderConfig } from '../models/defaults.js'; import { WorkDir } from '../config/config.js'; const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -138,15 +135,9 @@ function loadCalendarEventContext(calendarEventJson: string): string { } export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise { - const repo = container.resolve('modelConfigRepo'); - const config = await repo.getConfig(); - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(config.provider); - const modelId = config.meetingNotesModel - || (signedIn ? "gpt-5.4" : config.model); - const model = provider.languageModel(modelId); + const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); // If a specific calendar event was linked, use it directly. // Otherwise fall back to scanning events within ±3 hours. diff --git a/apps/x/packages/core/src/knowledge/track/routing.ts b/apps/x/packages/core/src/knowledge/track/routing.ts index f876106e..53e6f7b3 100644 --- a/apps/x/packages/core/src/knowledge/track/routing.ts +++ b/apps/x/packages/core/src/knowledge/track/routing.ts @@ -1,11 +1,8 @@ import { generateObject } from 'ai'; import { trackBlock, PrefixLogger } from '@x/shared'; import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; -import container from '../../di/container.js'; -import type { IModelConfigRepo } from '../../models/repo.js'; import { createProvider } from '../../models/models.js'; -import { isSignedIn } from '../../account/account.js'; -import { getGatewayProvider } from '../../models/gateway.js'; +import { getDefaultModelAndProvider, resolveProviderConfig } from '../../models/defaults.js'; const log = new PrefixLogger('TrackRouting'); @@ -37,15 +34,9 @@ Rules: - For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`; async function resolveModel() { - const repo = container.resolve('modelConfigRepo'); - const config = await repo.getConfig(); - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(config.provider); - const modelId = config.knowledgeGraphModel - || (signedIn ? 'gpt-5.4' : config.model); - return provider.languageModel(modelId); + const { model, provider } = await getDefaultModelAndProvider(); + const config = await resolveProviderConfig(provider); + return createProvider(config).languageModel(model); } function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string { diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts new file mode 100644 index 00000000..b9df52da --- /dev/null +++ b/apps/x/packages/core/src/models/defaults.ts @@ -0,0 +1,53 @@ +import z from "zod"; +import { LlmProvider } from "@x/shared/dist/models.js"; +import { IModelConfigRepo } from "./repo.js"; +import { isSignedIn } from "../account/account.js"; +import container from "../di/container.js"; + +const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; +const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; + +/** + * The single source of truth for "what model+provider should we use when + * the caller didn't specify and the agent didn't declare". Returns names only. + * This is the only place that branches on signed-in state. + */ +export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> { + if (await isSignedIn()) { + return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; + } + const repo = container.resolve("modelConfigRepo"); + const cfg = await repo.getConfig(); + return { model: cfg.model, provider: cfg.provider.flavor }; +} + +/** + * Resolve a provider name (as stored on a run, an agent, or returned by + * getDefaultModelAndProvider) into the full LlmProvider config that + * createProvider expects (apiKey/baseURL/headers). + * + * - "rowboat" → gateway provider (auth via OAuth bearer; no creds field). + * - other names → look up models.json's `providers[name]` map. + * - fallback: if the name matches the active default's flavor (legacy + * single-provider configs that didn't write to the providers map yet). + */ +export async function resolveProviderConfig(name: string): Promise> { + if (name === "rowboat") { + return { flavor: "rowboat" }; + } + const repo = container.resolve("modelConfigRepo"); + const cfg = await repo.getConfig(); + const entry = cfg.providers?.[name]; + if (entry) { + return LlmProvider.parse({ + flavor: name, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }); + } + if (cfg.provider.flavor === name) { + return cfg.provider; + } + throw new Error(`Provider '${name}' is referenced but not configured`); +} diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts index df9b413c..6f613704 100644 --- a/apps/x/packages/core/src/models/gateway.ts +++ b/apps/x/packages/core/src/models/gateway.ts @@ -10,7 +10,7 @@ const authedFetch: typeof fetch = async (input, init) => { return fetch(input, { ...init, headers }); }; -export async function getGatewayProvider(): Promise { +export function getGatewayProvider(): ProviderV2 { return createOpenRouter({ baseURL: `${API_URL}/v1/llm`, apiKey: 'managed-by-rowboat', diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 38b6801f..92353f0a 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -8,7 +8,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; -import { isSignedIn } from "../account/account.js"; import { getGatewayProvider } from "./gateway.js"; export const Provider = LlmProvider; @@ -65,6 +64,8 @@ export function createProvider(config: z.infer): ProviderV2 { baseURL, headers, }) as unknown as ProviderV2; + case "rowboat": + return getGatewayProvider(); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } @@ -80,9 +81,7 @@ export async function testModelConnection( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), effectiveTimeout); try { - const provider = await isSignedIn() - ? await getGatewayProvider() - : createProvider(providerConfig); + const provider = createProvider(providerConfig); const languageModel = provider.languageModel(model); await generateText({ model: languageModel, diff --git a/apps/x/packages/core/src/pre_built/email-draft.md b/apps/x/packages/core/src/pre_built/email-draft.md index f863271b..7a353d26 100644 --- a/apps/x/packages/core/src/pre_built/email-draft.md +++ b/apps/x/packages/core/src/pre_built/email-draft.md @@ -1,5 +1,5 @@ --- -model: gpt-4.1 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/pre_built/meeting-prep.md b/apps/x/packages/core/src/pre_built/meeting-prep.md index ca6bb2fc..5dc46eda 100644 --- a/apps/x/packages/core/src/pre_built/meeting-prep.md +++ b/apps/x/packages/core/src/pre_built/meeting-prep.md @@ -1,5 +1,5 @@ --- -model: gpt-4.1 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 5d563f1f..502976e6 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -6,9 +6,28 @@ import fsp from "fs/promises"; import fs from "fs"; import readline from "readline"; import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js"; +import { getDefaultModelAndProvider } from "../models/defaults.js"; + +/** + * Reading-only schemas: extend the canonical `StartEvent` / `RunEvent` to + * accept legacy run files written before `model`/`provider` were required. + * + * `RunEvent.or(LegacyStartEvent)` works because zod unions try left-to-right: + * for any non-start event RunEvent matches first; for a strict start event + * RunEvent still matches; only a legacy start event falls through and parses + * as LegacyStartEvent. New event types stay maintained in one place + * (`@x/shared/dist/runs.js`) — the lenient form just adds one fallback variant. + */ +const LegacyStartEvent = StartEvent.extend({ + model: z.string().optional(), + provider: z.string().optional(), +}); +const ReadRunEvent = RunEvent.or(LegacyStartEvent); + +export type CreateRunRepoOptions = Required>; export interface IRunsRepo { - create(options: z.infer): Promise>; + create(options: CreateRunRepoOptions): Promise>; fetch(id: string): Promise>; list(cursor?: string): Promise>; appendEvents(runId: string, events: z.infer[]): Promise; @@ -69,16 +88,19 @@ export class FSRunsRepo implements IRunsRepo { /** * Read file line-by-line using streams, stopping early once we have * the start event and title (or determine there's no title). + * + * Parses the start event with `LegacyStartEvent` so runs written before + * `model`/`provider` were required still surface in the list view. */ private async readRunMetadata(filePath: string): Promise<{ - start: z.infer; + start: z.infer; title: string | undefined; } | null> { return new Promise((resolve) => { const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - let start: z.infer | null = null; + let start: z.infer | null = null; let title: string | undefined; let lineIndex = 0; @@ -88,11 +110,10 @@ export class FSRunsRepo implements IRunsRepo { try { if (lineIndex === 0) { - // First line should be the start event - start = StartEvent.parse(JSON.parse(trimmed)); + start = LegacyStartEvent.parse(JSON.parse(trimmed)); } else { // Subsequent lines - look for first user message or assistant response - const event = RunEvent.parse(JSON.parse(trimmed)); + const event = ReadRunEvent.parse(JSON.parse(trimmed)); if (event.type === 'message') { const msg = event.message; if (msg.role === 'user') { @@ -157,13 +178,15 @@ export class FSRunsRepo implements IRunsRepo { ); } - async create(options: z.infer): Promise> { + async create(options: CreateRunRepoOptions): Promise> { const runId = await this.idGenerator.next(); const ts = new Date().toISOString(); const start: z.infer = { type: "start", runId, agentName: options.agentId, + model: options.model, + provider: options.provider, subflow: [], ts, }; @@ -172,24 +195,41 @@ export class FSRunsRepo implements IRunsRepo { id: runId, createdAt: ts, agentId: options.agentId, + model: options.model, + provider: options.provider, log: [start], }; } async fetch(id: string): Promise> { const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8'); - const events = contents.split('\n') + // Parse with the lenient schema so legacy start events (no model/provider) load. + const rawEvents = contents.split('\n') .filter(line => line.trim() !== '') - .map(line => RunEvent.parse(JSON.parse(line))); - if (events.length === 0 || events[0].type !== 'start') { + .map(line => ReadRunEvent.parse(JSON.parse(line))); + if (rawEvents.length === 0 || rawEvents[0].type !== 'start') { throw new Error('Corrupt run data'); } + // Backfill model/provider on the start event from current defaults if missing, + // then promote to the canonical strict types for callers. + const rawStart = rawEvents[0]; + const defaults = (!rawStart.model || !rawStart.provider) + ? await getDefaultModelAndProvider() + : null; + const start: z.infer = { + ...rawStart, + model: rawStart.model ?? defaults!.model, + provider: rawStart.provider ?? defaults!.provider, + }; + const events: z.infer[] = [start, ...rawEvents.slice(1) as z.infer[]]; const title = this.extractTitle(events); return { id, title, - createdAt: events[0].ts!, - agentId: events[0].agentName, + createdAt: start.ts!, + agentId: start.agentName, + model: start.model, + provider: start.provider, log: events, }; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 8ea4688b..5b8395a9 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -10,11 +10,21 @@ import { IRunsLock } from "./lock.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { extractCommandNames } from "../application/lib/command-executor.js"; import { addToSecurityConfig } from "../config/security.js"; +import { loadAgent } from "../agents/runtime.js"; +import { getDefaultModelAndProvider } from "../models/defaults.js"; export async function createRun(opts: z.infer): Promise> { const repo = container.resolve('runsRepo'); const bus = container.resolve('bus'); - const run = await repo.create(opts); + + // Resolve model+provider once at creation: opts > agent declaration > defaults. + // Both fields are plain strings (provider is a name, looked up at runtime). + const agent = await loadAgent(opts.agentId); + const defaults = await getDefaultModelAndProvider(); + const model = opts.model ?? agent.model ?? defaults.model; + const provider = opts.provider ?? agent.provider ?? defaults.provider; + + const run = await repo.create({ agentId: opts.agentId, model, provider }); await bus.publish(run.log[0]); return run; } @@ -110,4 +120,4 @@ export async function fetchRun(runId: string): Promise> { export async function listRuns(cursor?: string): Promise> { const repo = container.resolve('runsRepo'); return repo.list(cursor); -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 2c1588e8..feec148f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]), + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), @@ -11,6 +11,15 @@ export const LlmModelConfig = z.object({ provider: LlmProvider, model: z.string(), models: z.array(z.string()).optional(), + providers: z.record(z.string(), z.object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + model: z.string().optional(), + models: z.array(z.string()).optional(), + })).optional(), + // Deprecated: per-run model+provider supersedes these. Kept on the schema so + // existing settings/onboarding UIs continue to compile until they're cleaned up. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 5f52f611..2c5bcc7a 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -19,6 +19,8 @@ export const RunProcessingEndEvent = BaseRunEvent.extend({ export const StartEvent = BaseRunEvent.extend({ type: z.literal("start"), agentName: z.string(), + model: z.string(), + provider: z.string(), }); export const SpawnSubFlowEvent = BaseRunEvent.extend({ @@ -121,6 +123,8 @@ export const Run = z.object({ title: z.string().optional(), createdAt: z.iso.datetime(), agentId: z.string(), + model: z.string(), + provider: z.string(), log: z.array(RunEvent), }); @@ -134,6 +138,8 @@ export const ListRunsResponse = z.object({ nextCursor: z.string().optional(), }); -export const CreateRunOptions = Run.pick({ - agentId: true, -}); \ No newline at end of file +export const CreateRunOptions = z.object({ + agentId: z.string(), + model: z.string().optional(), + provider: z.string().optional(), +}); From f4dbb58a7782a841895322691db06280641d6511 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:35:08 +0530 Subject: [PATCH 05/76] add rowboat meeting notes to graph --- apps/x/packages/core/src/knowledge/build_graph.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 100af5d8..60c0572e 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -38,6 +38,7 @@ const SOURCE_FOLDERS = [ 'gmail_sync', path.join('knowledge', 'Meetings', 'fireflies'), path.join('knowledge', 'Meetings', 'granola'), + path.join('knowledge', 'Meetings', 'rowboat'), ]; // Voice memos are now created directly in knowledge/Voice Memos// From 75842fa06b1aa936eff45c03e07369fee92f8c86 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:49:06 +0530 Subject: [PATCH 06/76] assistant chat ui shows the model name properly --- .../renderer/src/components/chat-input-with-mentions.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0d2eb13d..e1fb950f 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -81,6 +81,10 @@ export interface SelectedModel { model: string } +function getSelectedModelDisplayName(model: string) { + return model.split('/').pop() || model +} + function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -516,7 +520,7 @@ function ChatInputInner({ className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground" title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`} > - {lockedModel.model} + {getSelectedModelDisplayName(lockedModel.model)} ) : configuredModels.length > 0 ? ( @@ -526,7 +530,7 @@ function ChatInputInner({ className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > - {configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'} + {getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')} From 0bb256879c756f2bed40cc783d10d2492ed4f7d8 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:29:51 +0530 Subject: [PATCH 07/76] preserve formatting in chat input text --- apps/x/apps/renderer/package.json | 1 + apps/x/apps/renderer/src/App.tsx | 23 +++++++++++++++++-- .../renderer/src/components/chat-sidebar.tsx | 23 +++++++++++++++++-- apps/x/pnpm-lock.yaml | 20 ++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index a8c67a43..d9216de1 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -49,6 +49,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.8.0", + "remark-breaks": "^4.0.0", "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index de75fb4a..67f3f06a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -62,6 +62,8 @@ import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { defaultRemarkPlugins } from 'streamdown' +import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { type ChatMessage, @@ -104,6 +106,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +// Render user messages with markdown so bullets, bold, links, etc. survive the +// round-trip from the input textarea. `remarkBreaks` turns single newlines +// into
so typed line breaks are preserved without requiring blank lines. +const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] + function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { const smoothText = useSmoothedText(text) return {smoothText} @@ -3974,7 +3981,14 @@ function App() { {item.content && ( - {item.content} + + + {item.content} + + )} ) @@ -3995,7 +4009,12 @@ function App() { ))}
)} - {message} + + {message} + ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 852993a2..0a407d5d 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -25,6 +25,8 @@ import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { defaultRemarkPlugins } from 'streamdown' +import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab } from '@/components/tab-bar' import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' @@ -49,6 +51,11 @@ import { const streamdownComponents = { pre: MarkdownPreOverride } +// Render user messages with markdown so bullets, bold, links, etc. survive the +// round-trip from the input textarea. `remarkBreaks` turns single newlines +// into
so typed line breaks are preserved without requiring blank lines. +const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] + /* ─── Billing error helpers ─── */ const BILLING_ERROR_PATTERNS = [ @@ -353,7 +360,14 @@ export function ChatSidebar({ {item.content && ( - {item.content} + + + {item.content} + + )} ) @@ -374,7 +388,12 @@ export function ChatSidebar({ ))}
)} - {message} + + {message} + ) diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 51248fff..ac219371 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: recharts: specifier: ^3.8.0 version: 3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5808,6 +5811,9 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -6768,6 +6774,9 @@ packages: rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-cjk-friendly-gfm-strikethrough@1.2.3: resolution: {integrity: sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA==} engines: {node: '>=16'} @@ -14414,6 +14423,11 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -15608,6 +15622,12 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + remark-cjk-friendly-gfm-strikethrough@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: micromark-extension-cjk-friendly-gfm-strikethrough: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2) From bdf270b7a1d93111b0967964048dc2b427499bd0 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:15:24 +0530 Subject: [PATCH 08/76] convert Today.md track blocks to event-driven and batch Gmail sync events Removes polling schedules from the up-next and calendar track blocks on Today.md so they refresh only on calendar.synced events, and rewrites the emails track instruction to consume a multi-thread digest payload. Batches Gmail sync so one email.synced event covers a whole sync run (capped at 10 threads per digest) instead of one event per thread, which collapses Pass 1 routing calls for multi-thread syncs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/src/knowledge/ensure_daily_note.ts | 24 ++-- .../packages/core/src/knowledge/sync_gmail.ts | 127 ++++++++++++------ 2 files changed, 96 insertions(+), 55 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts index 4a6872f4..ac54d029 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -21,14 +21,14 @@ const SECTIONS: Section[] = [ instruction: `Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today. -Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't started yet. +This section refreshes on calendar changes, not on a clock tick — do NOT promise live minute countdowns. Frame urgency in buckets based on the event's start time relative to now: +- Start time is in the past or within roughly half an hour → imminent: name the meeting and say it's starting soon (e.g. "Standup is starting — join link in the Calendar section below."). +- Start time is later this morning or this afternoon → upcoming: name the meeting and roughly when (e.g. "Design review later this morning." / "1:1 with Sam this afternoon."). +- Start time is several hours out or nothing before then → focus block: frame the gap (e.g. "Next up is the all-hands at 3pm — good long focus block until then."). -Lead based on how soon the next event is: -- Under 15 minutes → urgent ("Standup starts in 10 minutes — join link in the Calendar section below.") -- Under 2 hours → lead with the event ("Design review in 40 minutes.") -- 2+ hours → frame the gap as focus time ("Next up is standup at noon — you've got a solid 3-hour focus block.") +Use the event's start time of day ("at 3pm", "this afternoon") rather than a countdown ("in 40 minutes"). Countdowns go stale between syncs. -Always compute minutes-to-start against the actual current local time — never say "nothing in the next X hours" if an event is in that window. +Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't ended yet — for finding the next event, pick the earliest upcoming one; if all have passed, treat as clear. If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research. @@ -38,10 +38,6 @@ Plain markdown prose only — no calendar block, no email block, no headings.`, eventMatchCriteria: `Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`, active: true, - schedule: { - type: 'cron', - expression: '*/15 * * * *', - }, }, }, { @@ -53,16 +49,14 @@ Plain markdown prose only — no calendar block, no email block, no headings.`, Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended — only include meetings that haven't ended yet. +This section refreshes on calendar changes, not on a clock tick — the "drop ended meetings" rule applies on each refresh, so an ended meeting disappears the next time any calendar event changes (not exactly on the clock hour). That's fine. + Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink. After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight — one line each, only when meaningful. Skip routine/recurring meetings.`, eventMatchCriteria: `Calendar event changes affecting today — additions, updates, cancellations, reschedules.`, active: true, - schedule: { - type: 'cron', - expression: '0 * * * *', - }, }, }, { @@ -72,7 +66,7 @@ After the block, you MAY add one short markdown line per event giving useful pre instruction: `Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread). -Event-driven path (primary): the agent message will include a freshly-synced thread's markdown as the event payload. Decide whether THIS thread warrants surfacing. If it's marketing, an auto-notification, a thread already closed out, or otherwise low-signal, skip the update — do NOT call update-track-content. If it's attention-worthy, integrate it into the digest: add a new email block, or update the existing one if the same threadId is already shown. +Event-driven path (primary): the agent message will include a "Gmail sync update" digest payload describing one or more freshly-synced threads from a single sync run. The digest lists each thread with its subject, sender, date, threadId, and body. Iterate over every thread in the payload and decide per thread whether it warrants surfacing. Skip marketing, auto-notifications, closed-out threads, and other low-signal mail. For threads that are attention-worthy, integrate them into the existing digest: add a new email block for a new threadId, or update the existing block if the threadId is already shown. If NONE of the threads in the payload are attention-worthy, skip the update — do NOT call update-track-content. Emit at most one update-track-content call that covers the full set of changes from this event. Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads. diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index d00557a0..2aa48944 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -15,8 +15,52 @@ import { createEvent } from './track/events.js'; const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; +const MAX_THREADS_IN_DIGEST = 10; const nhm = new NodeHtmlMarkdown(); +interface SyncedThread { + threadId: string; + markdown: string; +} + +function summarizeGmailSync(threads: SyncedThread[]): string { + const lines: string[] = [ + `# Gmail sync update`, + ``, + `${threads.length} new/updated thread${threads.length === 1 ? '' : 's'}.`, + ``, + ]; + + const shown = threads.slice(0, MAX_THREADS_IN_DIGEST); + const hidden = threads.length - shown.length; + + if (shown.length > 0) { + lines.push(`## Threads`, ``); + for (const { markdown } of shown) { + lines.push(markdown.trimEnd(), ``, `---`, ``); + } + if (hidden > 0) { + lines.push(`_…and ${hidden} more thread(s) omitted from digest._`, ``); + } + } + + return lines.join('\n'); +} + +async function publishGmailSyncEvent(threads: SyncedThread[]): Promise { + if (threads.length === 0) return; + try { + await createEvent({ + source: 'gmail', + type: 'email.synced', + createdAt: new Date().toISOString(), + payload: summarizeGmailSync(threads), + }); + } catch (err) { + console.error('[Gmail] Failed to publish sync event:', err); + } +} + // --- Wake Signal for Immediate Sync Trigger --- let wakeResolve: (() => void) | null = null; @@ -113,14 +157,14 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, // --- Sync Logic --- -async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) { +async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise { const gmail = google.gmail({ version: 'v1', auth }); try { const res = await gmail.users.threads.get({ userId: 'me', id: threadId }); const thread = res.data; const messages = thread.messages; - if (!messages || messages.length === 0) return; + if (!messages || messages.length === 0) return null; // Subject from first message const firstHeader = messages[0].payload?.headers; @@ -173,15 +217,11 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); console.log(`Synced Thread: ${subject} (${threadId})`); - await createEvent({ - source: 'gmail', - type: 'email.synced', - createdAt: new Date().toISOString(), - payload: mdContent, - }); + return { threadId, markdown: mdContent }; } catch (error) { console.error(`Error processing thread ${threadId}:`, error); + return null; } } @@ -262,10 +302,14 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str truncated: limitedThreads.truncated, }); + const synced: SyncedThread[] = []; for (const threadId of threadIds) { - await processThread(auth, threadId, syncDir, attachmentsDir); + const result = await processThread(auth, threadId, syncDir, attachmentsDir); + if (result) synced.push(result); } + await publishGmailSyncEvent(synced); + saveState(currentHistoryId, stateFile); await serviceLogger.log({ type: 'run_complete', @@ -365,10 +409,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: truncated: limitedThreads.truncated, }); + const synced: SyncedThread[] = []; for (const tid of threadIdList) { - await processThread(auth, tid, syncDir, attachmentsDir); + const result = await processThread(auth, tid, syncDir, attachmentsDir); + if (result) synced.push(result); } + await publishGmailSyncEvent(synced); + const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); await serviceLogger.log({ @@ -565,7 +613,12 @@ function extractBodyFromPayload(payload: Record): string { return ''; } -async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise { +interface ComposioThreadResult { + synced: SyncedThread | null; + newestIsoPlusOne: string | null; +} + +async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise { let threadResult; try { threadResult = await executeAction( @@ -579,40 +632,34 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin ); } catch (error) { console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); - return null; + return { synced: null, newestIsoPlusOne: null }; } if (!threadResult.successful || !threadResult.data) { console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); - return null; + return { synced: null, newestIsoPlusOne: null }; } const data = threadResult.data as Record; const messages = data.messages as Array> | undefined; let newestDate: Date | null = null; + let mdContent: string; + let subjectForLog: string; if (!messages || messages.length === 0) { const parsed = parseMessageData(data); - const mdContent = `# ${parsed.subject}\n\n` + + mdContent = `# ${parsed.subject}\n\n` + `**Thread ID:** ${threadId}\n` + `**Message Count:** 1\n\n---\n\n` + `### From: ${parsed.from}\n` + `**Date:** ${parsed.date}\n\n` + `${parsed.body}\n\n---\n\n`; - - fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); - console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`); - await createEvent({ - source: 'gmail', - type: 'email.synced', - createdAt: new Date().toISOString(), - payload: mdContent, - }); + subjectForLog = parsed.subject; newestDate = tryParseDate(parsed.date); } else { const firstParsed = parseMessageData(messages[0]); - let mdContent = `# ${firstParsed.subject}\n\n`; + mdContent = `# ${firstParsed.subject}\n\n`; mdContent += `**Thread ID:** ${threadId}\n`; mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; @@ -628,19 +675,14 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin newestDate = msgDate; } } - - fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); - console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`); - await createEvent({ - source: 'gmail', - type: 'email.synced', - createdAt: new Date().toISOString(), - payload: mdContent, - }); + subjectForLog = firstParsed.subject; } - if (!newestDate) return null; - return new Date(newestDate.getTime() + 1000).toISOString(); + fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); + console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`); + + const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null; + return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne }; } async function performSyncComposio() { @@ -751,19 +793,22 @@ async function performSyncComposio() { let highWaterMark: string | null = state?.last_sync ?? null; let processedCount = 0; + const synced: SyncedThread[] = []; for (const threadId of allThreadIds) { // Re-check connection in case user disconnected mid-sync if (!composioAccountsRepo.isConnected('gmail')) { console.log('[Gmail] Account disconnected during sync. Stopping.'); - return; + break; } try { - const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); + const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); processedCount++; - if (newestInThread) { - if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) { - highWaterMark = newestInThread; + if (result.synced) synced.push(result.synced); + + if (result.newestIsoPlusOne) { + if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) { + highWaterMark = result.newestIsoPlusOne; } saveComposioState(STATE_FILE, highWaterMark); } @@ -772,6 +817,8 @@ async function performSyncComposio() { } } + await publishGmailSyncEvent(synced); + await serviceLogger.log({ type: 'run_complete', service: run!.service, From caf00fae0c18684f3d7da34e996e3e775f769452 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:44:02 +0530 Subject: [PATCH 09/76] configurable kg / meeting / track-block model overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back per-category model selection that 5c4aa772 dropped, plus add a new track-block category. Each is a BYOK-only override on `LlmModelConfig` (`knowledgeGraphModel`, `meetingNotesModel`, `trackBlockModel`); signed-in users always get the curated gateway default and never hit the on-disk config. Three helpers in core/models/defaults.ts — `getKgModel`, `getTrackBlockModel`, `getMeetingNotesModel` — each check `isSignedIn` first (fast path) and fall through to `cfg. ?? cfg.model` for BYOK. The model is now picked at the invocation site rather than via runtime agent-name branching: each top-level `createRun` for a polling KG agent or a track-block update passes `model: await getXxxModel()`. The `model:` declarations on the affected agent YAMLs are dropped — they were dead code under the per-call override. Standalone (non-run) callers `track/routing` and `summarize_meeting` use the helpers inline. Settings dialog and the two onboarding flows surface the two new fields ("Meeting Notes Model", "Track Block Model") next to the existing "Knowledge Graph Model"; `repo.setConfig` persists all three per-provider. Note: the signed-in `RowboatModelSettings` panel still has its now-defunct kg selector; that's a UI cleanup for a later pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/onboarding-modal.tsx | 88 +++++++++++++++-- .../onboarding/steps/llm-setup-step.tsx | 70 +++++++++++++ .../onboarding/use-onboarding-state.ts | 24 +++-- .../src/components/settings-dialog.tsx | 98 +++++++++++++++++-- .../core/src/knowledge/agent_notes.ts | 3 +- .../core/src/knowledge/agent_notes_agent.ts | 1 - .../core/src/knowledge/inline_task_agent.ts | 1 - .../core/src/knowledge/inline_tasks.ts | 5 +- .../core/src/knowledge/label_emails.ts | 2 + .../core/src/knowledge/labeling_agent.ts | 1 - .../core/src/knowledge/note_creation.ts | 1 - .../core/src/knowledge/note_tagging_agent.ts | 1 - .../core/src/knowledge/summarize_meeting.ts | 5 +- .../packages/core/src/knowledge/tag_notes.ts | 2 + .../core/src/knowledge/track/routing.ts | 5 +- .../core/src/knowledge/track/runner.ts | 3 +- apps/x/packages/core/src/models/defaults.ts | 35 +++++++ apps/x/packages/core/src/models/repo.ts | 1 + .../core/src/pre_built/email-draft.md | 1 - .../core/src/pre_built/meeting-prep.md | 1 - apps/x/packages/core/src/pre_built/runner.ts | 2 + apps/x/packages/shared/src/models.ts | 5 +- 22 files changed, 309 insertions(+), 46 deletions(-) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index c7f723ac..469ac35d 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -59,14 +59,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, }) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", @@ -109,7 +109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const updateProviderConfig = useCallback( - (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [provider]: { ...prev[provider], ...updates }, @@ -458,6 +458,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined + const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined + const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined const providerConfig = { provider: { flavor: llmProvider, @@ -466,6 +468,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { }, model, knowledgeGraphModel, + meetingNotesModel, + trackBlockModel, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -1157,6 +1161,72 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )}
+ +
+ Meeting notes model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
+ +
+ Track block model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index a9956245..a11b0d5f 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -221,6 +221,76 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { )} + +
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
+ +
+ + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index a55b23fe..edb3616b 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, }) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", @@ -81,7 +81,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) const updateProviderConfig = useCallback( - (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [provider]: { ...prev[provider], ...updates }, @@ -435,6 +435,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined + const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined + const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined const providerConfig = { provider: { flavor: llmProvider, @@ -443,6 +445,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { }, model, knowledgeGraphModel, + meetingNotesModel, + trackBlockModel, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -459,7 +463,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setTestState({ status: "error", error: "Connection test failed" }) toast.error("Connection test failed") } - }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext]) + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.trackBlockModel, canTest, llmProvider, handleNext]) // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 143c6292..ddc506c9 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -196,14 +196,14 @@ const defaultBaseURLs: Partial> = { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") const [defaultProvider, setDefaultProvider] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -229,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -302,6 +302,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), models: savedModels, knowledgeGraphModel: e.knowledgeGraphModel || "", + meetingNotesModel: e.meetingNotesModel || "", + trackBlockModel: e.trackBlockModel || "", }; } } @@ -318,6 +320,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), models: activeModels.length > 0 ? activeModels : [""], knowledgeGraphModel: parsed.knowledgeGraphModel || "", + meetingNotesModel: parsed.meetingNotesModel || "", + trackBlockModel: parsed.trackBlockModel || "", }; } return next; @@ -391,6 +395,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { model: allModels[0] || "", models: allModels, knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, + meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, + trackBlockModel: activeConfig.trackBlockModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -423,6 +429,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { model: allModels[0], models: allModels, knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, + meetingNotesModel: config.meetingNotesModel.trim() || undefined, + trackBlockModel: config.trackBlockModel.trim() || undefined, }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -452,6 +460,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed.model = defModels[0] || "" parsed.models = defModels parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined + parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined + parsed.trackBlockModel = defConfig.trackBlockModel.trim() || undefined } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -459,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }) setProviderConfigs(prev => ({ ...prev, - [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" }, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" }, })) setTestState({ status: "idle" }) window.dispatchEvent(new Event('models-config-changed')) @@ -649,6 +659,74 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )} + + {/* Meeting notes model */} +
+ Meeting notes model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { meetingNotesModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
+ + {/* Track block model */} +
+ Track block model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { trackBlockModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 16307bb5..359976dd 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -3,6 +3,7 @@ import path from 'path'; import { google } from 'googleapis'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; +import { getKgModel } from '../models/defaults.js'; import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { loadUserConfig, updateUserEmail } from '../config/user_config.js'; @@ -305,7 +306,7 @@ async function processAgentNotes(): Promise { const timestamp = new Date().toISOString(); const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`; - const agentRun = await createRun({ agentId: AGENT_ID }); + const agentRun = await createRun({ agentId: AGENT_ID, model: await getKgModel() }); await createMessage(agentRun.id, message); await waitForRunCompletion(agentRun.id); diff --git a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts index d7087405..58aa22a7 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts @@ -1,6 +1,5 @@ export function getRaw(): string { return `--- -model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index 9c3e2568..fd90875b 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -13,7 +13,6 @@ export function getRaw(): string { const defaultEndISO = defaultEnd.toISOString(); return `--- -model: anthropic/claude-sonnet-4.6 tools: ${toolEntries} --- diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 01d22352..953f86bd 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -4,6 +4,7 @@ import { CronExpressionParser } from 'cron-parser'; import { generateText } from 'ai'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage, fetchRun } from '../runs/runs.js'; +import { getKgModel } from '../models/defaults.js'; import container from '../di/container.js'; import type { IModelConfigRepo } from '../models/repo.js'; import { createProvider } from '../models/models.js'; @@ -467,7 +468,7 @@ async function processInlineTasks(): Promise { console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`); try { - const run = await createRun({ agentId: INLINE_TASK_AGENT }); + const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() }); const message = [ `Execute the following instruction from the note "${relativePath}":`, @@ -547,7 +548,7 @@ export async function processRowboatInstruction( scheduleLabel: string | null; response: string | null; }> { - const run = await createRun({ agentId: INLINE_TASK_AGENT }); + const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() }); const message = [ `Process the following @rowboat instruction from the note "${notePath}":`, diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts index 98b10c2f..95b6217b 100644 --- a/apps/x/packages/core/src/knowledge/label_emails.ts +++ b/apps/x/packages/core/src/knowledge/label_emails.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; +import { getKgModel } from '../models/defaults.js'; import { bus } from '../runs/bus.js'; import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; @@ -71,6 +72,7 @@ async function labelEmailBatch( ): Promise<{ runId: string; filesEdited: Set }> { const run = await createRun({ agentId: LABELING_AGENT, + model: await getKgModel(), }); let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`; diff --git a/apps/x/packages/core/src/knowledge/labeling_agent.ts b/apps/x/packages/core/src/knowledge/labeling_agent.ts index bb4a6efe..8842891a 100644 --- a/apps/x/packages/core/src/knowledge/labeling_agent.ts +++ b/apps/x/packages/core/src/knowledge/labeling_agent.ts @@ -2,7 +2,6 @@ import { renderTagSystemForEmails } from './tag_system.js'; export function getRaw(): string { return `--- -model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 283c77ec..0a4d8981 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -3,7 +3,6 @@ import { renderNoteEffectRules } from './tag_system.js'; export function getRaw(): string { return `--- -model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts index 71b10910..8e9e3320 100644 --- a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts +++ b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts @@ -2,7 +2,6 @@ import { renderTagSystemForNotes } from './tag_system.js'; export function getRaw(): string { return `--- -model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index a10aac28..c7e7a71f 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { generateText } from 'ai'; import { createProvider } from '../models/models.js'; -import { getDefaultModelAndProvider, resolveProviderConfig } from '../models/defaults.js'; +import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js'; import { WorkDir } from '../config/config.js'; const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -135,7 +135,8 @@ function loadCalendarEventContext(calendarEventJson: string): string { } export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise { - const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const modelId = await getMeetingNotesModel(); + const { provider: providerName } = await getDefaultModelAndProvider(); const providerConfig = await resolveProviderConfig(providerName); const model = createProvider(providerConfig).languageModel(modelId); diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts index 8fdabb86..2d074ab7 100644 --- a/apps/x/packages/core/src/knowledge/tag_notes.ts +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; +import { getKgModel } from '../models/defaults.js'; import { bus } from '../runs/bus.js'; import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; @@ -84,6 +85,7 @@ async function tagNoteBatch( ): Promise<{ runId: string; filesEdited: Set }> { const run = await createRun({ agentId: NOTE_TAGGING_AGENT, + model: await getKgModel(), }); let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`; diff --git a/apps/x/packages/core/src/knowledge/track/routing.ts b/apps/x/packages/core/src/knowledge/track/routing.ts index 53e6f7b3..6f8f3824 100644 --- a/apps/x/packages/core/src/knowledge/track/routing.ts +++ b/apps/x/packages/core/src/knowledge/track/routing.ts @@ -2,7 +2,7 @@ import { generateObject } from 'ai'; import { trackBlock, PrefixLogger } from '@x/shared'; import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; import { createProvider } from '../../models/models.js'; -import { getDefaultModelAndProvider, resolveProviderConfig } from '../../models/defaults.js'; +import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js'; const log = new PrefixLogger('TrackRouting'); @@ -34,7 +34,8 @@ Rules: - For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`; async function resolveModel() { - const { model, provider } = await getDefaultModelAndProvider(); + const model = await getTrackBlockModel(); + const { provider } = await getDefaultModelAndProvider(); const config = await resolveProviderConfig(provider); return createProvider(config).languageModel(model); } diff --git a/apps/x/packages/core/src/knowledge/track/runner.ts b/apps/x/packages/core/src/knowledge/track/runner.ts index 5ee90024..35f7e7ac 100644 --- a/apps/x/packages/core/src/knowledge/track/runner.ts +++ b/apps/x/packages/core/src/knowledge/track/runner.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { fetchAll, updateTrackBlock } from './fileops.js'; import { createRun, createMessage } from '../../runs/runs.js'; +import { getTrackBlockModel } from '../../models/defaults.js'; import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js'; import { trackBus } from './bus.js'; import type { TrackStateSchema } from './types.js'; @@ -102,7 +103,7 @@ export async function triggerTrackUpdate( const contentBefore = track.content; // Emit start event — runId is set after agent run is created - const agentRun = await createRun({ agentId: 'track-run' }); + const agentRun = await createRun({ agentId: 'track-run', model: await getTrackBlockModel() }); // Set lastRunAt and lastRunId immediately (before agent executes) so // the scheduler's next poll won't re-trigger this track. diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index b9df52da..66dda9e0 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -6,6 +6,8 @@ import container from "../di/container.js"; const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; +const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5"; +const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5"; /** * The single source of truth for "what model+provider should we use when @@ -51,3 +53,36 @@ export async function resolveProviderConfig(name: string): Promise { + if (await isSignedIn()) return SIGNED_IN_KG_MODEL; + const cfg = await container.resolve("modelConfigRepo").getConfig(); + return cfg.knowledgeGraphModel ?? cfg.model; +} + +/** + * Model used by track-block runner + routing classifier. + * Signed-in: curated default. BYOK: user override (`trackBlockModel`) or + * assistant model. + */ +export async function getTrackBlockModel(): Promise { + if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL; + const cfg = await container.resolve("modelConfigRepo").getConfig(); + return cfg.trackBlockModel ?? cfg.model; +} + +/** + * Model used by the meeting-notes summarizer. No special signed-in default — + * historically meetings used the assistant model. BYOK: user override + * (`meetingNotesModel`) or assistant model. + */ +export async function getMeetingNotesModel(): Promise { + if (await isSignedIn()) return SIGNED_IN_DEFAULT_MODEL; + const cfg = await container.resolve("modelConfigRepo").getConfig(); + return cfg.meetingNotesModel ?? cfg.model; +} diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 44a9d475..8f8fb158 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -52,6 +52,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { models: config.models, knowledgeGraphModel: config.knowledgeGraphModel, meetingNotesModel: config.meetingNotesModel, + trackBlockModel: config.trackBlockModel, }; const toWrite = { ...config, providers: existingProviders }; diff --git a/apps/x/packages/core/src/pre_built/email-draft.md b/apps/x/packages/core/src/pre_built/email-draft.md index 7a353d26..7ddd6ffb 100644 --- a/apps/x/packages/core/src/pre_built/email-draft.md +++ b/apps/x/packages/core/src/pre_built/email-draft.md @@ -1,5 +1,4 @@ --- -model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/pre_built/meeting-prep.md b/apps/x/packages/core/src/pre_built/meeting-prep.md index 5dc46eda..3391fc47 100644 --- a/apps/x/packages/core/src/pre_built/meeting-prep.md +++ b/apps/x/packages/core/src/pre_built/meeting-prep.md @@ -1,5 +1,4 @@ --- -model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/pre_built/runner.ts b/apps/x/packages/core/src/pre_built/runner.ts index c1985380..51dae3a0 100644 --- a/apps/x/packages/core/src/pre_built/runner.ts +++ b/apps/x/packages/core/src/pre_built/runner.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; +import { getKgModel } from '../models/defaults.js'; import { waitForRunCompletion } from '../agents/utils.js'; import { loadConfig, @@ -41,6 +42,7 @@ async function runAgent(agentName: string): Promise { // The agent file is expected to be in the agents directory with the same name const run = await createRun({ agentId: agentName, + model: await getKgModel(), }); // Build trigger message with user context diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index feec148f..e5b0e82f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -18,8 +18,9 @@ export const LlmModelConfig = z.object({ model: z.string().optional(), models: z.array(z.string()).optional(), })).optional(), - // Deprecated: per-run model+provider supersedes these. Kept on the schema so - // existing settings/onboarding UIs continue to compile until they're cleaned up. + // Per-category model overrides (BYOK only — signed-in users always get + // the curated gateway defaults). Read by helpers in core/models/defaults.ts. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), + trackBlockModel: z.string().optional(), }); From d42fb26bcc5ba4dde1de49c4df7054ebb9cd5575 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:58:18 +0530 Subject: [PATCH 10/76] allow per-track model + provider overrides Track block YAML gains optional `model` and `provider` fields. When set, the track runner passes them through to `createRun` so this specific track runs on the chosen model/provider; when unset the global default flows through (`getTrackBlockModel()` + the resolved provider). The track skill picks up the new fields automatically via the embedded `z.toJSONSchema(TrackBlockSchema)` and adds an explicit "Do Not Set" section: copilot leaves them omitted unless the user named a specific model or provider for the track. Common bad reasons ("might be faster", "in case it matters", complex instruction) are called out so the defaults stay the path of least resistance. Track modal Details tab shows the values when set, in the same conditional `
/
` style as the lastRun fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../renderer/src/components/track-modal.tsx | 8 ++++++++ .../assistant/skills/tracks/skill.ts | 17 +++++++++++++++++ .../packages/core/src/knowledge/track/runner.ts | 11 +++++++++-- apps/x/packages/shared/src/track-block.ts | 2 ++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/components/track-modal.tsx b/apps/x/apps/renderer/src/components/track-modal.tsx index 8e261977..a4c0b512 100644 --- a/apps/x/apps/renderer/src/components/track-modal.tsx +++ b/apps/x/apps/renderer/src/components/track-modal.tsx @@ -156,6 +156,8 @@ export function TrackModal() { const lastRunAt = track?.lastRunAt ?? '' const lastRunId = track?.lastRunId ?? '' const lastRunSummary = track?.lastRunSummary ?? '' + const model = track?.model ?? '' + const provider = track?.provider ?? '' const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule]) const triggerType: 'scheduled' | 'event' | 'manual' = schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual' @@ -393,6 +395,12 @@ export function TrackModal() {
Track ID
{trackId}
File
{detail.filePath}
Status
{active ? 'Active' : 'Paused'}
+ {model && (<> +
Model
{model}
+ )} + {provider && (<> +
Provider
{provider}
+ )} {lastRunAt && (<>
Last run
{formatDateTime(lastRunAt)}
)} 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 ff345acf..17521806 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 @@ -87,6 +87,23 @@ ${schemaYaml} **Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `. +## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always) + +The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for tracks; setting per-track values bypasses that and is almost always wrong. + +The only time these belong on a track: + +- The user **explicitly** named a model or provider for *this specific track* in their request ("use Claude Opus for this one", "force this track onto OpenAI"). Quote the user's wording back when confirming. + +Things that are **not** reasons to set these: + +- "Tracks should be fast" / "I want a small model" — that's a global preference, not a per-track one. Leave it; the global default exists. +- "This track is complex" — write a clearer instruction; don't reach for a different model. +- "Just to be safe" / "in case it matters" — this is the antipattern. Leave them out. +- The user changed their main chat model — that has nothing to do with tracks. Leave them out. + +When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest. If you find yourself adding them as a sensible default, stop — you're wrong. + ## Choosing a trackId - Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `. diff --git a/apps/x/packages/core/src/knowledge/track/runner.ts b/apps/x/packages/core/src/knowledge/track/runner.ts index 35f7e7ac..1eec3da1 100644 --- a/apps/x/packages/core/src/knowledge/track/runner.ts +++ b/apps/x/packages/core/src/knowledge/track/runner.ts @@ -102,8 +102,15 @@ export async function triggerTrackUpdate( const contentBefore = track.content; - // Emit start event — runId is set after agent run is created - const agentRun = await createRun({ agentId: 'track-run', model: await getTrackBlockModel() }); + // Per-track model/provider overrides win when set; otherwise fall back + // to the configured trackBlockModel default and the run-creation + // provider default (signed-in: rowboat; BYOK: active provider). + const model = track.track.model ?? await getTrackBlockModel(); + const agentRun = await createRun({ + agentId: 'track-run', + model, + ...(track.track.provider ? { provider: track.track.provider } : {}), + }); // Set lastRunAt and lastRunId immediately (before agent executes) so // the scheduler's next poll won't re-trigger this track. diff --git a/apps/x/packages/shared/src/track-block.ts b/apps/x/packages/shared/src/track-block.ts index c9e738b7..6d9ce3af 100644 --- a/apps/x/packages/shared/src/track-block.ts +++ b/apps/x/packages/shared/src/track-block.ts @@ -25,6 +25,8 @@ export const TrackBlockSchema = z.object({ eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'), active: z.boolean().default(true).describe('Set false to pause without deleting'), schedule: TrackScheduleSchema.optional(), + model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'), + provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'), lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'), lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'), lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'), From 43c1ba719f2cfba4d23e5b2ea1589a4b72d67d8a Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:53:40 +0530 Subject: [PATCH 11/76] add posthog analytics for llm usage and auth events Captures per-LLM-call token usage tagged by feature (copilot chat, track block, meeting note, knowledge sync), plus sign-in / sign-out and identity. Renderer and main share one PostHog identity so events from either process resolve to the same user. See apps/x/ANALYTICS.md for the event catalog, person properties, use-case taxonomy, and how to add new events. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + apps/x/ANALYTICS.md | 145 ++++++++++++++++++ apps/x/apps/main/bundle.mjs | 5 + apps/x/apps/main/src/ipc.ts | 10 +- apps/x/apps/main/src/main.ts | 4 + apps/x/apps/main/src/oauth-handler.ts | 26 +++- .../src/hooks/useAnalyticsIdentity.ts | 28 +++- apps/x/apps/renderer/src/main.tsx | 51 ++++-- apps/x/packages/core/package.json | 1 + .../core/src/agent-schedule/runner.ts | 6 +- apps/x/packages/core/src/agents/runtime.ts | 42 +++++ .../core/src/analytics/installation.ts | 37 +++++ apps/x/packages/core/src/analytics/posthog.ts | 90 +++++++++++ apps/x/packages/core/src/analytics/usage.ts | 38 +++++ .../x/packages/core/src/analytics/use_case.ts | 28 ++++ .../core/src/application/lib/builtin-tools.ts | 12 ++ apps/x/packages/core/src/config/env.ts | 2 +- .../core/src/knowledge/agent_notes.ts | 7 +- .../core/src/knowledge/build_graph.ts | 2 + .../core/src/knowledge/inline_tasks.ts | 23 ++- .../core/src/knowledge/label_emails.ts | 2 + .../core/src/knowledge/summarize_meeting.ts | 8 + .../packages/core/src/knowledge/tag_notes.ts | 2 + .../core/src/knowledge/track/routing.ts | 22 ++- .../core/src/knowledge/track/runner.ts | 2 + apps/x/packages/core/src/pre_built/runner.ts | 2 + apps/x/packages/core/src/runs/repo.ts | 16 +- apps/x/packages/core/src/runs/runs.ts | 9 +- apps/x/packages/shared/src/ipc.ts | 7 + apps/x/packages/shared/src/runs.ts | 20 +++ apps/x/pnpm-lock.yaml | 13 ++ 31 files changed, 625 insertions(+), 36 deletions(-) create mode 100644 apps/x/ANALYTICS.md create mode 100644 apps/x/packages/core/src/analytics/installation.ts create mode 100644 apps/x/packages/core/src/analytics/posthog.ts create mode 100644 apps/x/packages/core/src/analytics/usage.ts create mode 100644 apps/x/packages/core/src/analytics/use_case.ts diff --git a/CLAUDE.md b/CLAUDE.md index 51a11e35..6bbcf22b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,7 @@ Long-form docs for specific features. Read the relevant file before making chang | Feature | Doc | |---------|-----| | Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` | +| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` | ## Common Tasks diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md new file mode 100644 index 00000000..04659952 --- /dev/null +++ b/apps/x/ANALYTICS.md @@ -0,0 +1,145 @@ +# Analytics + +> PostHog instrumentation for `apps/x`. We capture LLM token usage (broken down by feature) and identity/auth events. Renderer (`posthog-js`) and main (`posthog-node`) share one stable distinct_id and one identified user, so events from either process resolve to the same person. + +## Identity model + +- **Anonymous distinct_id** = `installationId` from `~/.rowboat/config/installation.json` (auto-generated on first run; see `packages/core/src/analytics/installation.ts`). +- Renderer fetches it from main on startup via the `analytics:bootstrap` IPC channel and passes it as PostHog's `bootstrap.distinctID`. Main uses it directly in `posthog-node`. +- **On rowboat sign-in**: `posthog.identify(rowboatUserId)` runs in **both** processes. + - Main does it from `apps/main/src/oauth-handler.ts:285` (after `getBillingInfo()` resolves) — this is the load-bearing call, since main always runs. + - Renderer mirrors via `apps/renderer/src/hooks/useAnalyticsIdentity.ts` listening on the `oauth:didConnect` IPC event. + - Main also calls `alias()` so events emitted under the anonymous installation_id are linked to the identified user retroactively. +- **On rowboat sign-out**: `posthog.reset()` in both processes; future events resolve to the installation_id again. +- **`email`** is set on `identify` from main only (sourced from `/v1/me`). Person properties are server-side, so the renderer's events resolve to the same record without redundantly setting it. + +## Event catalog + +### `llm_usage` + +Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). + +| Property | Type | Notes | +|---|---|---| +| `use_case` | enum | `copilot_chat` / `track_block` / `meeting_note` / `knowledge_sync` | +| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below | +| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` | +| `model` | string | e.g. `claude-sonnet-4-6` | +| `provider` | string | `rowboat` = cloud LLM gateway; otherwise the BYOK provider (`openai`, `anthropic`, `ollama`, etc.) | +| `input_tokens` | number | | +| `output_tokens` | number | | +| `total_tokens` | number | | +| `cached_input_tokens` | number? | When the provider reports it | +| `reasoning_tokens` | number? | When the provider reports it | + +#### Use-case taxonomy + +Every `llm_usage` emit point in the codebase: + +| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line | +|---|---|---|---|---| +| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) | +| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` | +| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` | +| `track_block` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/track/routing.ts:104` | +| `track_block` | `run` | yes | Pass 2 track block execution | `packages/core/src/knowledge/track/runner.ts:109` (createRun) | +| `meeting_note` | (none) | no | Meeting transcript summarizer (`generateText`) | `packages/core/src/knowledge/summarize_meeting.ts:161` | +| `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) | +| `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) | +| `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) | +| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) | +| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | +| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | +| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | + +`testModelConnection` in `packages/core/src/models/models.ts` is **not** instrumented (diagnostic only — would skew per-model counts). + +### `user_signed_in` + +Emitted when rowboat OAuth completes. Properties: `plan`, `status` (subscription state from `/v1/me`). + +Emitted from **both** processes: +- Main (`apps/main/src/oauth-handler.ts:290`) — always fires; load-bearing. +- Renderer (`apps/renderer/src/hooks/useAnalyticsIdentity.ts:75`) — fires only when the renderer is open. Same distinct_id, so dedup is automatic in PostHog dashboards. + +### `user_signed_out` + +Emitted on rowboat disconnect. No properties. Followed immediately by `posthog.reset()`. + +Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/useAnalyticsIdentity.ts:82`. + +### Other events (pre-existing, not added by the LLM-usage work) + +All in `apps/renderer/src/lib/analytics.ts`: + +- `chat_session_created` — `{ run_id }` +- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }` +- `oauth_connected` / `oauth_disconnected` — `{ provider }` +- `voice_input_started` — no properties +- `search_executed` — `{ types: string[] }` +- `note_exported` — `{ format }` + +## Person properties + +Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`. + +| Property | Set by | Notes | +|---|---|---| +| `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | +| `plan`, `status` | main on identify | Subscription state | +| `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | +| `signed_in` | renderer | `true` while rowboat OAuth is connected | +| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | +| `total_notes` | renderer (init) | Workspace size signal | +| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags | + +## How to add a new event + +1. **Naming**: `snake_case`, `[object]_[verb]` shape (e.g. `note_exported`, not `exportedNote`). Matches PostHog convention. +2. **Pick the right helper**: + - LLM token usage → `captureLlmUsage()` from `@x/core/dist/analytics/usage.js`. Always include `useCase`; add `subUseCase` if it refines an existing top-level case. + - Anything else from main → `capture()` from `@x/core/dist/analytics/posthog.js`. + - Anything else from renderer → add a typed wrapper to `apps/renderer/src/lib/analytics.ts` and call it from the UI code (don't call `posthog.capture()` directly from components). +3. **If it's a new LLM call site**: + - Goes through `createRun`? Pass `useCase` (and optionally `subUseCase`) to the create call. The runtime auto-emits at every `finish-step` — no further code needed. + - Direct `generateText` / `generateObject`? Call `captureLlmUsage` after the call with `model`, `provider`, `usage` from the result. + - Inside a builtin tool? Call `getCurrentUseCase()` from `analytics/use_case.ts` first — the parent run's tag is propagated via `AsyncLocalStorage`. Use `ctx?.useCase ?? 'copilot_chat'` as fallback. +4. **Update this file in the same PR.** That's the contract — without it, dashboards and downstream consumers drift. + +## How to add a new use-case sub-case + +- **New `sub_use_case` under an existing top-level case**: just pick a string and add a row to the taxonomy table above. No code changes beyond the call site. +- **New top-level `use_case`**: edit the `UseCase` enum in `packages/shared/src/runs.ts` and the matching `UseCase` type in `packages/core/src/analytics/use_case.ts`. Then update this doc. + +## Configuration + +PostHog credentials live in two env vars (also baked into the binary at packaging time — never set at runtime in distributed builds): + +- `VITE_PUBLIC_POSTHOG_KEY` — project API key (e.g. `phc_xxx`). Public-facing — safe to commit if you'd rather hardcode. +- `VITE_PUBLIC_POSTHOG_HOST` — e.g. `https://us.i.posthog.com`. Defaults to US cloud if unset. + +Where they're consumed: +- **Renderer** (Vite): `import.meta.env.VITE_PUBLIC_POSTHOG_*` — inlined at build time. +- **Main** (esbuild via `apps/main/bundle.mjs`): inlined into `main.cjs` at packaging time using esbuild `define`. In dev (`npm run dev`), main reads them from `process.env` at runtime. + +For GitHub Actions / packaged builds: set both as workflow env vars (from secrets) on the step that runs `npm run package` or `npm run make`. They'll be baked in. + +If unset, analytics no-op silently — you'll see `[Analytics] POSTHOG_KEY not set; analytics disabled` in main-process logs. + +`installationId`: stored in `~/.rowboat/config/installation.json`, generated on first run. + +## File map + +| File | Purpose | +|---|---| +| `packages/core/src/analytics/installation.ts` | Stable per-install distinct_id | +| `packages/core/src/analytics/posthog.ts` | Main-process client (`capture`, `identify`, `reset`, `shutdown`) | +| `packages/core/src/analytics/usage.ts` | `captureLlmUsage()` helper | +| `packages/core/src/analytics/use_case.ts` | `AsyncLocalStorage` for tool-internal LLM call inheritance | +| `apps/renderer/src/lib/analytics.ts` | Renderer event wrappers | +| `apps/renderer/src/hooks/useAnalyticsIdentity.ts` | Renderer identify/reset on OAuth events | +| `apps/main/src/oauth-handler.ts` | Main-side identify/reset/sign-in/sign-out events | +| `apps/main/src/main.ts` | `before-quit` hook flushes queued events | +| `packages/shared/src/ipc.ts` | `analytics:bootstrap` IPC channel definition | +| `apps/main/src/ipc.ts` | `analytics:bootstrap` handler + forwards `userId` on `oauth:didConnect` | +| `apps/main/bundle.mjs` | Bakes `POSTHOG_KEY`/`POSTHOG_HOST` into packaged `main.cjs` | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 2444e356..9ae77e0e 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -31,6 +31,11 @@ await esbuild.build({ // Replace import.meta.url directly with our polyfill variable define: { 'import.meta.url': '__import_meta_url', + // Inject PostHog credentials at build time. Reuse the renderer's + // VITE_PUBLIC_* envs so packaging only needs one set of values. + // Empty strings disable analytics gracefully. + 'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), + 'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'), }, }); diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a9de9572..5e62e8ee 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -46,6 +46,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js'; import { trackBus } from '@x/core/dist/knowledge/track/bus.js'; +import { getInstallationId } from '@x/core/dist/analytics/installation.js'; +import { API_URL } from '@x/core/dist/config/env.js'; import { fetchYaml, updateTrackBlock, @@ -342,7 +344,7 @@ function emitServiceEvent(event: z.infer): void { } } -export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { +export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { if (!win.isDestroyed() && win.webContents) { @@ -415,6 +417,12 @@ export function setupIpcHandlers() { // args is null for this channel (no request payload) return getVersions(); }, + 'analytics:bootstrap': async () => { + return { + installationId: getInstallationId(), + apiUrl: API_URL, + }; + }, 'workspace:getRoot': async () => { return workspace.getRoot(); }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index eea21481..f04a0ecc 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -26,6 +26,7 @@ import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; +import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -318,4 +319,7 @@ app.on("before-quit", () => { shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); + shutdownAnalytics().catch((error) => { + console.error('[Analytics] Failed to flush on quit:', error); + }); }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 3bb9063b..d3caba38 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -12,6 +12,7 @@ import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_ import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent } from './ipc.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; +import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -275,16 +276,33 @@ export async function connectProvider(provider: string, credentials?: { clientId // For Rowboat sign-in, ensure user + Stripe customer exist before // notifying the renderer. Without this, parallel API calls from // multiple renderer hooks race to create the user, causing duplicates. + let signedInUserId: string | undefined; if (provider === 'rowboat') { try { - await getBillingInfo(); + const billing = await getBillingInfo(); + if (billing.userId) { + signedInUserId = billing.userId; + analyticsIdentify(billing.userId, { + ...(billing.userEmail ? { email: billing.userEmail } : {}), + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + analyticsCapture('user_signed_in', { + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + } } catch (meError) { console.error('[OAuth] Failed to initialize user via /v1/me:', meError); } } // Emit success event to renderer - emitOAuthEvent({ provider, success: true }); + emitOAuthEvent({ + provider, + success: true, + ...(signedInUserId ? { userId: signedInUserId } : {}), + }); } catch (error) { console.error('OAuth token exchange failed:', error); // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) @@ -347,6 +365,10 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.delete(provider); + if (provider === 'rowboat') { + analyticsCapture('user_signed_out'); + analyticsReset(); + } // Notify renderer so sidebar, voice, and billing re-check state emitOAuthEvent({ provider, success: false }); return { success: true }; diff --git a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts index 272014f8..82220782 100644 --- a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts +++ b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts @@ -58,15 +58,29 @@ export function useAnalyticsIdentity() { // Listen for OAuth connect/disconnect events to update identity useEffect(() => { const cleanup = window.ipc.on('oauth:didConnect', (event) => { - if (!event.success) return - - // If Rowboat provider connected, identify user - if (event.provider === 'rowboat' && event.userId) { - posthog.identify(event.userId) - posthog.people.set({ signed_in: true }) + if (event.provider !== 'rowboat') { + // Other providers: just toggle the connection flag + if (event.success) { + posthog.people.set({ [`${event.provider}_connected`]: true }) + } + return } - posthog.people.set({ [`${event.provider}_connected`]: true }) + // Rowboat sign-in + if (event.success) { + if (event.userId) { + posthog.identify(event.userId) + } + posthog.people.set({ signed_in: true, rowboat_connected: true }) + posthog.capture('user_signed_in') + return + } + + // Rowboat sign-out — flip flags, capture, and reset distinct_id so + // future events on this device don't get attributed to the prior user. + posthog.people.set({ signed_in: false, rowboat_connected: false }) + posthog.capture('user_signed_out') + posthog.reset() }) return cleanup diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index 7ad7ac86..fedc029c 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -2,20 +2,45 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' import { ThemeProvider } from '@/contexts/theme-context' -const options = { - api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: '2025-11-30', -} as const +// Fetch the stable installation ID from main so renderer + main share one +// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID +// if the IPC call fails (rare — main is always up before renderer). +async function bootstrap() { + let installationId: string | undefined + let apiUrl: string | undefined + try { + const result = await window.ipc.invoke('analytics:bootstrap', null) + installationId = result.installationId + apiUrl = result.apiUrl + } catch (err) { + console.error('[Analytics] Failed to bootstrap from main:', err) + } -createRoot(document.getElementById('root')!).render( - - - - - - - , -) + const options = { + api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, + defaults: '2025-11-30', + ...(installationId ? { bootstrap: { distinctID: installationId } } : {}), + } as const + + createRoot(document.getElementById('root')!).render( + + + + + + + , + ) + + // Tag the active person record with api_url so anonymous users are also + // segmentable by environment. + if (apiUrl) { + posthog.people.set({ api_url: apiUrl }) + } +} + +bootstrap() diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 72d6f079..f8dbac06 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -37,6 +37,7 @@ "openid-client": "^6.8.1", "papaparse": "^5.5.3", "pdf-parse": "^2.4.5", + "posthog-node": "^4.18.0", "react": "^19.2.3", "xlsx": "^0.18.5", "yaml": "^2.8.2", diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts index 5fca6878..44dac07d 100644 --- a/apps/x/packages/core/src/agent-schedule/runner.ts +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -164,7 +164,11 @@ async function runAgent( try { // Create a new run via core (resolves agent + default model+provider). - const run = await createRun({ agentId: agentName }); + const run = await createRun({ + agentId: agentName, + useCase: 'copilot_chat', + subUseCase: 'scheduled', + }); console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); // Add the starting message as a user message diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 6c84ac8b..a635b4e9 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -26,6 +26,8 @@ import { IRunsLock } from "../runs/lock.js"; import { IAbortRegistry } from "../runs/abort-registry.js"; import { PrefixLogger } from "@x/shared"; import { parse } from "yaml"; +import { captureLlmUsage } from "../analytics/usage.js"; +import { enterUseCase, type UseCase } from "../analytics/use_case.js"; import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js"; import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; @@ -650,6 +652,8 @@ export class AgentState { agentName: string | null = null; runModel: string | null = null; runProvider: string | null = null; + runUseCase: UseCase | null = null; + runSubUseCase: string | null = null; messages: z.infer = []; lastAssistantMsg: z.infer | null = null; subflowStates: Record = {}; @@ -765,6 +769,8 @@ export class AgentState { this.agentName = event.agentName; this.runModel = event.model; this.runProvider = event.provider; + this.runUseCase = event.useCase ?? null; + this.runSubUseCase = event.subUseCase ?? null; break; case "spawn-subflow": // Seed the subflow state with its agent so downstream loadAgent works. @@ -775,6 +781,8 @@ export class AgentState { this.subflowStates[event.toolCallId].agentName = event.agentName; this.subflowStates[event.toolCallId].runModel = this.runModel; this.subflowStates[event.toolCallId].runProvider = this.runProvider; + this.subflowStates[event.toolCallId].runUseCase = this.runUseCase; + this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase; break; case "message": this.messages.push(event.message); @@ -881,6 +889,14 @@ export async function* streamAgent({ const model = provider.languageModel(modelId); logger.log(`using model: ${modelId} (provider: ${state.runProvider})`); + // Install use-case context for tool-internal LLM calls (e.g. parseFile) + // so they can tag their `llm_usage` events with the parent run's category. + enterUseCase({ + useCase: state.runUseCase ?? "copilot_chat", + ...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}), + ...(state.agentName ? { agentName: state.agentName } : {}), + }); + let loopCounter = 0; let voiceInput = false; let voiceOutput: 'summary' | 'full' | null = null; @@ -1114,6 +1130,13 @@ export async function* streamAgent({ instructionsWithDateTime, tools, signal, + { + useCase: state.runUseCase ?? "copilot_chat", + ...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}), + agentName: state.agentName ?? undefined, + modelId, + providerName: state.runProvider!, + }, )) { messageBuilder.ingest(event); yield* processEvent({ @@ -1201,12 +1224,21 @@ export async function* streamAgent({ } } +interface StreamLlmAnalytics { + useCase: UseCase; + subUseCase?: string; + agentName?: string; + modelId: string; + providerName: string; +} + async function* streamLlm( model: LanguageModel, messages: z.infer, instructions: string, tools: ToolSet, signal?: AbortSignal, + analytics?: StreamLlmAnalytics, ): AsyncGenerator, void, unknown> { const converted = convertFromMessages(messages); console.log(`! SENDING payload to model: `, JSON.stringify(converted)) @@ -1277,6 +1309,16 @@ async function* streamLlm( }; break; case "finish-step": + if (analytics) { + captureLlmUsage({ + useCase: analytics.useCase, + ...(analytics.subUseCase ? { subUseCase: analytics.subUseCase } : {}), + ...(analytics.agentName ? { agentName: analytics.agentName } : {}), + model: analytics.modelId, + provider: analytics.providerName, + usage: event.usage, + }); + } yield { type: "finish-step", usage: event.usage, diff --git a/apps/x/packages/core/src/analytics/installation.ts b/apps/x/packages/core/src/analytics/installation.ts new file mode 100644 index 00000000..857a1bd6 --- /dev/null +++ b/apps/x/packages/core/src/analytics/installation.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { WorkDir } from '../config/config.js'; + +const INSTALLATION_PATH = path.join(WorkDir, 'config', 'installation.json'); + +let cached: string | null = null; + +export function getInstallationId(): string { + if (cached) return cached; + try { + if (fs.existsSync(INSTALLATION_PATH)) { + const raw = fs.readFileSync(INSTALLATION_PATH, 'utf-8'); + const parsed = JSON.parse(raw) as { installationId?: string }; + if (parsed.installationId && typeof parsed.installationId === 'string') { + cached = parsed.installationId; + return cached; + } + } + } catch (err) { + console.error('[Analytics] Failed to read installation.json:', err); + } + + const id = randomUUID(); + try { + const dir = path.dirname(INSTALLATION_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(INSTALLATION_PATH, JSON.stringify({ installationId: id }, null, 2)); + } catch (err) { + console.error('[Analytics] Failed to write installation.json:', err); + } + cached = id; + return id; +} diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts new file mode 100644 index 00000000..156194d9 --- /dev/null +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -0,0 +1,90 @@ +import { PostHog } from 'posthog-node'; +import { getInstallationId } from './installation.js'; +import { API_URL } from '../config/env.js'; + +// Build-time injected via esbuild `define` (apps/main/bundle.mjs). +// In dev/tsc, fall back to process.env so local runs work too. +const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''; +const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'; + +let client: PostHog | null = null; +let initAttempted = false; +let identifiedUserId: string | null = null; + +function getClient(): PostHog | null { + if (initAttempted) return client; + initAttempted = true; + if (!POSTHOG_KEY) { + console.log('[Analytics] POSTHOG_KEY not set; analytics disabled'); + return null; + } + try { + client = new PostHog(POSTHOG_KEY, { + host: POSTHOG_HOST, + flushAt: 20, + flushInterval: 10_000, + }); + // Tag the install with api_url as a person property up-front, + // so anonymous users are also segmentable by environment (api_url + // distinguishes prod / staging / custom — meaning is assigned in PostHog). + client.identify({ + distinctId: getInstallationId(), + properties: { api_url: API_URL }, + }); + } catch (err) { + console.error('[Analytics] Failed to init PostHog:', err); + client = null; + } + return client; +} + +function activeDistinctId(): string { + return identifiedUserId ?? getInstallationId(); +} + +export function capture(event: string, properties?: Record): void { + const ph = getClient(); + if (!ph) return; + try { + ph.capture({ + distinctId: activeDistinctId(), + event, + properties, + }); + } catch (err) { + console.error('[Analytics] capture failed:', err); + } +} + +export function identify(userId: string, properties?: Record): void { + const ph = getClient(); + if (!ph) return; + try { + // Alias the anonymous installation ID to the rowboat user ID so historical + // anonymous events are linked to the identified user. + ph.alias({ distinctId: userId, alias: getInstallationId() }); + ph.identify({ + distinctId: userId, + properties: { + ...properties, + api_url: API_URL, + }, + }); + identifiedUserId = userId; + } catch (err) { + console.error('[Analytics] identify failed:', err); + } +} + +export function reset(): void { + identifiedUserId = null; +} + +export async function shutdown(): Promise { + if (!client) return; + try { + await client.shutdown(); + } catch (err) { + console.error('[Analytics] shutdown failed:', err); + } +} diff --git a/apps/x/packages/core/src/analytics/usage.ts b/apps/x/packages/core/src/analytics/usage.ts new file mode 100644 index 00000000..31b703dc --- /dev/null +++ b/apps/x/packages/core/src/analytics/usage.ts @@ -0,0 +1,38 @@ +import { capture } from './posthog.js'; +import type { UseCase } from './use_case.js'; + +// Shape compatible with ai-sdk v5 `LanguageModelUsage`. +// All fields are optional because providers report subsets. +export interface LlmUsageInput { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + cachedInputTokens?: number; +} + +export interface CaptureLlmUsageArgs { + useCase: UseCase; + subUseCase?: string; + agentName?: string; + model: string; + provider: string; + usage: LlmUsageInput | undefined; +} + +export function captureLlmUsage(args: CaptureLlmUsageArgs): void { + const usage = args.usage ?? {}; + const properties: Record = { + use_case: args.useCase, + model: args.model, + provider: args.provider, + input_tokens: usage.inputTokens ?? 0, + output_tokens: usage.outputTokens ?? 0, + total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0), + }; + if (args.subUseCase) properties.sub_use_case = args.subUseCase; + if (args.agentName) properties.agent_name = args.agentName; + if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens; + if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens; + capture('llm_usage', properties); +} diff --git a/apps/x/packages/core/src/analytics/use_case.ts b/apps/x/packages/core/src/analytics/use_case.ts new file mode 100644 index 00000000..2dcf1ae2 --- /dev/null +++ b/apps/x/packages/core/src/analytics/use_case.ts @@ -0,0 +1,28 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export type UseCase = 'copilot_chat' | 'track_block' | 'meeting_note' | 'knowledge_sync'; + +export interface UseCaseContext { + useCase: UseCase; + subUseCase?: string; + agentName?: string; +} + +const storage = new AsyncLocalStorage(); + +export function withUseCase(ctx: UseCaseContext, fn: () => T): T { + return storage.run(ctx, fn); +} + +/** + * Permanently install a use-case context for the current async chain. + * Use inside generator functions where wrapping with `withUseCase()` doesn't + * compose. Child async work (e.g. tool execution) will inherit it. + */ +export function enterUseCase(ctx: UseCaseContext): void { + storage.enterWith(ctx); +} + +export function getCurrentUseCase(): UseCaseContext | undefined { + return storage.getStore(); +} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 52083277..4fd347b6 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -22,6 +22,8 @@ import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js"; +import { captureLlmUsage } from "../../analytics/usage.js"; +import { getCurrentUseCase } from "../../analytics/use_case.js"; import { isSignedIn } from "../../account/account.js"; import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; @@ -764,6 +766,16 @@ export const BuiltinTools: z.infer = { ], }); + const ctx = getCurrentUseCase(); + captureLlmUsage({ + useCase: ctx?.useCase ?? 'copilot_chat', + subUseCase: 'file_parse', + ...(ctx?.agentName ? { agentName: ctx.agentName } : {}), + model: modelId, + provider: providerName, + usage: response.usage, + }); + return { success: true, fileName, diff --git a/apps/x/packages/core/src/config/env.ts b/apps/x/packages/core/src/config/env.ts index 4c638986..0f4026f5 100644 --- a/apps/x/packages/core/src/config/env.ts +++ b/apps/x/packages/core/src/config/env.ts @@ -1,2 +1,2 @@ export const API_URL = - process.env.API_URL || 'https://api.x.rowboatlabs.com'; \ No newline at end of file + process.env.API_URL || 'https://api.x.rowboatlabs.com'; diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 359976dd..471bfecd 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -306,7 +306,12 @@ async function processAgentNotes(): Promise { const timestamp = new Date().toISOString(); const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`; - const agentRun = await createRun({ agentId: AGENT_ID, model: await getKgModel() }); + const agentRun = await createRun({ + agentId: AGENT_ID, + model: await getKgModel(), + useCase: 'knowledge_sync', + subUseCase: 'agent_notes', + }); await createMessage(agentRun.id, message); await waitForRunCompletion(agentRun.id); diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 60c0572e..d47413ca 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -252,6 +252,8 @@ async function createNotesFromBatch( // Create a run for the note creation agent const run = await createRun({ agentId: NOTE_CREATION_AGENT, + useCase: 'knowledge_sync', + subUseCase: 'build_graph', }); const suggestedTopicsContent = readSuggestedTopicsFile(); diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 953f86bd..5a19e4bd 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -10,6 +10,7 @@ import type { IModelConfigRepo } from '../models/repo.js'; import { createProvider } from '../models/models.js'; import { inlineTask } from '@x/shared'; import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js'; +import { captureLlmUsage } from '../analytics/usage.js'; const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds const INLINE_TASK_AGENT = 'inline_task_agent'; @@ -468,7 +469,12 @@ async function processInlineTasks(): Promise { console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`); try { - const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() }); + const run = await createRun({ + agentId: INLINE_TASK_AGENT, + model: await getKgModel(), + useCase: 'knowledge_sync', + subUseCase: 'inline_task_run', + }); const message = [ `Execute the following instruction from the note "${relativePath}":`, @@ -548,7 +554,12 @@ export async function processRowboatInstruction( scheduleLabel: string | null; response: string | null; }> { - const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() }); + const run = await createRun({ + agentId: INLINE_TASK_AGENT, + model: await getKgModel(), + useCase: 'knowledge_sync', + subUseCase: 'inline_task_run', + }); const message = [ `Process the following @rowboat instruction from the note "${notePath}":`, @@ -659,6 +670,14 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`; prompt: instruction, }); + captureLlmUsage({ + useCase: 'knowledge_sync', + subUseCase: 'inline_task_classify', + model: config.model, + provider: config.provider.flavor, + usage: result.usage, + }); + let text = result.text.trim(); console.log('[classifySchedule] LLM response:', text); // Strip markdown code fences if the LLM wraps the JSON diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts index 95b6217b..9ee57798 100644 --- a/apps/x/packages/core/src/knowledge/label_emails.ts +++ b/apps/x/packages/core/src/knowledge/label_emails.ts @@ -73,6 +73,8 @@ async function labelEmailBatch( const run = await createRun({ agentId: LABELING_AGENT, model: await getKgModel(), + useCase: 'knowledge_sync', + subUseCase: 'label_emails', }); let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`; diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index c7e7a71f..cd84cdb5 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -4,6 +4,7 @@ import { generateText } from 'ai'; import { createProvider } from '../models/models.js'; import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js'; import { WorkDir } from '../config/config.js'; +import { captureLlmUsage } from '../analytics/usage.js'; const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -157,5 +158,12 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st prompt, }); + captureLlmUsage({ + useCase: 'meeting_note', + model: modelId, + provider: providerName, + usage: result.usage, + }); + return result.text.trim(); } diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts index 2d074ab7..7a888725 100644 --- a/apps/x/packages/core/src/knowledge/tag_notes.ts +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -86,6 +86,8 @@ async function tagNoteBatch( const run = await createRun({ agentId: NOTE_TAGGING_AGENT, model: await getKgModel(), + useCase: 'knowledge_sync', + subUseCase: 'tag_notes', }); let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`; diff --git a/apps/x/packages/core/src/knowledge/track/routing.ts b/apps/x/packages/core/src/knowledge/track/routing.ts index 6f8f3824..49ab29d3 100644 --- a/apps/x/packages/core/src/knowledge/track/routing.ts +++ b/apps/x/packages/core/src/knowledge/track/routing.ts @@ -3,6 +3,7 @@ import { trackBlock, PrefixLogger } from '@x/shared'; import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; import { createProvider } from '../../models/models.js'; import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js'; +import { captureLlmUsage } from '../../analytics/usage.js'; const log = new PrefixLogger('TrackRouting'); @@ -34,10 +35,14 @@ Rules: - For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`; async function resolveModel() { - const model = await getTrackBlockModel(); + const modelId = await getTrackBlockModel(); const { provider } = await getDefaultModelAndProvider(); const config = await resolveProviderConfig(provider); - return createProvider(config).languageModel(model); + return { + model: createProvider(config).languageModel(modelId), + modelId, + providerName: provider, + }; } function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string { @@ -84,19 +89,26 @@ export async function findCandidates( log.log(`Routing event ${event.id} against ${filtered.length} track(s)`); - const model = await resolveModel(); + const { model, modelId, providerName } = await resolveModel(); const candidateKeys = new Set(); for (let i = 0; i < filtered.length; i += BATCH_SIZE) { const batch = filtered.slice(i, i + BATCH_SIZE); try { - const { object } = await generateObject({ + const result = await generateObject({ model, system: ROUTING_SYSTEM_PROMPT, prompt: buildRoutingPrompt(event, batch), schema: trackBlock.Pass1OutputSchema, }); - for (const c of object.candidates) { + captureLlmUsage({ + useCase: 'track_block', + subUseCase: 'routing', + model: modelId, + provider: providerName, + usage: result.usage, + }); + for (const c of result.object.candidates) { candidateKeys.add(trackKey(c.trackId, c.filePath)); } } catch (err) { diff --git a/apps/x/packages/core/src/knowledge/track/runner.ts b/apps/x/packages/core/src/knowledge/track/runner.ts index 1eec3da1..ab48d12e 100644 --- a/apps/x/packages/core/src/knowledge/track/runner.ts +++ b/apps/x/packages/core/src/knowledge/track/runner.ts @@ -110,6 +110,8 @@ export async function triggerTrackUpdate( agentId: 'track-run', model, ...(track.track.provider ? { provider: track.track.provider } : {}), + useCase: 'track_block', + subUseCase: 'run', }); // Set lastRunAt and lastRunId immediately (before agent executes) so diff --git a/apps/x/packages/core/src/pre_built/runner.ts b/apps/x/packages/core/src/pre_built/runner.ts index 51dae3a0..0596372f 100644 --- a/apps/x/packages/core/src/pre_built/runner.ts +++ b/apps/x/packages/core/src/pre_built/runner.ts @@ -43,6 +43,8 @@ async function runAgent(agentName: string): Promise { const run = await createRun({ agentId: agentName, model: await getKgModel(), + useCase: 'knowledge_sync', + subUseCase: 'pre_built', }); // Build trigger message with user context diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 502976e6..bbc148fd 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -5,7 +5,7 @@ import path from "path"; import fsp from "fs/promises"; import fs from "fs"; import readline from "readline"; -import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js"; +import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js"; import { getDefaultModelAndProvider } from "../models/defaults.js"; /** @@ -24,7 +24,13 @@ const LegacyStartEvent = StartEvent.extend({ }); const ReadRunEvent = RunEvent.or(LegacyStartEvent); -export type CreateRunRepoOptions = Required>; +export type CreateRunRepoOptions = { + agentId: string; + model: string; + provider: string; + useCase: z.infer; + subUseCase?: string; +}; export interface IRunsRepo { create(options: CreateRunRepoOptions): Promise>; @@ -187,6 +193,8 @@ export class FSRunsRepo implements IRunsRepo { agentName: options.agentId, model: options.model, provider: options.provider, + useCase: options.useCase, + ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), subflow: [], ts, }; @@ -197,6 +205,8 @@ export class FSRunsRepo implements IRunsRepo { agentId: options.agentId, model: options.model, provider: options.provider, + useCase: options.useCase, + ...(options.subUseCase ? { subUseCase: options.subUseCase } : {}), log: [start], }; } @@ -230,6 +240,8 @@ export class FSRunsRepo implements IRunsRepo { agentId: start.agentName, model: start.model, provider: start.provider, + ...(start.useCase ? { useCase: start.useCase } : {}), + ...(start.subUseCase ? { subUseCase: start.subUseCase } : {}), log: events, }; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 5b8395a9..8785e05f 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -23,8 +23,15 @@ export async function createRun(opts: z.infer): Promise const defaults = await getDefaultModelAndProvider(); const model = opts.model ?? agent.model ?? defaults.model; const provider = opts.provider ?? agent.provider ?? defaults.provider; + const useCase = opts.useCase ?? "copilot_chat"; - const run = await repo.create({ agentId: opts.agentId, model, provider }); + const run = await repo.create({ + agentId: opts.agentId, + model, + provider, + useCase, + ...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}), + }); await bus.publish(run.log[0]); return run; } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index cc98f4f1..575f8395 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -25,6 +25,13 @@ const ipcSchemas = { electron: z.string(), }), }, + 'analytics:bootstrap': { + req: z.null(), + res: z.object({ + installationId: z.string(), + apiUrl: z.string(), + }), + }, 'workspace:getRoot': { req: z.null(), res: z.object({ diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 2c5bcc7a..ea93c8a3 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -21,6 +21,15 @@ export const StartEvent = BaseRunEvent.extend({ agentName: z.string(), model: z.string(), provider: z.string(), + // useCase/subUseCase tag the run for analytics. Optional on read so legacy + // run files written before these fields existed still parse cleanly. + useCase: z.enum([ + "copilot_chat", + "track_block", + "meeting_note", + "knowledge_sync", + ]).optional(), + subUseCase: z.string().optional(), }); export const SpawnSubFlowEvent = BaseRunEvent.extend({ @@ -118,6 +127,13 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({ response: true, }); +export const UseCase = z.enum([ + "copilot_chat", + "track_block", + "meeting_note", + "knowledge_sync", +]); + export const Run = z.object({ id: z.string(), title: z.string().optional(), @@ -125,6 +141,8 @@ export const Run = z.object({ agentId: z.string(), model: z.string(), provider: z.string(), + useCase: UseCase.optional(), + subUseCase: z.string().optional(), log: z.array(RunEvent), }); @@ -142,4 +160,6 @@ export const CreateRunOptions = z.object({ agentId: z.string(), model: z.string().optional(), provider: z.string().optional(), + useCase: UseCase.optional(), + subUseCase: z.string().optional(), }); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index ac219371..efe77d10 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: pdf-parse: specifier: ^2.4.5 version: 2.4.5 + posthog-node: + specifier: ^4.18.0 + version: 4.18.0 react: specifier: ^19.2.3 version: 19.2.3 @@ -6471,6 +6474,10 @@ packages: posthog-js@1.332.0: resolution: {integrity: sha512-w3+sL+IFK4mpfFmgTW7On8cR+z34pre+SOewx+eHZQSYF9RYqXsLIhrxagWbQKkowPd4tCwUHrkS1+VHsjnPqA==} + posthog-node@4.18.0: + resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==} + engines: {node: '>=15.0.0'} + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -15203,6 +15210,12 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 4.2.4 + posthog-node@4.18.0: + dependencies: + axios: 1.13.2 + transitivePeerDependencies: + - debug + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 From 4ca03daa4cf51d4ec06f9073e515baaf0c2356dc Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Tue, 28 Apr 2026 20:10:13 +0530 Subject: [PATCH 12/76] feat: group consecutive tool calls into collapsible summary Consecutive plain tool calls are now grouped into a single collapsible row instead of rendering as individual items. - Header shows the currently-executing tool name live with a vertical ticker animation, then switches to "Ran N tools" on completion - Expanding the group reveals each tool call individually collapsible - Tool calls with pending permission requests render individually - Special cards (web search, composio connect, app actions) excluded --- apps/x/apps/renderer/src/App.tsx | 19 +++- .../src/components/ai-elements/tool.tsx | 89 +++++++++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 19 +++- .../renderer/src/lib/chat-conversation.ts | 66 ++++++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 67f3f06a..0e925e2e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -35,7 +35,7 @@ import { import { Shimmer } from '@/components/ai-elements/shimmer'; import { useSmoothedText } from './hooks/useSmoothedText'; -import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; +import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; @@ -76,10 +76,12 @@ import { getAppActionCardData, getComposioConnectCardData, getToolDisplayName, + groupConversationItems, inferRunTitleFromMessage, isChatMessage, isErrorMessage, isToolCall, + isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -4578,7 +4580,20 @@ function App() { ) : ( <> - {tabState.conversation.map(item => { + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) + ).map(item => { + if (isToolGroup(item)) { + return ( + isToolOpenForTab(tab.id, toolId)} + onToolOpenChange={(toolId, open) => setToolOpenForTab(tab.id, toolId, open)} + /> + ) + } const rendered = renderConversationItem(item, tab.id) if (isToolCall(item)) { const permRequest = tabState.allPermissionRequests.get(item.id) diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 66feb1c6..5f65fa32 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -17,6 +17,9 @@ import { XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation"; +import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; @@ -224,3 +227,89 @@ export const ToolTabbedContent = ({ ); }; + +export type ToolGroupProps = { + group: ToolGroupType + isToolOpen: (toolId: string) => boolean + onToolOpenChange: (toolId: string, open: boolean) => void +} + +const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => { + if (tools.some(t => t.status === 'error')) return 'output-error' + if (tools.some(t => t.status === 'running')) return 'input-available' + if (tools.some(t => t.status === 'pending')) return 'input-streaming' + return 'output-available' +} + +export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => { + const [open, setOpen] = useState(false) + const state = getGroupState(group.items) + const isCompleted = state === 'output-available' || state === 'output-error' + const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending') + const currentTool = runningTool ?? group.items[group.items.length - 1] + const summary = isCompleted + ? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}` + : currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items) + + return ( + + +
+ +
+ + + {summary} + + +
+
+
+ {getStatusBadge(state)} + +
+
+ +
+ {group.items.map((tool) => { + const toolState = toToolState(tool.status) + const isOpen = isToolOpen(tool.id) + return ( + onToolOpenChange(tool.id, o)} + className="mb-0 border-border/60" + > + + + + + + ) + })} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 0a407d5d..07f1b637 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -16,7 +16,7 @@ import { MessageResponse, } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' -import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' +import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' @@ -40,9 +40,11 @@ import { getWebSearchCardData, getComposioConnectCardData, getToolDisplayName, + groupConversationItems, isChatMessage, isErrorMessage, isToolCall, + isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -591,7 +593,20 @@ export function ChatSidebar({ ) : ( <> - {tabState.conversation.map((item) => { + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) + ).map((item) => { + if (isToolGroup(item)) { + return ( + isToolOpenForTab?.(tab.id, toolId) ?? false} + onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)} + /> + ) + } const rendered = renderConversationItem(item, tab.id) if (isToolCall(item) && onPermissionResponse) { const permRequest = tabState.allPermissionRequests.get(item.id) diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 693961c9..150edacb 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -586,6 +586,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat return null } +export type ToolGroup = { + type: 'tool-group' + items: ToolCall[] + groupId: string +} + +export type GroupedConversationItem = ConversationItem | ToolGroup + +export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup => + 'type' in item && (item as ToolGroup).type === 'tool-group' + +const isPlainToolCall = (item: ConversationItem): item is ToolCall => { + if (!isToolCall(item)) return false + if (getWebSearchCardData(item)) return false + if (getComposioConnectCardData(item)) return false + if (getAppActionCardData(item)) return false + return true +} + +export const groupConversationItems = ( + items: ConversationItem[], + hasPermissionRequest: (id: string) => boolean +): GroupedConversationItem[] => { + const result: GroupedConversationItem[] = [] + let i = 0 + + while (i < items.length) { + const item = items[i] + if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) { + const group: ToolCall[] = [item] + i++ + while ( + i < items.length && + isPlainToolCall(items[i] as ConversationItem) && + !hasPermissionRequest((items[i] as ToolCall).id) + ) { + group.push(items[i] as ToolCall) + i++ + } + if (group.length === 1) { + result.push(group[0]) + } else { + result.push({ type: 'tool-group', items: group, groupId: group[0].id }) + } + } else { + result.push(item) + i++ + } + } + + return result +} + +export const getToolGroupSummary = (tools: ToolCall[]): string => { + const seen = new Set() + const names: string[] = [] + for (const tool of tools) { + const name = getToolDisplayName(tool) + if (!seen.has(name)) { + seen.add(name) + names.push(name) + } + } + return names.join(' · ') +} + export const inferRunTitleFromMessage = (content: string): string | undefined => { const { message } = parseAttachedFiles(content) const normalized = message.replace(/\s+/g, ' ').trim() From de176ec4583ff69e942a6eb22a37be44475b8637 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:21:37 +0530 Subject: [PATCH 13/76] identify signed-in users on every app startup Previously identify() only fired during the OAuth completion flow, so existing installs (signed in before analytics shipped) and every cold start of v0.3.4+ would emit main-process events under the anonymous installation_id until the user happened to re-sign-in. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/ANALYTICS.md | 1 + apps/x/apps/main/src/main.ts | 8 +++++++ .../x/packages/core/src/analytics/identify.ts | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 apps/x/packages/core/src/analytics/identify.ts diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 04659952..f0372dd5 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -10,6 +10,7 @@ - Main does it from `apps/main/src/oauth-handler.ts:285` (after `getBillingInfo()` resolves) — this is the load-bearing call, since main always runs. - Renderer mirrors via `apps/renderer/src/hooks/useAnalyticsIdentity.ts` listening on the `oauth:didConnect` IPC event. - Main also calls `alias()` so events emitted under the anonymous installation_id are linked to the identified user retroactively. +- **On every app startup**: main re-identifies if rowboat tokens exist (`packages/core/src/analytics/identify.ts`, called from `apps/main/src/main.ts` whenReady). Idempotent — PostHog merges person properties on duplicate identifies. This catches users who installed before analytics existed, and refreshes person properties (plan/status) on every launch. - **On rowboat sign-out**: `posthog.reset()` in both processes; future events resolve to the installation_id again. - **`email`** is set on `identify` from main only (sourced from `/v1/me`). Person properties are server-side, so the renderer's events resolve to the same record without redundantly setting it. diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f04a0ecc..99c77589 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -27,6 +27,7 @@ import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/schedul import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; +import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -231,6 +232,13 @@ app.whenReady().then(async () => { // Initialize all config files before UI can access them await initConfigs(); + // PostHog identify() is idempotent — call it on every startup so existing + // signed-in installs (and every cold start of v0.3.4+) get re-identified. + // Otherwise main-process events stay anonymous until the user re-signs-in. + identifyIfSignedIn().catch((error) => { + console.error('[Analytics] Failed to identify on startup:', error); + }); + registerBrowserControlService(new ElectronBrowserControlService()); setupIpcHandlers(); diff --git a/apps/x/packages/core/src/analytics/identify.ts b/apps/x/packages/core/src/analytics/identify.ts new file mode 100644 index 00000000..3d647711 --- /dev/null +++ b/apps/x/packages/core/src/analytics/identify.ts @@ -0,0 +1,23 @@ +import { isSignedIn } from '../account/account.js'; +import { getBillingInfo } from '../billing/billing.js'; +import { identify } from './posthog.js'; + +/** + * If the user has rowboat OAuth tokens, fetch their billing info and + * call posthog.identify(). Idempotent — safe to call on every app start. + * Catches all errors so analytics never blocks app launch. + */ +export async function identifyIfSignedIn(): Promise { + try { + if (!(await isSignedIn())) return; + const billing = await getBillingInfo(); + if (!billing.userId) return; + identify(billing.userId, { + ...(billing.userEmail ? { email: billing.userEmail } : {}), + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + } catch (err) { + console.error('[Analytics] startup identify failed:', err); + } +} From 1c2b2ac1fc8d00fc7d4f09077a96b9e539e1b3a0 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Mon, 4 May 2026 15:47:30 +0530 Subject: [PATCH 14/76] feat: native desktop notifications + rowboat:// deep links Adds INotificationService with an Electron implementation, plus a deep-link dispatcher (rowboat://) for routing notification clicks back into the app. Notifications: - New `notify-user` skill + builtin tool. Title, message, optional primary link, optional secondary actions. Supports https:// (opens in browser) and rowboat:// (opens in app) targets. - ElectronNotificationService holds strong refs to active Notification instances so click handlers survive GC (otherwise macOS click silently no-ops). - Calendar meeting notifier fires 1-min warnings with "take notes" / "join + take notes" actions backed by deep links. Deep links (rowboat://): - forge.config.cjs declares the protocol; main.ts wires single-instance lock, setAsDefaultProtocolClient, open-url (mac), second-instance (win/ linux), and first-launch argv extraction. - New deeplink.ts dispatcher with dispatchUrl(url): main-handled actions (rowboat://action?type=...) vs renderer navigation (rowboat://open?...) via app:openUrl IPC. Includes pending-URL buffering for first-launch delivery before the renderer is ready. - Renderer parseDeepLink supports file / chat / graph / task / suggested-topics targets. - New app:consumePendingDeepLink IPC for renderer one-time drain on mount. Refactor: extractConferenceLink moved out of calendar-block.tsx into shared lib/calendar-event.ts (used by both the block and the take-notes deep-link handler) --- apps/x/apps/main/forge.config.cjs | 3 + apps/x/apps/main/src/deeplink.ts | 118 ++++++++++++ apps/x/apps/main/src/ipc.ts | 4 + apps/x/apps/main/src/main.ts | 54 +++++- .../electron-notification-service.ts | 84 ++++++++ apps/x/apps/renderer/src/App.tsx | 92 +++++++++ .../src/extensions/calendar-block.tsx | 20 +- .../x/apps/renderer/src/lib/calendar-event.ts | 15 ++ .../src/application/assistant/instructions.ts | 2 + .../src/application/assistant/skills/index.ts | 7 + .../assistant/skills/notify-user/skill.ts | 70 +++++++ .../core/src/application/lib/builtin-tools.ts | 41 ++++ .../src/application/notification/service.ts | 12 ++ apps/x/packages/core/src/di/container.ts | 7 + .../src/knowledge/notify_calendar_meetings.ts | 180 ++++++++++++++++++ .../core/src/knowledge/track/run-agent.ts | 1 + apps/x/packages/shared/src/ipc.ts | 22 +++ 17 files changed, 712 insertions(+), 20 deletions(-) create mode 100644 apps/x/apps/main/src/deeplink.ts create mode 100644 apps/x/apps/main/src/notification/electron-notification-service.ts create mode 100644 apps/x/apps/renderer/src/lib/calendar-event.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts create mode 100644 apps/x/packages/core/src/application/notification/service.ts create mode 100644 apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 178cb7e1..ad639a86 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,6 +11,9 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + protocols: [ + { name: 'Rowboat', schemes: ['rowboat'] }, + ], extendInfo: { NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', }, diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts new file mode 100644 index 00000000..605990d1 --- /dev/null +++ b/apps/x/apps/main/src/deeplink.ts @@ -0,0 +1,118 @@ +import { BrowserWindow } from "electron"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +export const DEEP_LINK_SCHEME = "rowboat"; +const URL_PREFIX = `${DEEP_LINK_SCHEME}://`; +const ACTION_HOST = "action"; + +let pendingUrl: string | null = null; +let mainWindowRef: BrowserWindow | null = null; + +export function setMainWindowForDeepLinks(win: BrowserWindow | null): void { + mainWindowRef = win; +} + +export function consumePendingDeepLink(): string | null { + const url = pendingUrl; + pendingUrl = null; + return url; +} + +export function extractDeepLinkFromArgv(argv: readonly string[]): string | null { + for (const arg of argv) { + if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg; + } + return null; +} + +/** + * Dispatch any rowboat:// URL — chooses navigation vs action automatically. + * Use this from notification click handlers and other URL entry points. + */ +export function dispatchUrl(url: string): void { + if (parseAction(url)) { + void dispatchAction(url); + } else { + dispatchDeepLink(url); + } +} + +export function dispatchDeepLink(url: string): void { + if (!url.startsWith(URL_PREFIX)) return; + + pendingUrl = url; + + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + if (win.webContents.isLoading()) return; + + win.webContents.send("app:openUrl", { url }); + pendingUrl = null; +} + +interface MeetingNotesAction { + type: "take-meeting-notes" | "join-and-take-meeting-notes"; + eventId: string; +} + +type ParsedAction = MeetingNotesAction; + +function parseAction(url: string): ParsedAction | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, ""); + if (host !== ACTION_HOST) return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const type = params.get("type"); + if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { + const eventId = params.get("eventId"); + return eventId ? { type, eventId } : null; + } + return null; +} + +async function dispatchAction(url: string): Promise { + const parsed = parseAction(url); + if (!parsed) return; + + const openMeeting = parsed.type === "join-and-take-meeting-notes"; + await handleTakeMeetingNotes(parsed.eventId, openMeeting); +} + +async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise { + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); + let event: unknown; + try { + const raw = await fs.readFile(filePath, "utf-8"); + event = JSON.parse(raw); + } catch (err) { + console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); + return; + } + + const payload = { event, openMeeting }; + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("app:takeMeetingNotes", payload); + }); + return; + } + + win.webContents.send("app:takeMeetingNotes", payload); +} + +function focusWindow(win: BrowserWindow): void { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5e62e8ee..d70192cc 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -34,6 +34,7 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { consumePendingDeepLink } from './deeplink.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; @@ -417,6 +418,9 @@ export function setupIpcHandlers() { // args is null for this channel (no request payload) return getVersions(); }, + 'app:consumePendingDeepLink': async () => { + return { url: consumePendingDeepLink() }; + }, 'analytics:bootstrap': async () => { return { installationId: getInstallationId(), diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 99c77589..cd0717a4 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -23,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; +import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; @@ -34,10 +35,17 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService } from "@x/core/dist/di/container.js"; +import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; +import { ElectronNotificationService } from "./notification/electron-notification-service.js"; +import { + DEEP_LINK_SCHEME, + dispatchDeepLink, + extractDeepLinkFromArgv, + setMainWindowForDeepLinks, +} from "./deeplink.js"; const execAsync = promisify(exec); @@ -47,6 +55,43 @@ const __dirname = dirname(__filename); // run this as early in the main process as possible if (started) app.quit(); +// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link) +// back into the existing process via the 'second-instance' event. +if (!app.requestSingleInstanceLock()) { + app.quit(); + process.exit(0); +} + +// Register as the OS handler for rowboat:// URLs. +// In dev, point at the right argv so the OS can re-invoke us correctly. +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [ + path.resolve(process.argv[1]), + ]); + } +} else { + app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME); +} + +// First-launch URL on Windows/Linux comes through argv. +{ + const initialUrl = extractDeepLinkFromArgv(process.argv); + if (initialUrl) dispatchDeepLink(initialUrl); +} + +// macOS sends URLs via 'open-url' (both first launch and while running). +app.on("open-url", (event, url) => { + event.preventDefault(); + dispatchDeepLink(url); +}); + +// Subsequent launches on Windows/Linux land here via the single-instance lock. +app.on("second-instance", (_event, argv) => { + const url = extractDeepLinkFromArgv(argv); + if (url) dispatchDeepLink(url); +}); + // Fix PATH for packaged Electron apps on macOS/Linux. // Packaged apps inherit a minimal environment that doesn't include paths from // the user's shell profile (such as those provided by nvm, Homebrew, etc.). @@ -165,6 +210,9 @@ function createWindow() { configureSessionPermissions(session.defaultSession); configureSessionPermissions(session.fromPartition(BROWSER_PARTITION)); + setMainWindowForDeepLinks(win); + win.on("closed", () => setMainWindowForDeepLinks(null)); + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { win.maximize(); @@ -240,6 +288,7 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); + registerNotificationService(new ElectronNotificationService()); setupIpcHandlers(); setupBrowserEventForwarding(); @@ -298,6 +347,9 @@ app.whenReady().then(async () => { // start agent notes learning service initAgentNotes(); + // start calendar meeting notification service (fires 1-minute warnings) + initCalendarNotifications(); + // start chrome extension sync server initChromeSync(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts new file mode 100644 index 00000000..dd37e37d --- /dev/null +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -0,0 +1,84 @@ +import { BrowserWindow, Notification, shell } from "electron"; +import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { dispatchUrl } from "../deeplink.js"; + +const HTTP_URL = /^https?:\/\//i; +const ROWBOAT_URL = /^rowboat:\/\//i; + +export class ElectronNotificationService implements INotificationService { + // Holds strong references to active Notification instances so the GC can't + // collect them while they're still visible — without this, the click handler + // gets dropped and macOS clicks just focus the app silently. + private active = new Set(); + + isSupported(): boolean { + return Notification.isSupported(); + } + + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + // Build the actions array AND a parallel index → link map. + // macOS shows actions[0] inline (Banner) or all of them (Alert); + // additional ones live behind the chevron menu. + const actionDefs: Electron.NotificationConstructorOptions["actions"] = []; + const actionLinks: string[] = []; + + const primaryLabel = actionLabel?.trim(); + if (link && primaryLabel) { + actionDefs!.push({ type: "button", text: primaryLabel }); + actionLinks.push(link); + } + if (secondaryActions) { + for (const sa of secondaryActions) { + actionDefs!.push({ type: "button", text: sa.label }); + actionLinks.push(sa.link); + } + } + + const notification = new Notification({ + title, + body: message, + actions: actionDefs, + }); + + this.active.add(notification); + const release = () => { this.active.delete(notification); }; + + const openLink = (target: string | undefined) => { + if (target && ROWBOAT_URL.test(target)) { + dispatchUrl(target); + } else if (target && HTTP_URL.test(target)) { + shell.openExternal(target).catch((err) => { + console.error("[notification] failed to open link:", err); + }); + } else { + this.focusMainWindow(); + } + release(); + }; + + // Body click: always opens the primary `link` (or focuses the app if none). + notification.on("click", () => openLink(link)); + + // Action button click: dispatch by index into the actions array. + notification.on("action", (_event, index) => { + if (index >= 0 && index < actionLinks.length) { + openLink(actionLinks[index]); + } else { + openLink(undefined); + } + }); + + notification.on("close", release); + notification.on("failed", release); + + notification.show(); + } + + private focusMainWindow(): void { + const [win] = BrowserWindow.getAllWindows(); + if (!win) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + } +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0e925e2e..7c749664 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -54,6 +54,7 @@ import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' +import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { TrackModal } from '@/components/track-modal' @@ -515,6 +516,45 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } +/** + * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is + * malformed or names an unknown target. + * + * 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 + */ +function parseDeepLink(input: string): ViewState | null { + const SCHEME = 'rowboat://' + if (!input.startsWith(SCHEME)) return null + const rest = input.slice(SCHEME.length) + const queryIdx = rest.indexOf('?') + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '') + if (host !== 'open') return null + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '') + switch (params.get('type')) { + case 'file': { + const path = params.get('path') + return path ? { type: 'file', path } : null + } + case 'chat': + return { type: 'chat', runId: params.get('runId') || null } + case 'graph': + return { type: 'graph' } + case 'task': { + const name = params.get('name') + return name ? { type: 'task', name } : null + } + case 'suggested-topics': + return { type: 'suggested-topics' } + default: + return null + } +} + /** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ leftInsetPx, @@ -3050,6 +3090,58 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + // Deep-link handler kept in a ref so the useEffect below can register the + // IPC listener (and run the one-time pending-link drain) just once on mount, + // rather than re-running on every navigation when navigateToView's identity + // changes. + const navigateToViewRef = useRef(navigateToView) + useEffect(() => { navigateToViewRef.current = navigateToView }, [navigateToView]) + + useEffect(() => { + const handle = (url: string) => { + const view = parseDeepLink(url) + if (view) void navigateToViewRef.current(view) + } + void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => { + if (url) handle(url) + }) + return window.ipc.on('app:openUrl', ({ url }) => handle(url)) + }, []) + + // Triggered by main when the user clicks a calendar-meeting notification. + // Reuses the same flow as the in-app "Join meeting & take notes" button. + // When `openMeeting` is true, also opens the meeting URL in the system browser. + useEffect(() => { + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { + const e = event as { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + if (!e || typeof e !== 'object') return + const conferenceLink = extractConferenceLink(e as Record) + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) + } + window.__pendingCalendarEvent = { + summary: e.summary, + start: e.start, + end: e.end, + location: e.location, + htmlLink: e.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) + }) + }, []) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) diff --git a/apps/x/apps/renderer/src/extensions/calendar-block.tsx b/apps/x/apps/renderer/src/extensions/calendar-block.tsx index 9f0eec02..ecc5403d 100644 --- a/apps/x/apps/renderer/src/extensions/calendar-block.tsx +++ b/apps/x/apps/renderer/src/extensions/calendar-block.tsx @@ -3,6 +3,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react' import { blocks } from '@x/shared' import { useState, useEffect, useRef } from 'react' +import { extractConferenceLink } from '../lib/calendar-event' function formatTime(dateStr: string): string { const d = new Date(dateStr) @@ -40,25 +41,6 @@ function getTimeRange(event: blocks.CalendarEvent): string { return `${startTime} \u2013 ${endTime}` } -/** - * Extract a video conference link from raw Google Calendar event JSON. - * Checks conferenceData.entryPoints (video type), hangoutLink, then falls back - * to conferenceLink if already set. - */ -function extractConferenceLink(raw: Record): string | undefined { - // Check conferenceData.entryPoints for video entry - const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined - if (confData?.entryPoints) { - const video = confData.entryPoints.find(ep => ep.entryPointType === 'video') - if (video?.uri) return video.uri - } - // Check hangoutLink (Google Meet shortcut) - if (typeof raw.hangoutLink === 'string') return raw.hangoutLink - // Fall back to conferenceLink if present - if (typeof raw.conferenceLink === 'string') return raw.conferenceLink - return undefined -} - interface ResolvedEvent { event: blocks.CalendarEvent loaded: blocks.CalendarEvent | null diff --git a/apps/x/apps/renderer/src/lib/calendar-event.ts b/apps/x/apps/renderer/src/lib/calendar-event.ts new file mode 100644 index 00000000..b7ace75a --- /dev/null +++ b/apps/x/apps/renderer/src/lib/calendar-event.ts @@ -0,0 +1,15 @@ +/** + * Extract a video conference link from raw Google Calendar event JSON. + * Checks conferenceData.entryPoints (video type), hangoutLink, then falls back + * to a top-level conferenceLink if present. + */ +export function extractConferenceLink(raw: Record): string | undefined { + const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined + if (confData?.entryPoints) { + const video = confData.entryPoints.find(ep => ep.entryPointType === 'video') + if (video?.uri) return video.uri + } + if (typeof raw.hangoutLink === 'string') return raw.hangoutLink + if (typeof raw.conferenceLink === 'string') return raw.conferenceLink + return undefined +} diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index af2d7a20..a455d845 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -85,6 +85,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards. **Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane. +**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it. + ## Learning About the User (save-to-memory) 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 cad23177..6d3cdc5b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -14,6 +14,7 @@ import appNavigationSkill from "./app-navigation/skill.js"; import browserControlSkill from "./browser-control/skill.js"; import composioIntegrationSkill from "./composio-integration/skill.js"; import tracksSkill from "./tracks/skill.js"; +import notifyUserSkill from "./notify-user/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -112,6 +113,12 @@ const definitions: SkillDefinition[] = [ summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.", content: browserControlSkill, }, + { + id: "notify-user", + title: "Notify User", + summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.", + content: notifyUserSkill, + }, ]; const skillEntries = definitions.map((definition) => ({ diff --git a/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts new file mode 100644 index 00000000..9bc619be --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts @@ -0,0 +1,70 @@ +export const skill = String.raw` +# Notify User + +Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result. + +## When to use +- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive. +- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit — restraint is on you). + +## The tool: \`notify-user\` + +Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click. + +### Parameters +- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top. +- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines. +- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted: + - **\`https://...\` / \`http://...\`** — opens in the default browser + - **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below) + - If omitted, clicking the notification focuses the Rowboat app. + +### Examples + +Plain alert (no link — clicking focuses the app): +\`\`\`json +{ + "title": "Backup complete", + "message": "All 142 files synced to iCloud." +} +\`\`\` + +External link: +\`\`\`json +{ + "title": "New email from Monica", + "message": "Re: Q4 planning — needs your input by Friday", + "link": "https://mail.google.com/mail/u/0/#inbox/abc123" +} +\`\`\` + +Deep link into a Rowboat note: +\`\`\`json +{ + "message": "Daily brief is ready", + "link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md" +} +\`\`\` + +## Deep links: \`rowboat://\` + +Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters. + +| Target | Format | Example | +|---|---|---| +| Open a file | \`rowboat://open?type=file&path=\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` | +| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=\`) | \`rowboat://open?type=chat&runId=abc123\` | +| Knowledge graph | \`rowboat://open?type=graph\` | — | +| Background task view | \`rowboat://open?type=task&name=\` | \`rowboat://open?type=task&name=daily-brief\` | +| Suggested topics | \`rowboat://open?type=suggested-topics\` | — | + +The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`). + +## Anti-patterns +- **Don't notify per step** of a multi-step task. Notify on completion, not on progress. +- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification. +- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link. +- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done". +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 4fd347b6..65b398a1 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -29,6 +29,7 @@ import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; import type { IBrowserControlService } from "../browser-control/service.js"; +import type { INotificationService } from "../notification/service.js"; // Parser libraries are loaded dynamically inside parseFile.execute() // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // Import paths are computed so esbuild cannot statically resolve them. @@ -1526,4 +1527,44 @@ export const BuiltinTools: z.infer = { } }, }, + + 'notify-user': { + description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.", + inputSchema: z.object({ + title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."), + message: z.string().min(1).describe("Body text of the notification."), + link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { + message: "link must be an http(s):// or rowboat:// URL", + }).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."), + actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."), + secondaryActions: z.array(z.object({ + label: z.string().min(1).max(30), + link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { + message: "secondary action link must be an http(s):// or rowboat:// URL", + }), + })).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."), + }), + isAvailable: async () => { + try { + return container.resolve('notificationService').isSupported(); + } catch { + return false; + } + }, + execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => { + try { + const service = container.resolve('notificationService'); + if (!service.isSupported()) { + return { success: false, error: 'Notifications are not supported on this system' }; + } + service.notify({ title, message, link, actionLabel, secondaryActions }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, }; diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts new file mode 100644 index 00000000..195315b1 --- /dev/null +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -0,0 +1,12 @@ +export interface NotifyInput { + title?: string; + message: string; + link?: string; + actionLabel?: string; + secondaryActions?: Array<{ label: string; link: string }>; +} + +export interface INotificationService { + isSupported(): boolean; + notify(input: NotifyInput): void; +} diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 93ba9ebd..9382de8b 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -16,6 +16,7 @@ import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo. import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; import type { IBrowserControlService } from "../application/browser-control/service.js"; +import type { INotificationService } from "../application/notification/service.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService): browserControlService: asValue(service), }); } + +export function registerNotificationService(service: INotificationService): void { + container.register({ + notificationService: asValue(service), + }); +} diff --git a/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts new file mode 100644 index 00000000..cca9d230 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts @@ -0,0 +1,180 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import { WorkDir } from "../config/config.js"; +import container from "../di/container.js"; +import type { INotificationService } from "../application/notification/service.js"; + +const TICK_INTERVAL_MS = 30_000; +// Notify when an event is between 30s in the past (started just now) and +// 90s in the future (about to start). The window is wider than 60s so we +// don't miss an event if the tick lands slightly off the start time. +const NOTIFY_LEAD_MS = 90_000; +const NOTIFY_GRACE_MS = 30_000; +// Drop state entries older than 24h so the file doesn't grow forever. +const STATE_TTL_MS = 24 * 60 * 60 * 1000; + +const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync"); +const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json"); + +interface NotificationState { + notifiedEventIds: Record; +} + +interface CalendarEvent { + id?: string; + summary?: string; + status?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string }; + attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>; + hangoutLink?: string; + conferenceData?: { + entryPoints?: Array<{ entryPointType?: string; uri?: string }>; + }; +} + +async function loadState(): Promise { + try { + const raw = await fs.readFile(STATE_FILE, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) { + return parsed as NotificationState; + } + } catch { + // No state file yet, or corrupt — start fresh. + } + return { notifiedEventIds: {} }; +} + +async function saveState(state: NotificationState): Promise { + // Write to a sibling tmp file then rename so a mid-write crash can't leave + // the JSON corrupt — a corrupt state file would make every event in the + // 90s notify window re-fire on next start. + const tmp = `${STATE_FILE}.tmp`; + await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8"); + await fs.rename(tmp, STATE_FILE); +} + +function gcState(state: NotificationState): NotificationState { + const cutoff = Date.now() - STATE_TTL_MS; + const fresh: NotificationState["notifiedEventIds"] = {}; + for (const [id, entry] of Object.entries(state.notifiedEventIds)) { + const ts = Date.parse(entry.notifiedAt); + if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry; + } + return { notifiedEventIds: fresh }; +} + +function isAllDay(event: CalendarEvent): boolean { + // Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`. + return !event.start?.dateTime; +} + +function isDeclinedBySelf(event: CalendarEvent): boolean { + if (!event.attendees) return false; + const self = event.attendees.find((a) => a.self); + return self?.responseStatus === "declined"; +} + +async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> { + let entries: Dirent[]; + try { + entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true }); + } catch { + return { state, dirty: false }; + } + + let service: INotificationService; + try { + service = container.resolve("notificationService"); + } catch { + // Notification service not registered yet (very early startup) — skip this tick. + return { state, dirty: false }; + } + if (!service.isSupported()) return { state, dirty: false }; + + const now = Date.now(); + let dirty = false; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue; + + const eventId = entry.name.replace(/\.json$/, ""); + if (state.notifiedEventIds[eventId]) continue; + + const filePath = path.join(CALENDAR_SYNC_DIR, entry.name); + let event: CalendarEvent; + try { + event = JSON.parse(await fs.readFile(filePath, "utf-8")); + } catch { + continue; + } + + if (event.status === "cancelled") continue; + if (isAllDay(event)) continue; + if (isDeclinedBySelf(event)) continue; + + const startStr = event.start?.dateTime; + if (!startStr) continue; + const startMs = Date.parse(startStr); + if (!Number.isFinite(startMs)) continue; + + const msUntilStart = startMs - now; + if (msUntilStart > NOTIFY_LEAD_MS) continue; + if (msUntilStart < -NOTIFY_GRACE_MS) continue; + + const summary = event.summary?.trim() || "Untitled meeting"; + const eid = encodeURIComponent(eventId); + + try { + service.notify({ + title: "Upcoming meeting", + message: `${summary} starts in 1 minute. Click to join and take notes.`, + // Single labeled button — adding a secondary action would force + // macOS to bundle them into an "Options" dropdown, hiding the + // primary label. + link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`, + actionLabel: "Join & Notes", + }); + console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`); + } catch (err) { + console.error(`[CalendarNotify] notify failed for ${eventId}:`, err); + continue; + } + + state.notifiedEventIds[eventId] = { + notifiedAt: new Date().toISOString(), + startTime: startStr, + }; + dirty = true; + } + + return { state, dirty }; +} + +export async function init(): Promise { + console.log("[CalendarNotify] starting calendar notification service"); + console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`); + + let state = gcState(await loadState()); + + while (true) { + try { + const result = await tick(state); + state = result.state; + if (result.dirty) { + state = gcState(state); + try { + await saveState(state); + } catch (err) { + console.error("[CalendarNotify] failed to save state:", err); + } + } + } catch (err) { + console.error("[CalendarNotify] tick failed:", err); + } + await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS)); + } +} diff --git a/apps/x/packages/core/src/knowledge/track/run-agent.ts b/apps/x/packages/core/src/knowledge/track/run-agent.ts index d93366f3..685305b2 100644 --- a/apps/x/packages/core/src/knowledge/track/run-agent.ts +++ b/apps/x/packages/core/src/knowledge/track/run-agent.ts @@ -263,6 +263,7 @@ You have the full workspace toolkit. Quick reference for common cases: - **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files. - **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized. - **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering. +- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view). # The Knowledge Graph diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 575f8395..ab7d7f73 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -299,6 +299,28 @@ const ipcSchemas = { }), res: z.null(), }, + 'app:openUrl': { + req: z.object({ + url: z.string(), + }), + res: z.null(), + }, + 'app:takeMeetingNotes': { + req: z.object({ + // Pass the raw calendar event JSON through; renderer adapts to its existing flow. + event: z.unknown(), + // When true, the renderer should also open the meeting URL (Zoom/Meet/etc.) + // in addition to triggering the take-notes flow. + openMeeting: z.boolean().optional(), + }), + res: z.null(), + }, + 'app:consumePendingDeepLink': { + req: z.null(), + res: z.object({ + url: z.string().nullable(), + }), + }, 'granola:getConfig': { req: z.null(), res: z.object({ From 93feee15a051a0839141d2850c55e6fa80da7e0d Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 4 May 2026 17:20:19 +0530 Subject: [PATCH 15/76] fixed collapsed sidebar issue on chat --- apps/x/apps/renderer/src/App.tsx | 1 + .../x/apps/renderer/src/components/chat-sidebar.tsx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7c749664..0321aaed 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4850,6 +4850,7 @@ function App() { onToolOpenChangeForTab={setToolOpenForTab} onOpenKnowledgeFile={(path) => { navigateToFile(path) }} onActivate={() => setActiveShortcutPane('right')} + collapsedLeftPaddingPx={collapsedLeftPaddingPx} isRecording={isRecording} recordingText={voice.interimText} recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 07f1b637..6fa295b1 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -30,6 +30,7 @@ import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab } from '@/components/tab-bar' import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' +import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' import { type ChatViewportAnchorState, @@ -177,6 +178,7 @@ interface ChatSidebarProps { onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void onOpenKnowledgeFile?: (path: string) => void onActivate?: () => void + collapsedLeftPaddingPx?: number // Voice / TTS props isRecording?: boolean recordingText?: string @@ -231,6 +233,7 @@ export function ChatSidebar({ onToolOpenChangeForTab, onOpenKnowledgeFile, onActivate, + collapsedLeftPaddingPx = 196, isRecording, recordingText, recordingState, @@ -245,6 +248,7 @@ export function ChatSidebar({ onTtsModeChange, onComposioConnected, }: ChatSidebarProps) { + const { state: sidebarState } = useSidebar() const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [isResizing, setIsResizing] = useState(false) const [showContent, setShowContent] = useState(isOpen) @@ -519,7 +523,14 @@ export function ChatSidebar({ {showContent && ( <> -
+
Date: Mon, 4 May 2026 17:28:04 +0530 Subject: [PATCH 16/76] fix browser reload issue --- apps/x/apps/main/src/browser/view.ts | 35 ++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index d319c5fb..b540809d 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -109,10 +109,31 @@ export class BrowserViewManager extends EventEmitter { private visible = false; private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; private snapshotCache = new Map(); + private cleanupWindowListeners: (() => void) | null = null; attach(window: BrowserWindow): void { + this.cleanupWindowListeners?.(); this.window = window; - window.on('closed', () => { + + const resetForHostWindowNavigation = () => { + // Renderer refreshes do not run React unmount cleanup reliably, so the + // native browser view must be detached from the main process side. + this.visible = false; + this.bounds = { x: 0, y: 0, width: 0, height: 0 }; + this.syncAttachedView(); + }; + + const handleDidStartLoading = () => { + resetForHostWindowNavigation(); + }; + + const handleRenderProcessGone = () => { + resetForHostWindowNavigation(); + }; + + const handleClosed = () => { + this.cleanupWindowListeners?.(); + this.cleanupWindowListeners = null; this.window = null; this.browserSession = null; this.tabs.clear(); @@ -121,7 +142,17 @@ export class BrowserViewManager extends EventEmitter { this.attachedTabId = null; this.visible = false; this.snapshotCache.clear(); - }); + }; + + window.webContents.on('did-start-loading', handleDidStartLoading); + window.webContents.on('render-process-gone', handleRenderProcessGone); + window.on('closed', handleClosed); + + this.cleanupWindowListeners = () => { + window.webContents.removeListener('did-start-loading', handleDidStartLoading); + window.webContents.removeListener('render-process-gone', handleRenderProcessGone); + window.removeListener('closed', handleClosed); + }; } private getSession(): Session { From a76f8bae14a4f7208d513144a19dd2ce539bfb1b Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 11:41:08 +0530 Subject: [PATCH 17/76] fix sticky browser issue --- apps/x/apps/renderer/src/App.tsx | 37 ++++++++++++++----- .../components/browser-pane/BrowserPane.tsx | 11 +++++- .../src/components/sidebar-content.tsx | 27 +++++++++++--- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0321aaed..d0ed5284 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2400,6 +2400,10 @@ function App() { } }, [runId]) + const dismissBrowserOverlay = useCallback(() => { + setIsBrowserOpen(false) + }, []) + const handleNewChat = useCallback(() => { // Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in) loadRunRequestIdRef.current += 1 @@ -2623,6 +2627,7 @@ function App() { // File tab operations const openFileInNewTab = useCallback((path: string) => { + dismissBrowserOverlay() const existingTab = fileTabs.find(t => t.path === path) if (existingTab) { setActiveFileTabId(existingTab.id) @@ -2635,11 +2640,12 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setSelectedPath(path) - }, [fileTabs]) + }, [fileTabs, dismissBrowserOverlay]) const switchFileTab = useCallback((tabId: string) => { const tab = fileTabs.find(t => t.id === tabId) if (!tab) return + dismissBrowserOverlay() setActiveFileTabId(tabId) setSelectedBackgroundTask(null) setExpandedFrom(null) @@ -2662,7 +2668,7 @@ function App() { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) setSelectedPath(tab.path) - }, [fileTabs, isRightPaneMaximized]) + }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) @@ -2734,8 +2740,9 @@ function App() { // Create a new tab const id = newChatTabId() setChatTabs(prev => [...prev, { id, runId: null }]) - setActiveChatTabId(id) + setActiveChatTabId(id) } + dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { @@ -2747,7 +2754,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2865,11 +2872,12 @@ function App() { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) } + dismissBrowserOverlay() setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { @@ -3004,8 +3012,7 @@ function App() { case 'chat': setSelectedPath(null) setIsGraphOpen(false) - // Don't touch isBrowserOpen here — chat navigation should land in - // the right sidebar when the browser overlay is active. + setIsBrowserOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) @@ -3021,7 +3028,12 @@ function App() { const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState - if (viewStatesEqual(current, nextView)) return + if (viewStatesEqual(current, nextView)) { + if (isBrowserOpen) { + dismissBrowserOverlay() + } + return + } cancelRecordingIfActive() const nextHistory = { @@ -3030,7 +3042,7 @@ function App() { } setHistory(nextHistory) await applyViewState(nextView) - }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory]) + }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -4329,6 +4341,8 @@ function App() { meetingSummarizing={meetingSummarizing} meetingAvailable={voiceAvailable} onToggleMeeting={() => { void handleToggleMeeting() }} + isSearchOpen={isSearchOpen} + isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} @@ -4463,7 +4477,10 @@ function App() { {isBrowserOpen ? ( - + ) : isSuggestedTopicsOpen ? (
void + forceHidden?: boolean } const getActiveTab = (state: BrowserState) => @@ -85,7 +86,7 @@ const getBrowserTabTitle = (tab: BrowserTabState) => { } } -export function BrowserPane({ onClose }: BrowserPaneProps) { +export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) { const [state, setState] = useState(EMPTY_STATE) const [addressValue, setAddressValue] = useState('') @@ -175,6 +176,12 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { }, []) const syncView = useCallback(() => { + if (forceHidden) { + lastBoundsRef.current = null + setViewVisible(false) + return null + } + const doc = viewportRef.current?.ownerDocument if (doc && hasBlockingOverlay(doc)) { lastBoundsRef.current = null @@ -191,7 +198,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { pushBounds(bounds) setViewVisible(true) return bounds - }, [measureBounds, pushBounds, setViewVisible]) + }, [forceHidden, measureBounds, pushBounds, setViewVisible]) useEffect(() => { syncView() diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 41d6b622..7e204781 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -186,6 +186,8 @@ type SidebarContentPanelProps = { meetingSummarizing?: boolean meetingAvailable?: boolean onToggleMeeting?: () => void + isSearchOpen?: boolean + isMeetingActionActive?: boolean isBrowserOpen?: boolean onToggleBrowser?: () => void isSuggestedTopicsOpen?: boolean @@ -420,6 +422,8 @@ export function SidebarContentPanel({ meetingSummarizing = false, meetingAvailable = false, onToggleMeeting, + isSearchOpen = false, + isMeetingActionActive = false, isBrowserOpen = false, onToggleBrowser, isSuggestedTopicsOpen = false, @@ -436,6 +440,9 @@ export function SidebarContentPanel({ const [loggingIn, setLoggingIn] = useState(false) const [appUrl, setAppUrl] = useState(null) const { billing } = useBilling(isRowboatConnected) + const isMeetingQuickActionSelected = isMeetingActionActive + const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected + const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen const handleRowboatLogin = useCallback(async () => { try { @@ -533,7 +540,12 @@ export function SidebarContentPanel({ + +
+ + + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 469ac35d..33c89231 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -96,14 +96,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscoverError, setSlackDiscoverError] = useState(null) - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed — flags are seeded false and + // never flipped. Kept here so legacy gating expressions still type-check. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) @@ -151,25 +151,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { setProvidersLoading(false) } } - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } + // (Composio Gmail/Calendar flag fetches removed — sync was deleted.) loadProviders() - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -622,12 +605,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Falls back to BYOK modal + // for not-signed-in users. (Mirrors useConnectors.handleConnect.) + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index edb3616b..b06ec862 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -66,16 +66,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Inline upsell callout dismissed const [upsellDismissed, setUpsellDismissed] = useState(false) - // Composio/Gmail state (used when signed in with Rowboat account) - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed — flags are seeded false and + // never flipped. Kept here so legacy gating expressions still type-check. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) @@ -123,25 +123,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setProvidersLoading(false) } } - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } + // (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.) loadProviders() - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -539,17 +522,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const cleanup = window.ipc.on('oauth:didConnect', async (event) => { if (event.provider === 'rowboat' && event.success) { - // Re-check composio flags now that the account is connected - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (error) { - console.error('Failed to re-check composio flags:', error) - } + // (Composio Gmail/Calendar flag re-check removed — sync was deleted.) setCurrentStep(2) // Go to Connect Accounts } }) @@ -609,12 +582,20 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Falls back to BYOK modal + // for not-signed-in users. (Mirrors useConnectors.handleConnect.) + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) diff --git a/apps/x/apps/renderer/src/hooks/useConnectors.ts b/apps/x/apps/renderer/src/hooks/useConnectors.ts index 7285fe04..af56b921 100644 --- a/apps/x/apps/renderer/src/hooks/useConnectors.ts +++ b/apps/x/apps/renderer/src/hooks/useConnectors.ts @@ -38,16 +38,21 @@ export function useConnectors(active: boolean) { const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscoverError, setSlackDiscoverError] = useState(null) - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed. These flags are seeded false + // and never flipped — the IPC that used to set them is gone. The setters + // remain so the legacy Composio-Gmail handlers below still type-check, + // but those handlers are no longer reachable in the UI (the gating + // condition `useComposioForGoogle` stays false). + // TODO follow-up: drop these flags entirely and prune the dead UI branches + // in connectors-popover, connected-accounts-settings, and onboarding-modal. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) + const [gmailLoading, setGmailLoading] = useState(false) const [gmailConnecting, setGmailConnecting] = useState(false) - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) - const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) // Load available providers on mount @@ -67,28 +72,7 @@ export function useConnectors(active: boolean) { loadProviders() }, []) - // Re-check composio-for-google flags when active - useEffect(() => { - if (!active) return - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() - }, [active]) + // (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.) // Load Granola config const refreshGranolaConfig = useCallback(async () => { @@ -346,13 +330,22 @@ export function useConnectors(active: boolean) { const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Main process detects + // signed-in via isSignedIn() when oauth:connect arrives without creds. + // Falls back to the BYOK modal for not-signed-in users. + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdDescription(undefined) setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) @@ -485,19 +478,6 @@ export function useConnectors(active: boolean) { toast.success(`Connected to ${displayName}`) } - if (provider === 'rowboat') { - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (err) { - console.error('Failed to re-check composio flags:', err) - } - } - refreshAllStatuses() } }) diff --git a/apps/x/packages/core/src/auth/google-backend-oauth.ts b/apps/x/packages/core/src/auth/google-backend-oauth.ts new file mode 100644 index 00000000..a441d205 --- /dev/null +++ b/apps/x/packages/core/src/auth/google-backend-oauth.ts @@ -0,0 +1,113 @@ +import { API_URL } from "../config/env.js"; +import { getAccessToken } from "./tokens.js"; +import { OAuthTokens } from "./types.js"; + +/** + * Client for the rowboat-mode Google OAuth endpoints on the api: + * POST /v1/google-oauth/claim — one-shot retrieval of tokens parked by + * the webapp callback under a `state` ticket + * POST /v1/google-oauth/refresh — exchange a refresh_token for fresh tokens + * (the secret-requiring step that can't + * happen on the desktop) + * + * Both are called with the user's Rowboat Supabase bearer (via getAccessToken). + * + * The api response shape uses `scope: string` (space-delimited); we convert + * to the desktop's `scopes: string[]`. On refresh, api may omit `scope` and + * `refresh_token` — caller-provided existingScopes / refreshToken are + * preserved in those cases (Google rarely rotates refresh tokens). + */ + +/** Thrown when the api signals the user must reconnect (Google `invalid_grant`). */ +export class ReconnectRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "ReconnectRequiredError"; + } +} + +interface ApiTokenResponse { + access_token: string; + refresh_token?: string; + expires_at: number; + scope?: string; + token_type?: string; +} + +function toOAuthTokens( + body: ApiTokenResponse, + fallbackRefreshToken: string | null = null, + fallbackScopes?: string[], +): OAuthTokens { + const refresh_token = body.refresh_token ?? fallbackRefreshToken; + const scopes = body.scope + ? body.scope.split(" ").filter((s) => s.length > 0) + : fallbackScopes; + return { + access_token: body.access_token, + refresh_token, + expires_at: body.expires_at, + token_type: "Bearer", + scopes, + }; +} + +async function postWithBearer(path: string, body: unknown): Promise { + const bearer = await getAccessToken(); + return fetch(`${API_URL}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bearer}`, + }, + body: JSON.stringify(body), + }); +} + +interface ErrorBody { + error?: string; + reconnectRequired?: boolean; +} + +async function readError(res: Response): Promise { + try { + return (await res.json()) as ErrorBody; + } catch { + return {}; + } +} + +/** Claim the tokens parked under `state` after the webapp finished its callback. */ +export async function claimTokensViaBackend(state: string): Promise { + const res = await postWithBearer("/v1/google-oauth/claim", { session: state }); + if (!res.ok) { + const err = await readError(res); + throw new Error(`claim failed: ${res.status} ${err.error ?? ""}`.trim()); + } + const body = (await res.json()) as ApiTokenResponse; + return toOAuthTokens(body); +} + +/** + * Refresh an access token via the api. Preserves caller's `refreshToken` and + * `existingScopes` when Google omits them on the refresh response. + */ +export async function refreshTokensViaBackend( + refreshToken: string, + existingScopes?: string[], +): Promise { + const res = await postWithBearer("/v1/google-oauth/refresh", { refreshToken }); + if (res.status === 409) { + const err = await readError(res); + if (err.reconnectRequired) { + throw new ReconnectRequiredError(err.error ?? "Reconnect required"); + } + throw new Error(`refresh failed: 409 ${err.error ?? ""}`.trim()); + } + if (!res.ok) { + const err = await readError(res); + throw new Error(`refresh failed: ${res.status} ${err.error ?? ""}`.trim()); + } + const body = (await res.json()) as ApiTokenResponse; + return toOAuthTokens(body, refreshToken, existingScopes); +} diff --git a/apps/x/packages/core/src/auth/repo.ts b/apps/x/packages/core/src/auth/repo.ts index 5276faea..08f6b56d 100644 --- a/apps/x/packages/core/src/auth/repo.ts +++ b/apps/x/packages/core/src/auth/repo.ts @@ -8,6 +8,13 @@ const ProviderConnectionSchema = z.object({ tokens: OAuthTokens.nullable().optional(), clientId: z.string().nullable().optional(), clientSecret: z.string().nullable().optional(), + /** + * `byok` (default for absent) — user provides their own client_id+secret; + * tokens stored locally; refresh handled locally via openid-client. + * `rowboat` — signed-in user; client_id+secret never on the desktop; + * tokens stored locally but refresh goes through the api. + */ + mode: z.enum(['byok', 'rowboat']).optional(), error: z.string().nullable().optional(), }); diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 2844fc28..8080d923 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -49,8 +49,6 @@ async function getAuthHeaders(): Promise> { */ const ZComposioConfig = z.object({ apiKey: z.string().optional(), - use_composio_for_google: z.boolean().optional(), - use_composio_for_google_calendar: z.boolean().optional(), }); type ComposioConfig = z.infer; @@ -106,24 +104,6 @@ export async function isConfigured(): Promise { return !!getApiKey(); } -/** - * Check if Composio should be used for Google services (Gmail, etc.) - */ -export async function useComposioForGoogle(): Promise { - if (await isSignedIn()) return true; - const config = loadConfig(); - return config.use_composio_for_google === true; -} - -/** - * Check if Composio should be used for Google Calendar - */ -export async function useComposioForGoogleCalendar(): Promise { - if (await isSignedIn()) return true; - const config = loadConfig(); - return config.use_composio_for_google_calendar === true; -} - /** * Make an API call to Composio */ diff --git a/apps/x/packages/core/src/config/remote-config.ts b/apps/x/packages/core/src/config/remote-config.ts new file mode 100644 index 00000000..87174ef7 --- /dev/null +++ b/apps/x/packages/core/src/config/remote-config.ts @@ -0,0 +1,51 @@ +import { API_URL } from "./env.js"; + +/** + * Per-process cache of the unauthenticated `GET /v1/config` response from + * the api. The api returns `{ appUrl, supabaseUrl, websocketApiUrl }` — + * we use this to discover the webapp host (where the rowboat-mode OAuth + * flow runs) without hardcoding it on the desktop side. + * + * Cached as a Promise so concurrent first-callers all await the same fetch + * (no thundering herd). On failure the cache is cleared so the next call + * can retry. + */ + +interface RemoteConfig { + appUrl: string; + supabaseUrl: string; + websocketApiUrl: string; +} + +let _cached: Promise | null = null; + +async function fetchRemoteConfig(): Promise { + const res = await fetch(`${API_URL}/v1/config`); + if (!res.ok) { + throw new Error(`/v1/config returned ${res.status}`); + } + const body = (await res.json()) as Partial; + if (!body.appUrl) { + throw new Error("/v1/config response missing appUrl"); + } + return { + appUrl: body.appUrl, + supabaseUrl: body.supabaseUrl ?? "", + websocketApiUrl: body.websocketApiUrl ?? "", + }; +} + +export async function getRemoteConfig(): Promise { + if (!_cached) { + _cached = fetchRemoteConfig().catch((err) => { + _cached = null; // allow retry + throw err; + }); + } + return _cached; +} + +export async function getWebappUrl(): Promise { + const config = await getRemoteConfig(); + return config.appUrl; +} diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 471bfecd..301c10a6 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -8,8 +8,6 @@ import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { loadUserConfig, updateUserEmail } from '../config/user_config.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { useComposioForGoogle, executeAction } from '../composio/client.js'; -import { composioAccountsRepo } from '../composio/repo.js'; import { loadAgentNotesState, saveAgentNotesState, @@ -199,30 +197,7 @@ async function ensureUserEmail(): Promise { return existing.email; } - // Try Composio (used when signed in or composio configured) - try { - if (await useComposioForGoogle()) { - const account = composioAccountsRepo.getAccount('gmail'); - if (account && account.status === 'ACTIVE') { - const result = await executeAction('GMAIL_GET_PROFILE', { - connected_account_id: account.id, - user_id: 'rowboat-user', - version: 'latest', - arguments: { user_id: 'me' }, - }); - const email = (result.data as Record)?.emailAddress as string | undefined; - if (email) { - updateUserEmail(email); - console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`); - return email; - } - } - } - } catch (error) { - console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error); - } - - // Try direct Google OAuth + // Try direct Google OAuth (covers both BYOK and rowboat modes) try { const auth = await GoogleClientFactory.getClient(); if (auth) { diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index 9e0ad2d1..0c48ae37 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -6,20 +6,44 @@ import { getProviderConfig } from '../auth/providers.js'; import * as oauthClient from '../auth/oauth-client.js'; import type { Configuration } from '../auth/oauth-client.js'; import { OAuthTokens } from '../auth/types.js'; +import { + ReconnectRequiredError, + refreshTokensViaBackend, +} from '../auth/google-backend-oauth.js'; + +type Mode = 'byok' | 'rowboat'; /** * Factory for creating and managing Google OAuth2Client instances. * Handles caching, token refresh, and client reuse for Google API SDKs. + * + * Two connection modes share the same `oauth.json` provider entry: + * - `byok` user supplied client_id+secret; refresh runs locally via + * openid-client; OAuth2Client built with creds. + * - `rowboat` signed-in user; client_id+secret never on the desktop; + * refresh goes through the api at /v1/google-oauth/refresh; + * OAuth2Client built without creds and without refresh_token + * (we own all refreshes — see note below). + * + * **Auto-refresh disabled in rowboat mode:** google-auth-library's + * OAuth2Client will, on a 401 from a Google API call, try to refresh using + * the refresh_token + client secret it has on hand. In rowboat mode we have + * no secret, so that would 401-loop. We block this by passing only + * access_token + expiry_date in setCredentials (no refresh_token), which + * leaves the library nothing to refresh with. Our proactive expiry check + * in getClient() is the only refresh path. */ export class GoogleClientFactory { private static readonly PROVIDER_NAME = 'google'; private static cache: { + mode: Mode | null; config: Configuration | null; client: OAuth2Client | null; tokens: OAuthTokens | null; clientId: string | null; clientSecret: string | null; } = { + mode: null, config: null, client: null, tokens: null, @@ -27,7 +51,14 @@ export class GoogleClientFactory { clientSecret: null, }; - private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> { + /** + * Promise singleton so a burst of getClient() calls during the brief + * expiry window all wait on a single refresh round-trip rather than + * fanning out parallel refreshes. + */ + private static refreshInFlight: Promise | null = null; + + private static async resolveByokCredentials(): Promise<{ clientId: string; clientSecret?: string }> { const oauthRepo = container.resolve('oauthRepo'); const connection = await oauthRepo.read(this.PROVIDER_NAME); if (!connection.clientId) { @@ -41,80 +72,116 @@ export class GoogleClientFactory { * Get or create OAuth2Client, reusing cached instance when possible */ static async getClient(): Promise { + if (this.refreshInFlight) { + return this.refreshInFlight; + } + const oauthRepo = container.resolve('oauthRepo'); - const { tokens } = await oauthRepo.read(this.PROVIDER_NAME); + const connection = await oauthRepo.read(this.PROVIDER_NAME); + const tokens = connection.tokens ?? null; + const mode: Mode = connection.mode ?? 'byok'; if (!tokens) { this.clearCache(); return null; } - // Initialize config cache if needed - try { - await this.initializeConfigCache(); - } catch (error) { - console.error("[OAuth] Failed to initialize Google OAuth configuration:", error); + // Mode flipped (e.g. user disconnected then reconnected differently) — invalidate. + if (this.cache.mode && this.cache.mode !== mode) { this.clearCache(); - return null; - } - if (!this.cache.config) { - return null; } - // Check if token is expired + // BYOK needs an openid-client Configuration for local refresh; rowboat doesn't. + if (mode === 'byok') { + try { + await this.initializeConfigCache(); + } catch (error) { + console.error('[OAuth] Failed to initialize Google OAuth configuration:', error); + this.clearCache(); + return null; + } + if (!this.cache.config) { + return null; + } + } + + // Check expiry against the cached tokens. Note: oauthClient.isTokenExpired + // applies a small clock-skew margin so we refresh slightly before real + // expiry — keeps long-running calls from racing the boundary. if (oauthClient.isTokenExpired(tokens)) { - // Token expired, try to refresh if (!tokens.refresh_token) { - console.log("[OAuth] Token expired and no refresh token available for Google."); + console.log('[OAuth] Token expired and no refresh token available for Google.'); await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' }); this.clearCache(); return null; } - try { - console.log(`[OAuth] Token expired, refreshing access token...`); - const existingScopes = tokens.scopes; - const refreshedTokens = await oauthClient.refreshTokens( - this.cache.config, - tokens.refresh_token, - existingScopes - ); - await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens }); - - // Update cached tokens and recreate client - this.cache.tokens = refreshedTokens; - if (!this.cache.clientId) { - const creds = await this.resolveCredentials(); - this.cache.clientId = creds.clientId; - this.cache.clientSecret = creds.clientSecret ?? null; - } - this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined); - console.log(`[OAuth] Token refreshed successfully`); - return this.cache.client; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to refresh token for Google'; - await oauthRepo.upsert(this.PROVIDER_NAME, { error: message }); - console.error("[OAuth] Failed to refresh token for Google:", error); - this.clearCache(); - return null; - } + this.refreshInFlight = this.refreshAndBuild(tokens, mode).finally(() => { + this.refreshInFlight = null; + }); + return this.refreshInFlight; } // Reuse client if tokens haven't changed - if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token) { + if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token && this.cache.mode === mode) { return this.cache.client; } - // Create new client with current tokens - console.log(`[OAuth] Creating new OAuth2Client instance`); - this.cache.tokens = tokens; - if (!this.cache.clientId) { - const creds = await this.resolveCredentials(); + // Build a fresh client for current tokens + return this.buildAndCacheClient(tokens, mode); + } + + private static async refreshAndBuild(tokens: OAuthTokens, mode: Mode): Promise { + const oauthRepo = container.resolve('oauthRepo'); + + try { + console.log(`[OAuth] Token expired, refreshing via ${mode}...`); + const existingScopes = tokens.scopes; + + let refreshedTokens: OAuthTokens; + if (mode === 'rowboat') { + refreshedTokens = await refreshTokensViaBackend(tokens.refresh_token!, existingScopes); + } else { + if (!this.cache.config) { + // Should not happen — initializeConfigCache ran above for byok. + throw new Error('Google OAuth config not initialized'); + } + refreshedTokens = await oauthClient.refreshTokens(this.cache.config, tokens.refresh_token!, existingScopes); + } + + await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens, error: null }); + console.log('[OAuth] Token refreshed successfully'); + return this.buildAndCacheClient(refreshedTokens, mode); + } catch (error) { + if (error instanceof ReconnectRequiredError) { + console.log('[OAuth] Reconnect required for Google'); + await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Reconnect Google' }); + this.clearCache(); + return null; + } + const message = error instanceof Error ? error.message : 'Failed to refresh token for Google'; + await oauthRepo.upsert(this.PROVIDER_NAME, { error: message }); + console.error('[OAuth] Failed to refresh token for Google:', error); + this.clearCache(); + return null; + } + } + + private static async buildAndCacheClient(tokens: OAuthTokens, mode: Mode): Promise { + if (mode === 'byok' && !this.cache.clientId) { + const creds = await this.resolveByokCredentials(); this.cache.clientId = creds.clientId; this.cache.clientSecret = creds.clientSecret ?? null; } - this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined); - return this.cache.client; + + const client = mode === 'rowboat' + ? this.createRowboatClient(tokens) + : this.createByokClient(tokens, this.cache.clientId!, this.cache.clientSecret ?? undefined); + + this.cache.mode = mode; + this.cache.tokens = tokens; + this.cache.client = client; + return client; } /** @@ -139,7 +206,8 @@ export class GoogleClientFactory { * Clear cache (useful for testing or when credentials are revoked) */ static clearCache(): void { - console.log(`[OAuth] Clearing Google auth cache`); + console.log('[OAuth] Clearing Google auth cache'); + this.cache.mode = null; this.cache.config = null; this.cache.client = null; this.cache.tokens = null; @@ -148,10 +216,10 @@ export class GoogleClientFactory { } /** - * Initialize cached configuration (called once) + * Initialize cached configuration for BYOK mode (rowboat doesn't need it). */ private static async initializeConfigCache(): Promise { - const { clientId, clientSecret } = await this.resolveCredentials(); + const { clientId, clientSecret } = await this.resolveByokCredentials(); if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) { return; // Already initialized for these credentials @@ -161,13 +229,13 @@ export class GoogleClientFactory { this.clearCache(); } - console.log(`[OAuth] Initializing Google OAuth configuration...`); + console.log('[OAuth] Initializing Google OAuth configuration...'); const providerConfig = await getProviderConfig(this.PROVIDER_NAME); if (providerConfig.discovery.mode === 'issuer') { if (providerConfig.client.mode === 'static') { // Discover endpoints, use static client ID - console.log(`[OAuth] Discovery mode: issuer with static client ID`); + console.log('[OAuth] Discovery mode: issuer with static client ID'); this.cache.config = await oauthClient.discoverConfiguration( providerConfig.discovery.issuer, clientId, @@ -175,7 +243,7 @@ export class GoogleClientFactory { ); } else { // DCR mode - need existing registration - console.log(`[OAuth] Discovery mode: issuer with DCR`); + console.log('[OAuth] Discovery mode: issuer with DCR'); const clientRepo = container.resolve('clientRegistrationRepo'); const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME); @@ -194,7 +262,7 @@ export class GoogleClientFactory { throw new Error('DCR requires discovery mode "issuer", not "static"'); } - console.log(`[OAuth] Using static endpoints (no discovery)`); + console.log('[OAuth] Using static endpoints (no discovery)'); this.cache.config = oauthClient.createStaticConfiguration( providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.tokenEndpoint, @@ -206,27 +274,33 @@ export class GoogleClientFactory { this.cache.clientId = clientId; this.cache.clientSecret = clientSecret ?? null; - console.log(`[OAuth] Google OAuth configuration initialized`); + console.log('[OAuth] Google OAuth configuration initialized'); } - /** - * Create OAuth2Client from OAuthTokens - */ - private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client { - const client = new OAuth2Client( - clientId, - clientSecret ?? undefined, - undefined // redirect_uri not needed for token usage - ); - - // Set credentials + /** BYOK OAuth2Client — has client_id + secret + refresh_token. */ + private static createByokClient(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client { + const client = new OAuth2Client(clientId, clientSecret ?? undefined, undefined); client.setCredentials({ access_token: tokens.access_token, refresh_token: tokens.refresh_token || undefined, - expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds + expiry_date: tokens.expires_at * 1000, scope: tokens.scopes?.join(' ') || undefined, }); + return client; + } + /** + * Rowboat OAuth2Client — no client_id/secret, no refresh_token. + * Library auto-refresh is disabled by absence of refresh_token; our + * proactive refresh in getClient() is the only refresh path. + */ + private static createRowboatClient(tokens: OAuthTokens): OAuth2Client { + const client = new OAuth2Client(); + client.setCredentials({ + access_token: tokens.access_token, + expiry_date: tokens.expires_at * 1000, + scope: tokens.scopes?.join(' ') || undefined, + }); return client; } } diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index b6258975..b311dfa2 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -5,10 +5,8 @@ import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; +import { serviceLogger } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; -import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js'; -import { composioAccountsRepo } from '../composio/repo.js'; import { createEvent } from './track/events.js'; const MAX_EVENTS_IN_DIGEST = 50; @@ -138,7 +136,6 @@ async function publishCalendarSyncEvent( const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 7; -const COMPOSIO_LOOKBACK_DAYS = 7; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/drive.readonly' @@ -477,286 +474,17 @@ async function performSync(syncDir: string, lookbackDays: number) { } } -// --- Composio-based Sync --- - -interface ComposioCalendarState { - last_sync: string; // ISO string -} - -function loadComposioState(stateFile: string): ComposioCalendarState | null { - if (fs.existsSync(stateFile)) { - try { - const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (data.last_sync) { - return { last_sync: data.last_sync }; - } - } catch (e) { - console.error('[Calendar] Failed to load composio state:', e); - } - } - return null; -} - -function saveComposioState(stateFile: string, lastSync: string): void { - fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); -} - -/** - * Save a Composio calendar event as JSON (same format used by Google OAuth path). - * The event data from Composio is already structured similarly to Google Calendar API. - */ -function saveComposioEvent(eventData: Record, syncDir: string): { changed: boolean; isNew: boolean; title: string } { - const eventId = eventData.id as string | undefined; - if (!eventId) return { changed: false, isNew: false, title: 'Unknown' }; - - const filePath = path.join(syncDir, `${eventId}.json`); - const content = JSON.stringify(eventData, null, 2); - const exists = fs.existsSync(filePath); - - try { - if (exists) { - const existing = fs.readFileSync(filePath, 'utf-8'); - if (existing === content) { - return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; - } - } - - fs.writeFileSync(filePath, content); - return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId }; - } catch (e) { - console.error(`[Calendar] Error saving event ${eventId}:`, e); - return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; - } -} - -async function performSyncComposio() { - const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json'); - - if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); - - const account = composioAccountsRepo.getAccount('googlecalendar'); - if (!account || account.status !== 'ACTIVE') { - console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.'); - return; - } - - const connectedAccountId = account.id; - - // Calculate time window: lookback + 14 days forward - const now = new Date(); - const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000; - const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; - - const timeMin = new Date(now.getTime() - lookbackMs).toISOString(); - const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString(); - - console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`); - - let run: ServiceRunContext | null = null; - const ensureRun = async (): Promise => { - if (!run) { - run = await serviceLogger.startRun({ - service: 'calendar', - message: 'Syncing calendar (Composio)', - trigger: 'timer', - }); - } - return run; - }; - - try { - const currentEventIds = new Set(); - let newCount = 0; - let updatedCount = 0; - const changedTitles: string[] = []; - const newEvents: AnyEvent[] = []; - const updatedEvents: AnyEvent[] = []; - let pageToken: string | null = null; - const MAX_PAGES = 20; - - for (let page = 0; page < MAX_PAGES; page++) { - // Re-check connection in case user disconnected mid-sync - if (!composioAccountsRepo.isConnected('googlecalendar')) { - console.log('[Calendar] Account disconnected during sync. Stopping.'); - return; - } - - const args: Record = { - calendar_id: 'primary', - time_min: timeMin, - time_max: timeMax, - single_events: true, - order_by: 'startTime', - }; - if (pageToken) { - args.page_token = pageToken; - } - - const result = await executeAction( - 'GOOGLECALENDAR_FIND_EVENT', - { - connected_account_id: connectedAccountId, - user_id: 'rowboat-user', - version: 'latest', - arguments: args, - } - ); - - if (!result.successful || !result.data) { - console.error('[Calendar] Failed to list events via Composio:', result.error); - return; - } - - const data = result.data as Record; - // Composio may return events in different structures - let events: Array> = []; - - if (Array.isArray(data.items)) { - events = data.items as Array>; - } else if (Array.isArray(data.events)) { - events = data.events as Array>; - } else if (data.event_data && typeof data.event_data === 'object') { - const nested = data.event_data as Record; - if (Array.isArray(nested.event_data)) { - events = nested.event_data as Array>; - } else if (Array.isArray(data.event_data)) { - events = data.event_data as Array>; - } - } else if (Array.isArray(data)) { - events = data as unknown as Array>; - } - - if (events.length === 0 && page === 0) { - console.log('[Calendar] No events found in this window.'); - } else if (events.length > 0) { - console.log(`[Calendar] Page ${page + 1}: found ${events.length} events.`); - for (const event of events) { - const eventId = event.id as string | undefined; - if (eventId) { - const saveResult = saveComposioEvent(event, SYNC_DIR); - currentEventIds.add(eventId); - - if (saveResult.changed) { - await ensureRun(); - changedTitles.push(saveResult.title); - if (saveResult.isNew) { - newCount++; - newEvents.push(event); - } else { - updatedCount++; - updatedEvents.push(event); - } - } - } - } - } - - // Check for next page - const nextToken = data.nextPageToken as string | undefined; - if (nextToken) { - pageToken = nextToken; - console.log(`[Calendar] Fetching next page...`); - } else { - break; - } - } - - // Clean up events no longer in the window - const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR); - let deletedCount = 0; - if (deletedFiles.length > 0) { - await ensureRun(); - deletedCount = deletedFiles.length; - } - - // Publish a single bundled event capturing all changes from this sync. - await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles); - - // Log results if any changes were detected (run was started by ensureRun) - if (run) { - const r = run as ServiceRunContext; - const totalChanges = newCount + updatedCount + deletedCount; - const limitedTitles = limitEventItems(changedTitles); - await serviceLogger.log({ - type: 'changes_identified', - service: r.service, - runId: r.runId, - level: 'info', - message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, - counts: { - newEvents: newCount, - updatedEvents: updatedCount, - deletedFiles: deletedCount, - }, - items: limitedTitles.items, - truncated: limitedTitles.truncated, - }); - await serviceLogger.log({ - type: 'run_complete', - service: r.service, - runId: r.runId, - level: 'info', - message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, - durationMs: Date.now() - r.startedAt, - outcome: 'ok', - summary: { - newEvents: newCount, - updatedEvents: updatedCount, - deletedFiles: deletedCount, - }, - }); - } - - // Save state - saveComposioState(STATE_FILE, new Date().toISOString()); - console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`); - } catch (error) { - console.error('[Calendar] Error during Composio sync:', error); - const errRun = await ensureRun(); - await serviceLogger.log({ - type: 'error', - service: errRun.service, - runId: errRun.runId, - level: 'error', - message: 'Calendar sync error', - error: error instanceof Error ? error.message : String(error), - }); - await serviceLogger.log({ - type: 'run_complete', - service: errRun.service, - runId: errRun.runId, - level: 'error', - message: 'Calendar sync failed', - durationMs: Date.now() - errRun.startedAt, - outcome: 'error', - }); - } -} - export async function init() { console.log("Starting Google Calendar & Notes Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - const composioMode = await useComposioForGoogleCalendar(); - if (composioMode) { - const isConnected = composioAccountsRepo.isConnected('googlecalendar'); - if (!isConnected) { - console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...'); - } else { - await performSyncComposio(); - } + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); } else { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); - } else { - // Perform one sync - await performSync(SYNC_DIR, LOOKBACK_DAYS); - } + await performSync(SYNC_DIR, LOOKBACK_DAYS); } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 2aa48944..81a63edf 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -7,8 +7,6 @@ import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; -import { executeAction, useComposioForGoogle } from '../composio/client.js'; -import { composioAccountsRepo } from '../composio/repo.js'; import { createEvent } from './track/events.js'; // Configuration @@ -225,7 +223,7 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri } } -function loadState(stateFile: string): { historyId?: string } { +function loadState(stateFile: string): { historyId?: string; last_sync?: string } { if (fs.existsSync(stateFile)) { return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } @@ -240,9 +238,24 @@ function saveState(historyId: string, stateFile: string) { } async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { - console.log(`Performing full sync of last ${lookbackDays} days...`); const gmail = google.gmail({ version: 'v1', auth }); + // If the state file holds a last_sync timestamp (e.g. left over from a + // prior Composio sync, or from a previous successful native sync that + // we're falling back to after a history.list 404), use that as the + // floor instead of the default lookback. Carries forward Composio's + // last_sync on first migration so we don't refetch the last 7 days. + const state = loadState(stateFile); + let pastDate: Date; + if (state.last_sync) { + pastDate = new Date(state.last_sync); + console.log(`Performing full sync from last_sync=${state.last_sync}...`); + } else { + pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - lookbackDays); + console.log(`Performing full sync of last ${lookbackDays} days...`); + } + let run: ServiceRunContext | null = null; const ensureRun = async () => { if (!run) { @@ -255,8 +268,6 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str }; try { - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); // Get History ID @@ -498,386 +509,17 @@ async function performSync() { } } -// --- Composio-based Sync --- - -const COMPOSIO_LOOKBACK_DAYS = 7; - -interface ComposioSyncState { - last_sync: string; // ISO string -} - -function loadComposioState(stateFile: string): ComposioSyncState | null { - if (fs.existsSync(stateFile)) { - try { - const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (data.last_sync) { - return { last_sync: data.last_sync }; - } - } catch (e) { - console.error('[Gmail] Failed to load composio state:', e); - } - } - return null; -} - -function saveComposioState(stateFile: string, lastSync: string): void { - fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); -} - -function tryParseDate(dateStr: string): Date | null { - const d = new Date(dateStr); - return isNaN(d.getTime()) ? null : d; -} - -interface ParsedMessage { - from: string; - date: string; - subject: string; - body: string; -} - -function parseMessageData(messageData: Record): ParsedMessage { - const headers = messageData.payload && typeof messageData.payload === 'object' - ? (messageData.payload as Record).headers as Array<{ name: string; value: string }> | undefined - : undefined; - - const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown'); - const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown'); - const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)'); - - let body = ''; - - if (messageData.payload && typeof messageData.payload === 'object') { - body = extractBodyFromPayload(messageData.payload as Record); - } - - if (!body) { - if (typeof messageData.body === 'string') { - body = messageData.body; - } else if (typeof messageData.snippet === 'string') { - body = messageData.snippet; - } else if (typeof messageData.text === 'string') { - body = messageData.text; - } - } - - if (body && (body.includes(' !line.trim().startsWith('>')).join('\n'); - } - - return { from, date, subject, body }; -} - -function extractBodyFromPayload(payload: Record): string { - const parts = payload.parts as Array> | undefined; - - if (parts) { - for (const part of parts) { - const mimeType = part.mimeType as string | undefined; - const bodyData = part.body && typeof part.body === 'object' - ? (part.body as Record).data as string | undefined - : undefined; - - if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) { - const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); - if (mimeType === 'text/html') { - return nhm.translate(decoded); - } - return decoded; - } - - if (part.parts) { - const result = extractBodyFromPayload(part as Record); - if (result) return result; - } - } - } - - const bodyData = payload.body && typeof payload.body === 'object' - ? (payload.body as Record).data as string | undefined - : undefined; - - if (bodyData) { - const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); - const mimeType = payload.mimeType as string | undefined; - if (mimeType === 'text/html') { - return nhm.translate(decoded); - } - return decoded; - } - - return ''; -} - -interface ComposioThreadResult { - synced: SyncedThread | null; - newestIsoPlusOne: string | null; -} - -async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise { - let threadResult; - try { - threadResult = await executeAction( - 'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', - { - connected_account_id: connectedAccountId, - user_id: 'rowboat-user', - version: 'latest', - arguments: { thread_id: threadId, user_id: 'me' }, - } - ); - } catch (error) { - console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); - return { synced: null, newestIsoPlusOne: null }; - } - - if (!threadResult.successful || !threadResult.data) { - console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); - return { synced: null, newestIsoPlusOne: null }; - } - - const data = threadResult.data as Record; - const messages = data.messages as Array> | undefined; - - let newestDate: Date | null = null; - let mdContent: string; - let subjectForLog: string; - - if (!messages || messages.length === 0) { - const parsed = parseMessageData(data); - mdContent = `# ${parsed.subject}\n\n` + - `**Thread ID:** ${threadId}\n` + - `**Message Count:** 1\n\n---\n\n` + - `### From: ${parsed.from}\n` + - `**Date:** ${parsed.date}\n\n` + - `${parsed.body}\n\n---\n\n`; - subjectForLog = parsed.subject; - newestDate = tryParseDate(parsed.date); - } else { - const firstParsed = parseMessageData(messages[0]); - mdContent = `# ${firstParsed.subject}\n\n`; - mdContent += `**Thread ID:** ${threadId}\n`; - mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; - - for (const msg of messages) { - const parsed = parseMessageData(msg); - mdContent += `### From: ${parsed.from}\n`; - mdContent += `**Date:** ${parsed.date}\n\n`; - mdContent += `${parsed.body}\n\n`; - mdContent += `---\n\n`; - - const msgDate = tryParseDate(parsed.date); - if (msgDate && (!newestDate || msgDate > newestDate)) { - newestDate = msgDate; - } - } - subjectForLog = firstParsed.subject; - } - - fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); - console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`); - - const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null; - return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne }; -} - -async function performSyncComposio() { - const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); - const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); - - if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); - if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); - - const account = composioAccountsRepo.getAccount('gmail'); - if (!account || account.status !== 'ACTIVE') { - console.log('[Gmail] Gmail not connected via Composio. Skipping sync.'); - return; - } - - const connectedAccountId = account.id; - - const state = loadComposioState(STATE_FILE); - let afterEpochSeconds: number; - - if (state) { - afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000); - console.log(`[Gmail] Syncing messages since ${state.last_sync}...`); - } else { - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS); - afterEpochSeconds = Math.floor(pastDate.getTime() / 1000); - console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`); - } - - let run: ServiceRunContext | null = null; - const ensureRun = async () => { - if (!run) { - run = await serviceLogger.startRun({ - service: 'gmail', - message: 'Syncing Gmail (Composio)', - trigger: 'timer', - }); - } - }; - - try { - const allThreadIds: string[] = []; - let pageToken: string | undefined; - - do { - const params: Record = { - query: `after:${afterEpochSeconds}`, - max_results: 20, - user_id: 'me', - }; - if (pageToken) { - params.page_token = pageToken; - } - - const result = await executeAction( - 'GMAIL_LIST_THREADS', - { - connected_account_id: connectedAccountId, - user_id: 'rowboat-user', - version: 'latest', - arguments: params, - } - ); - - if (!result.successful || !result.data) { - console.error('[Gmail] Failed to list threads:', result.error); - return; - } - - const data = result.data as Record; - const threads = data.threads as Array> | undefined; - - if (threads && threads.length > 0) { - for (const thread of threads) { - const threadId = thread.id as string | undefined; - if (threadId) { - allThreadIds.push(threadId); - } - } - } - - pageToken = data.nextPageToken as string | undefined; - } while (pageToken); - - if (allThreadIds.length === 0) { - console.log('[Gmail] No new threads.'); - return; - } - - console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`); - - await ensureRun(); - const limitedThreads = limitEventItems(allThreadIds); - await serviceLogger.log({ - type: 'changes_identified', - service: run!.service, - runId: run!.runId, - level: 'info', - message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`, - counts: { threads: allThreadIds.length }, - items: limitedThreads.items, - truncated: limitedThreads.truncated, - }); - - // Process oldest first so high-water mark advances chronologically - allThreadIds.reverse(); - - let highWaterMark: string | null = state?.last_sync ?? null; - let processedCount = 0; - const synced: SyncedThread[] = []; - for (const threadId of allThreadIds) { - // Re-check connection in case user disconnected mid-sync - if (!composioAccountsRepo.isConnected('gmail')) { - console.log('[Gmail] Account disconnected during sync. Stopping.'); - break; - } - try { - const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); - processedCount++; - - if (result.synced) synced.push(result.synced); - - if (result.newestIsoPlusOne) { - if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) { - highWaterMark = result.newestIsoPlusOne; - } - saveComposioState(STATE_FILE, highWaterMark); - } - } catch (error) { - console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error); - } - } - - await publishGmailSyncEvent(synced); - - await serviceLogger.log({ - type: 'run_complete', - service: run!.service, - runId: run!.runId, - level: 'info', - message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`, - durationMs: Date.now() - run!.startedAt, - outcome: 'ok', - summary: { threads: processedCount }, - }); - - console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`); - } catch (error) { - console.error('[Gmail] Error during sync:', error); - await ensureRun(); - await serviceLogger.log({ - type: 'error', - service: run!.service, - runId: run!.runId, - level: 'error', - message: 'Gmail sync error', - error: error instanceof Error ? error.message : String(error), - }); - await serviceLogger.log({ - type: 'run_complete', - service: run!.service, - runId: run!.runId, - level: 'error', - message: 'Gmail sync failed', - durationMs: Date.now() - run!.startedAt, - outcome: 'error', - }); - } -} - export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - const composioMode = await useComposioForGoogle(); - if (composioMode) { - const isConnected = composioAccountsRepo.isConnected('gmail'); - if (!isConnected) { - console.log('[Gmail] Gmail not connected via Composio. Sleeping...'); - } else { - await performSyncComposio(); - } + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); } else { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); - } else { - // Perform one sync - await performSync(); - } + await performSync(); } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/core/src/migrations/composio-google-migration.ts b/apps/x/packages/core/src/migrations/composio-google-migration.ts new file mode 100644 index 00000000..3e8f699d --- /dev/null +++ b/apps/x/packages/core/src/migrations/composio-google-migration.ts @@ -0,0 +1,132 @@ +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; +import { WorkDir } from '../config/config.js'; +import { isSignedIn } from '../account/account.js'; +import { composioAccountsRepo } from '../composio/repo.js'; +import { deleteConnectedAccount } from '../composio/client.js'; +import container from '../di/container.js'; +import { IOAuthRepo } from '../auth/repo.js'; + +/** + * One-time migration that moves Composio-connected Gmail/Calendar users + * to the native rowboat-mode Google OAuth flow. + * + * Triggered by the renderer on app launch and after Rowboat sign-in. The + * single guard is `dismissed_at` in the migration state file — once set, + * none of the migration's side effects run again. This protects users who + * later re-add Composio Google for non-sync purposes (e.g. a tool that + * happens to use the Gmail toolkit) from having that connection blown + * away on a future launch. + */ + +const STATE_FILE = path.join(WorkDir, 'config', 'composio-google-migration.json'); + +const ZState = z.object({ + dismissed_at: z.string().min(1).optional(), +}); +type State = z.infer; + +function loadState(): State { + try { + if (fs.existsSync(STATE_FILE)) { + const raw = fs.readFileSync(STATE_FILE, 'utf-8'); + return ZState.parse(JSON.parse(raw)); + } + } catch (error) { + console.error('[composio-google-migration] failed to load state:', error); + } + return {}; +} + +function saveState(state: State): void { + const dir = path.dirname(STATE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +function markDismissed(): void { + saveState({ dismissed_at: new Date().toISOString() }); +} + +async function disconnectComposioGoogle(): Promise { + for (const slug of ['gmail', 'googlecalendar'] as const) { + const account = composioAccountsRepo.getAccount(slug); + if (!account?.id) continue; + + try { + await deleteConnectedAccount(account.id); + console.log(`[composio-google-migration] composio: deleted ${slug} (${account.id})`); + } catch (error) { + // Best-effort — logged but doesn't block the local cleanup. + console.warn(`[composio-google-migration] composio delete failed for ${slug}:`, error); + } + + try { + composioAccountsRepo.deleteAccount(slug); + } catch (error) { + console.warn(`[composio-google-migration] local delete failed for ${slug}:`, error); + } + } +} + +function cleanupCalendarComposioState(): void { + const file = path.join(WorkDir, 'calendar_sync', 'composio_state.json'); + try { + if (fs.existsSync(file)) { + fs.unlinkSync(file); + console.log('[composio-google-migration] removed stale calendar composio_state.json'); + } + } catch (error) { + console.warn('[composio-google-migration] failed to remove composio_state.json:', error); + } +} + +/** + * Check whether the user qualifies for the migration. If they do, atomically + * mark `dismissed_at`, fire-and-forget the Composio disconnect, and return + * `{shouldShow: true}` so the renderer can show the modal. + * + * Idempotent: subsequent calls return `{shouldShow: false}` once `dismissed_at` + * is set, regardless of whether the modal was actually shown or the user + * completed the OAuth flow. + */ +export async function qualifyAndDisconnectComposioGoogle(): Promise<{ shouldShow: boolean }> { + // Rule 4 — already processed + const state = loadState(); + if (state.dismissed_at) { + return { shouldShow: false }; + } + + // Rule 1 — must be signed in to Rowboat + if (!(await isSignedIn())) { + return { shouldShow: false }; + } + + // Rule 3 — already on native rowboat-mode Google → silently mark dismissed + // (so we stop re-checking) and bail before touching Composio state. + const oauthRepo = container.resolve('oauthRepo'); + const googleConnection = await oauthRepo.read('google'); + if (googleConnection.tokens && googleConnection.mode === 'rowboat') { + markDismissed(); + return { shouldShow: false }; + } + + // Rule 2 — must have at least one Composio Google toolkit connected + const hasGmail = composioAccountsRepo.isConnected('gmail'); + const hasCalendar = composioAccountsRepo.isConnected('googlecalendar'); + if (!hasGmail && !hasCalendar) { + return { shouldShow: false }; + } + + // All rules pass. Mark dismissed atomically before any side effects so + // a crash mid-migration leaves us in a deterministic post-migration state. + markDismissed(); + + // Fire-and-forget: disconnect Composio Google + clean up the stale + // calendar state file. Both are best-effort. + void disconnectComposioGoogle(); + cleanupCalendarComposioState(); + + return { shouldShow: true }; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index ab7d7f73..605b26d9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -429,16 +429,10 @@ const ipcSchemas = { toolkits: z.array(z.string()), }), }, - 'composio:use-composio-for-google': { + 'migration:check-composio-google': { req: z.null(), res: z.object({ - enabled: z.boolean(), - }), - }, - 'composio:use-composio-for-google-calendar': { - req: z.null(), - res: z.object({ - enabled: z.boolean(), + shouldShow: z.boolean(), }), }, 'composio:didConnect': { From c382e3ee8afa318a2dc53f7d615b65f804e86b92 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 16:07:37 +0530 Subject: [PATCH 19/76] use gemini as default kg model --- apps/x/packages/core/src/models/defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 66dda9e0..dc690d66 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -6,7 +6,7 @@ import container from "../di/container.js"; const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; -const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5"; +const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview"; const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5"; /** From c6083de05438a893e5f324fe472b2a2ccba74baf Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 19:21:32 +0530 Subject: [PATCH 20/76] show errors in activity tab for knowledge graph --- .../src/components/sidebar-content.tsx | 73 +++++++++++++++++-- apps/x/packages/core/src/agents/utils.ts | 62 +++++++++++++--- .../core/src/knowledge/agent_notes.ts | 15 +++- .../core/src/knowledge/build_graph.ts | 13 ++-- .../core/src/knowledge/label_emails.ts | 21 ++++-- .../packages/core/src/knowledge/tag_notes.ts | 21 ++++-- 6 files changed, 171 insertions(+), 34 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 7e204781..9c50c334 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -156,6 +156,28 @@ const SERVICE_LABELS: Record = { granola: "Syncing Granola", graph: "Updating knowledge", voice_memo: "Processing voice memo", + email_labeling: "Labeling emails", + note_tagging: "Tagging notes", + agent_notes: "Updating agent notes", +} + +function summarizeServiceError(error: string): string { + const firstLine = error.split("\n").find((line) => line.trim().length > 0) + return firstLine?.trim() || error.trim() +} + +function collectServiceErrors(events: ServiceEventType[]): Map { + const errors = new Map() + for (const event of events) { + if (event.type === "error") { + errors.set(event.service, summarizeServiceError(event.error)) + continue + } + if (event.type === "run_complete" && event.outcome !== "error") { + errors.delete(event.service) + } + } + return errors } type TasksActions = { @@ -227,6 +249,7 @@ function formatRunTime(ts: string): string { function SyncStatusBar() { const { state } = useSidebar() const [activeServices, setActiveServices] = useState>(new Map()) + const [serviceErrors, setServiceErrors] = useState>(new Map()) const [popoverOpen, setPopoverOpen] = useState(false) const [logEvents, setLogEvents] = useState([]) const [logLoading, setLogLoading] = useState(false) @@ -260,11 +283,25 @@ function SyncStatusBar() { next.delete(nextEvent.runId) return next }) + if (nextEvent.outcome !== 'error') { + setServiceErrors((prev) => { + if (!prev.has(nextEvent.service)) return prev + const next = new Map(prev) + next.delete(nextEvent.service) + return next + }) + } const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) if (existingTimeout) { clearTimeout(existingTimeout) runTimeoutsRef.current.delete(nextEvent.runId) } + } else if (nextEvent.type === 'error') { + setServiceErrors((prev) => { + const next = new Map(prev) + next.set(nextEvent.service, summarizeServiceError(nextEvent.error)) + return next + }) } }) return cleanup @@ -298,10 +335,14 @@ function SyncStatusBar() { // skip malformed lines } } + setServiceErrors(collectServiceErrors(parsed)) // Newest first, limit to 1000 setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS)) } catch { - if (!cancelled) setLogEvents([]) + if (!cancelled) { + setLogEvents([]) + setServiceErrors(new Map()) + } } finally { if (!cancelled) setLogLoading(false) } @@ -312,12 +353,19 @@ function SyncStatusBar() { const isSyncing = activeServices.size > 0 const isCollapsed = state === "collapsed" + const errorEntries = Array.from(serviceErrors.entries()) + const primaryErrorService = errorEntries[0]?.[0] ?? null + const hasServiceErrors = errorEntries.length > 0 // Build status label from active services const activeServiceNames = [...new Set(activeServices.values())] const statusLabel = isSyncing ? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ") - : "All caught up" + : hasServiceErrors + ? errorEntries.length === 1 + ? `${SERVICE_LABELS[primaryErrorService ?? ""] || primaryErrorService} failed` + : "Recent sync issues" + : "All caught up" return ( <> @@ -335,11 +383,16 @@ function SyncStatusBar() { + +

+ 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({ From 72ed4bd6d9f7caa3850c3105f7e128bf40c5e19f Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 12:25:10 +0530 Subject: [PATCH 23/76] pull browser-harness skills (#519) use browser-harness skill without eval or http-fetch --- .../apps/main/src/browser/control-service.ts | 30 ++- .../assistant/skills/browser-control/skill.ts | 17 +- .../src/application/browser-skills/index.ts | 3 + .../src/application/browser-skills/loader.ts | 215 ++++++++++++++++++ .../src/application/browser-skills/matcher.ts | 56 +++++ .../core/src/application/lib/builtin-tools.ts | 66 ++++++ apps/x/packages/shared/src/browser-control.ts | 8 + 7 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 apps/x/packages/core/src/application/browser-skills/index.ts create mode 100644 apps/x/packages/core/src/application/browser-skills/loader.ts create mode 100644 apps/x/packages/core/src/application/browser-skills/matcher.ts diff --git a/apps/x/apps/main/src/browser/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts index b83ea7cb..7c97ea7a 100644 --- a/apps/x/apps/main/src/browser/control-service.ts +++ b/apps/x/apps/main/src/browser/control-service.ts @@ -1,8 +1,24 @@ import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js'; -import type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js'; +import type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js'; +import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js'; import { browserViewManager } from './view.js'; import { normalizeNavigationTarget } from './navigation.js'; +async function getSuggestedSkills(url: string | undefined): Promise { + if (!url) return undefined; + try { + const status = await ensureLoaded(); + if (status.status === 'ready' || status.status === 'stale') { + const matched = matchSkillsForUrl(status.index, url); + if (matched.length === 0) return undefined; + return matched.map((e) => ({ id: e.id, title: e.title, path: e.path })); + } + } catch (err) { + console.warn('[browser-control] suggestedSkills lookup failed:', err); + } + return undefined; +} + function buildSuccessResult( action: BrowserControlAction, message: string, @@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult( 'new-tab', target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', page, ); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'switch-tab': { @@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('navigate', `Navigated to ${target}.`, page); + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'back': { @@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok || !result.page) { return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.'); } - return buildSuccessResult('read-page', 'Read the current page.', result.page); + const suggestedSkills = await getSuggestedSkills(result.page.url); + const success = buildSuccessResult('read-page', 'Read the current page.', result.page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'click': { diff --git a/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts b/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts index f1c06f0c..868ce8e8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts @@ -14,8 +14,10 @@ Use this skill when the user asks you to open a website, browse in-app, search t - page ` + "`url`" + ` and ` + "`title`" + ` - visible page text - interactable elements with numbered ` + "`index`" + ` values -4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `. -5. After each action, read the returned page snapshot before deciding the next step. + - ` + "`suggestedSkills`" + ` — site-specific and interaction-specific skill hints for the current page +4. **Always inspect ` + "`suggestedSkills`" + ` before acting.** If any skill in the list matches what the user asked for (site or task), call ` + "`load-browser-skill({ id: \"\" })`" + ` *first*, read it in full, then plan your actions. These skills encode selectors, timing, and gotchas that would otherwise cost you several failed attempts to rediscover. If no skill matches, proceed — but do not skip this check. +5. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `. +6. After each action, read the returned page snapshot before deciding the next step — including re-checking ` + "`suggestedSkills`" + ` if the navigation landed you on a new domain. ## Actions @@ -92,12 +94,23 @@ Wait for the page to settle, useful after async UI changes. Parameters: - ` + "`ms`" + `: milliseconds to wait (optional) +## Companion Tools + +### load-browser-skill +Rowboat caches a library of browser skills (from ` + "`browser-use/browser-harness`" + `) indexed by both **domain** (github, linkedin, amazon, booking, …) and **interaction type** within a domain (e.g. ` + "`github/repo-actions`" + `, ` + "`github/scraping`" + `, ` + "`arxiv-bulk/*`" + `). Whenever ` + "`browser-control`" + ` returns a ` + "`suggestedSkills`" + ` array — which it does on ` + "`navigate`" + `, ` + "`new-tab`" + `, and ` + "`read-page`" + ` — treat it as a required reading step, not optional. Pick the entry that matches the current task (domain match first, then the interaction-specific variant if one exists) and call ` + "`load-browser-skill({ id: \"\" })`" + ` before attempting the action. + +You can also proactively call ` + "`load-browser-skill({ action: \"list\", site: \"\" })`" + ` when you know you're about to work on a site, to see what skills exist even if ` + "`suggestedSkills`" + ` is empty (e.g. before navigating). + +These skills are written against a Python harness, so treat them as **reference knowledge**. Reuse the selectors, timing, and sequencing, but adapt them to Rowboat's structured browser actions. **Do not look for or call ` + "`http-fetch`" + `.** If a browser-harness recipe suggests ` + "`js(...)`" + ` or ` + "`http_get(...)`" + ` style shortcuts, treat those as non-portable and fall back to reading and interacting with the page itself. + ## Important Rules - Prefer ` + "`read-page`" + ` before interacting. - Prefer element ` + "`index`" + ` over CSS selectors. - If the tool says the snapshot is stale, call ` + "`read-page`" + ` again. - After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state. +- **Always check ` + "`suggestedSkills`" + ` after ` + "`navigate`" + `, ` + "`new-tab`" + `, or ` + "`read-page`" + `, and load the matching domain or interaction skill before acting.** Skipping this step is the single most common way to waste a dozen failed clicks on a site whose quirks are already documented. If the array is empty, proceed normally — but don't skip the check. +- Do not try to use ` + "`http-fetch`" + `. If a browser-harness recipe mentions ` + "`http_get(...)`" + ` or a public API shortcut, adapt it to DOM-based browsing instead. - Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary. - Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs. - If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card. diff --git a/apps/x/packages/core/src/application/browser-skills/index.ts b/apps/x/packages/core/src/application/browser-skills/index.ts new file mode 100644 index 00000000..2040c963 --- /dev/null +++ b/apps/x/packages/core/src/application/browser-skills/index.ts @@ -0,0 +1,3 @@ +export { ensureLoaded, readSkillContent, refreshFromRemote } from './loader.js'; +export type { SkillEntry, SkillsIndex, LoaderStatus } from './loader.js'; +export { matchSkillsForUrl } from './matcher.js'; diff --git a/apps/x/packages/core/src/application/browser-skills/loader.ts b/apps/x/packages/core/src/application/browser-skills/loader.ts new file mode 100644 index 00000000..3e68d7ca --- /dev/null +++ b/apps/x/packages/core/src/application/browser-skills/loader.ts @@ -0,0 +1,215 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { WorkDir } from '../../config/config.js'; + +const REPO_OWNER = 'browser-use'; +const REPO_NAME = 'browser-harness'; +const REPO_BRANCH = 'main'; +const DOMAIN_SKILLS_PREFIX = 'domain-skills/'; + +const MANIFEST_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 20_000; + +export type SkillEntry = { + id: string; // e.g. "github/repo-actions" + site: string; // e.g. "github" + fileName: string; // e.g. "repo-actions.md" + title: string; // first H1 from the markdown, or a derived title + path: string; // relative repo path, e.g. "domain-skills/github/repo-actions.md" + localPath: string; // absolute path on disk +}; + +export type SkillsIndex = { + fetchedAt: number; + treeSha: string; + entries: SkillEntry[]; +}; + +export type LoaderStatus = + | { status: 'ready'; index: SkillsIndex } + | { status: 'stale'; index: SkillsIndex; refreshing: boolean } + | { status: 'empty' } + | { status: 'error'; error: string }; + +const cacheRoot = () => path.join(WorkDir, 'cache', 'browser-skills'); +const skillsDir = () => path.join(cacheRoot(), 'domain-skills'); +const manifestPath = () => path.join(cacheRoot(), 'manifest.json'); + +async function ensureCacheDir(): Promise { + await fs.mkdir(skillsDir(), { recursive: true }); +} + +async function readManifest(): Promise { + try { + const raw = await fs.readFile(manifestPath(), 'utf8'); + const parsed = JSON.parse(raw) as SkillsIndex; + if (!parsed.entries || !Array.isArray(parsed.entries)) return null; + return parsed; + } catch { + return null; + } +} + +async function writeManifest(index: SkillsIndex): Promise { + await ensureCacheDir(); + await fs.writeFile(manifestPath(), JSON.stringify(index, null, 2), 'utf8'); +} + +function extractTitle(markdown: string, fallback: string): string { + const match = markdown.match(/^#\s+(.+?)\s*$/m); + if (match?.[1]) return match[1].trim(); + return fallback; +} + +async function fetchWithTimeout(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { + ...init, + signal: controller.signal, + headers: { + 'User-Agent': 'rowboat-browser-skills', + Accept: 'application/vnd.github+json', + ...(init?.headers ?? {}), + }, + }); + } finally { + clearTimeout(timer); + } +} + +type GithubTreeNode = { path: string; type: string; sha: string }; + +async function fetchRepoTree(): Promise<{ treeSha: string; skillPaths: string[] }> { + const branchUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches/${REPO_BRANCH}`; + const branchRes = await fetchWithTimeout(branchUrl); + if (!branchRes.ok) { + throw new Error(`GitHub branch fetch failed: ${branchRes.status} ${branchRes.statusText}`); + } + const branch = (await branchRes.json()) as { commit: { commit: { tree: { sha: string } } } }; + const treeSha = branch.commit?.commit?.tree?.sha; + if (!treeSha) throw new Error('Could not resolve tree SHA from branch response.'); + + const treeUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/${treeSha}?recursive=1`; + const treeRes = await fetchWithTimeout(treeUrl); + if (!treeRes.ok) { + throw new Error(`GitHub tree fetch failed: ${treeRes.status} ${treeRes.statusText}`); + } + const tree = (await treeRes.json()) as { tree: GithubTreeNode[]; truncated: boolean }; + + const skillPaths = tree.tree + .filter((n) => n.type === 'blob' && n.path.startsWith(DOMAIN_SKILLS_PREFIX) && n.path.endsWith('.md')) + .map((n) => n.path); + + return { treeSha, skillPaths }; +} + +async function fetchRawFile(repoPath: string): Promise { + const url = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}/${repoPath}`; + const res = await fetchWithTimeout(url, { headers: { Accept: 'text/plain' } }); + if (!res.ok) { + throw new Error(`Raw file fetch failed for ${repoPath}: ${res.status} ${res.statusText}`); + } + return res.text(); +} + +function parseRepoPath(repoPath: string): { id: string; site: string; fileName: string } | null { + const rel = repoPath.slice(DOMAIN_SKILLS_PREFIX.length); + const parts = rel.split('/'); + if (parts.length < 2) return null; + const site = parts[0]; + const fileName = parts.slice(1).join('/'); + const id = rel.replace(/\.md$/, ''); + return { id, site, fileName }; +} + +export async function refreshFromRemote(): Promise { + await ensureCacheDir(); + const { treeSha, skillPaths } = await fetchRepoTree(); + + const entries: SkillEntry[] = []; + await Promise.all(skillPaths.map(async (repoPath) => { + const parsed = parseRepoPath(repoPath); + if (!parsed) return; + try { + const content = await fetchRawFile(repoPath); + const localRel = path.join(parsed.site, parsed.fileName); + const localPath = path.join(skillsDir(), localRel); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, content, 'utf8'); + entries.push({ + id: parsed.id, + site: parsed.site, + fileName: parsed.fileName, + title: extractTitle(content, parsed.id), + path: repoPath, + localPath, + }); + } catch (err) { + console.warn(`[browser-skills] Failed to fetch ${repoPath}:`, err); + } + })); + + entries.sort((a, b) => a.id.localeCompare(b.id)); + + const index: SkillsIndex = { + fetchedAt: Date.now(), + treeSha, + entries, + }; + await writeManifest(index); + return index; +} + +let inFlightRefresh: Promise | null = null; + +export async function ensureLoaded(options?: { forceRefresh?: boolean }): Promise { + try { + const existing = await readManifest(); + const fresh = existing && Date.now() - existing.fetchedAt < MANIFEST_TTL_MS; + + if (existing && fresh && !options?.forceRefresh) { + return { status: 'ready', index: existing }; + } + + if (existing && !options?.forceRefresh) { + if (!inFlightRefresh) { + inFlightRefresh = refreshFromRemote() + .catch((err) => { + console.warn('[browser-skills] Background refresh failed:', err); + return existing; + }) + .finally(() => { inFlightRefresh = null; }); + } + return { status: 'stale', index: existing, refreshing: true }; + } + + if (!inFlightRefresh) { + inFlightRefresh = refreshFromRemote().finally(() => { inFlightRefresh = null; }); + } + try { + const index = await inFlightRefresh; + return { status: 'ready', index }; + } catch (err) { + return { status: 'error', error: err instanceof Error ? err.message : 'Failed to load skills.' }; + } + } catch (err) { + return { status: 'error', error: err instanceof Error ? err.message : 'Skill loader failed.' }; + } +} + +export async function readSkillContent(id: string): Promise<{ ok: true; content: string; entry: SkillEntry } | { ok: false; error: string }> { + const status = await ensureLoaded(); + if (status.status === 'error' || status.status === 'empty') { + return { ok: false, error: status.status === 'error' ? status.error : 'No skills cached yet.' }; + } + const entry = status.index.entries.find((e) => e.id === id); + if (!entry) return { ok: false, error: `Skill '${id}' not found.` }; + try { + const content = await fs.readFile(entry.localPath, 'utf8'); + return { ok: true, content, entry }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Failed to read skill file.' }; + } +} diff --git a/apps/x/packages/core/src/application/browser-skills/matcher.ts b/apps/x/packages/core/src/application/browser-skills/matcher.ts new file mode 100644 index 00000000..a4aabde8 --- /dev/null +++ b/apps/x/packages/core/src/application/browser-skills/matcher.ts @@ -0,0 +1,56 @@ +import type { SkillEntry, SkillsIndex } from './loader.js'; + +/** + * Map browser-harness `domain-skills//` folder names to hostname tokens we + * match against the current tab's URL. + * + * Heuristic: for each site folder we generate candidate hostnames like + * "booking-com" -> ["booking-com", "bookingcom", "booking.com"] + * "github" -> ["github", "github.com"] + * "dev-to" -> ["dev-to", "devto", "dev.to"] + * Then we check whether any candidate is a substring of the tab hostname. + */ +function siteCandidates(site: string): string[] { + const candidates = new Set(); + candidates.add(site); + candidates.add(site.replace(/-/g, '')); + candidates.add(site.replace(/-/g, '.')); + if (site.endsWith('-com')) { + candidates.add(`${site.slice(0, -4)}.com`); + } + if (site.endsWith('-org')) { + candidates.add(`${site.slice(0, -4)}.org`); + } + if (site.endsWith('-io')) { + candidates.add(`${site.slice(0, -3)}.io`); + } + return Array.from(candidates); +} + +function extractHostname(url: string): string | null { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} + +export function matchSkillsForUrl(index: SkillsIndex, url: string, limit = 5): SkillEntry[] { + const hostname = extractHostname(url); + if (!hostname) return []; + + const bySite = new Map(); + for (const entry of index.entries) { + if (!bySite.has(entry.site)) bySite.set(entry.site, []); + bySite.get(entry.site)!.push(entry); + } + + const matched: SkillEntry[] = []; + for (const [site, entries] of bySite) { + const candidates = siteCandidates(site); + const hit = candidates.some((c) => hostname === c || hostname.endsWith(`.${c}`) || hostname.includes(c)); + if (hit) matched.push(...entries); + } + + return matched.slice(0, limit); +} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 65b398a1..7dd06dd2 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -18,6 +18,7 @@ import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js"; import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js"; +import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js"; import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; @@ -1007,6 +1008,71 @@ export const BuiltinTools: z.infer = { }, }, + // ============================================================================ + // Browser Skills (browser-use/browser-harness domain-skills cache) + // ============================================================================ + + 'load-browser-skill': { + description: 'Load a site-specific browser skill (from the browser-use/browser-harness domain-skills library) by id. Returns the full markdown content with selectors, gotchas, and recipes for the target site. Call this after browser-control responses surface a matching skill in suggestedSkills. Pass action="list" to see all available skills. Skills are fetched on first use and cached locally; pass action="refresh" to force an update from upstream.', + inputSchema: z.object({ + action: z.enum(['load', 'list', 'refresh']).optional().describe('load: fetch a skill by id (default). list: list all cached skills. refresh: re-fetch the library from upstream.'), + id: z.string().optional().describe('Skill id (e.g., "github/repo-actions") — required for load.'), + site: z.string().optional().describe('Filter list results to a single site (e.g., "github").'), + }), + execute: async (input: { action?: 'load' | 'list' | 'refresh'; id?: string; site?: string }) => { + const action = input.action ?? 'load'; + try { + if (action === 'refresh') { + const index = await refreshBrowserSkills(); + return { + success: true, + message: `Refreshed ${index.entries.length} skill${index.entries.length === 1 ? '' : 's'} from upstream.`, + count: index.entries.length, + treeSha: index.treeSha, + }; + } + + if (action === 'list') { + const status = await ensureBrowserSkillsLoaded(); + if (status.status === 'error') { + return { success: false, error: status.error }; + } + if (status.status === 'empty') { + return { success: false, error: 'No browser skills cached yet.' }; + } + const entries = status.index.entries + .filter((e) => !input.site || e.site === input.site) + .map((e) => ({ id: e.id, title: e.title, site: e.site })); + return { + success: true, + count: entries.length, + skills: entries, + cacheAgeMs: Date.now() - status.index.fetchedAt, + refreshing: status.status === 'stale' ? status.refreshing : false, + }; + } + + if (!input.id) { + return { success: false, error: 'id is required for load.' }; + } + const result = await readBrowserSkillContent(input.id); + if (!result.ok) { + return { success: false, error: result.error }; + } + return { + success: true, + id: result.entry.id, + title: result.entry.title, + site: result.entry.site, + path: result.entry.path, + content: result.content, + }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to load browser skill.' }; + } + }, + }, + // ============================================================================ // Browser Control // ============================================================================ diff --git a/apps/x/packages/shared/src/browser-control.ts b/apps/x/packages/shared/src/browser-control.ts index e1418a5e..e4eb112d 100644 --- a/apps/x/packages/shared/src/browser-control.ts +++ b/apps/x/packages/shared/src/browser-control.ts @@ -116,6 +116,12 @@ export const BrowserControlInputSchema = z.object({ } }); +export const SuggestedBrowserSkillSchema = z.object({ + id: z.string(), + title: z.string(), + path: z.string(), +}); + export const BrowserControlResultSchema = z.object({ success: z.boolean(), action: BrowserControlActionSchema, @@ -123,6 +129,7 @@ export const BrowserControlResultSchema = z.object({ error: z.string().optional(), browser: BrowserStateSchema, page: BrowserPageSnapshotSchema.optional(), + suggestedSkills: z.array(SuggestedBrowserSkillSchema).optional(), }); export type BrowserTabState = z.infer; @@ -132,3 +139,4 @@ export type BrowserPageSnapshot = z.infer; export type BrowserControlAction = z.infer; export type BrowserControlInput = z.infer; export type BrowserControlResult = z.infer; +export type SuggestedBrowserSkill = z.infer; From 5e47bd430942f736a564d26e8eb0b3f55d2ab0e4 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 13:02:01 +0530 Subject: [PATCH 24/76] fix shell path issue on mac --- apps/x/apps/main/src/main.ts | 4 +++- .../core/src/application/assistant/runtime-context.ts | 10 +++++++++- .../core/src/application/lib/command-executor.ts | 10 ++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index c3618000..b7d0a491 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -112,7 +112,9 @@ function initializeExecutionEnvironment(): void { ).trim(); const env = JSON.parse(stdout) as Record; - process.env = { ...env, ...process.env }; + // Let the user's shell environment win for overlapping keys like PATH. + // Finder/launched GUI apps on macOS often start with a stripped PATH. + process.env = { ...process.env, ...env }; } catch (error) { console.error('Failed to load shell environment', error); } diff --git a/apps/x/packages/core/src/application/assistant/runtime-context.ts b/apps/x/packages/core/src/application/assistant/runtime-context.ts index f1011c2c..a9baffc2 100644 --- a/apps/x/packages/core/src/application/assistant/runtime-context.ts +++ b/apps/x/packages/core/src/application/assistant/runtime-context.ts @@ -9,7 +9,15 @@ export interface RuntimeContext { } export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { - return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; + if (platform === 'win32') { + return process.env.ComSpec || 'cmd.exe'; + } + + if (process.env.SHELL) { + return process.env.SHELL; + } + + return platform === 'darwin' ? '/bin/zsh' : '/bin/sh'; } export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 611bde45..11b15d90 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -8,7 +8,6 @@ const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); -const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"()]+|['"()]+$/g, ''); @@ -84,11 +83,12 @@ export async function executeCommand( } ): Promise { try { + const shell = getExecutionShell(); const { stdout, stderr } = await execPromise(command, { cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: EXECUTION_SHELL, + shell, }); return { @@ -161,8 +161,9 @@ export function executeCommandAbortable( }; } + const shell = getExecutionShell(); const proc = spawn(command, [], { - shell: EXECUTION_SHELL, + shell, cwd: options?.cwd, detached: process.platform !== 'win32', // Create process group on Unix stdio: ['ignore', 'pipe', 'pipe'], @@ -272,11 +273,12 @@ export function executeCommandSync( } ): CommandResult { try { + const shell = getExecutionShell(); const stdout = execSync(command, { cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: EXECUTION_SHELL, + shell, }); return { From f26d57e8eb15af350f65c7bc916df82ceb4167b7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 13:10:20 +0530 Subject: [PATCH 25/76] fix sync resume modal copy --- .../composio-google-migration-modal.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx b/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx index 8afea839..97ef9321 100644 --- a/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx +++ b/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx @@ -37,21 +37,14 @@ export function ComposioGoogleMigrationModal({
- Reconnect Google to keep syncing + Reconnect Google to resume syncing

- Rowboat used to sync your Gmail and Calendar through{" "} - Composio, a - third-party connector. We've now built a direct connection to - Google — it's faster, more private, and doesn't rely on a - middleman. -

-

- We've disconnected the Composio connection. Reconnect Google - directly to resume syncing — your existing emails and calendar - events stay exactly where they are. + Knowledge graph syncing for Gmail and Calendar now uses a + direct Google connection. Reconnect to resume. Your existing + emails and events stay where they are.

From 0bb58e55ac6f8f91927298f96e0719579a89bed1 Mon Sep 17 00:00:00 2001 From: gagan Date: Wed, 6 May 2026 14:34:53 +0530 Subject: [PATCH 26/76] feat: minimal Today.md UI polish - no emoji headings, better track chip (#528) * feat: remove emoji headings and polish track block chip styling - Strip emojis from Today.md section headings (new + existing files via migration) - Track chip: full-width card style matching email blocks, colored icons per track type - Larger, taller chip with muted gray background for light/dark mode * feat: increase track chip icon and text size * feat: make track block icons configurable via yaml --- .../renderer/src/extensions/track-block.tsx | 36 ++++++++++++++----- apps/x/apps/renderer/src/styles/editor.css | 34 ++++++++++-------- .../core/src/knowledge/ensure_daily_note.ts | 36 ++++++++++++++++--- apps/x/packages/shared/src/track-block.ts | 1 + 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/apps/x/apps/renderer/src/extensions/track-block.tsx b/apps/x/apps/renderer/src/extensions/track-block.tsx index a87decc8..4f2a1f0a 100644 --- a/apps/x/apps/renderer/src/extensions/track-block.tsx +++ b/apps/x/apps/renderer/src/extensions/track-block.tsx @@ -1,12 +1,31 @@ import { z } from 'zod' -import { useMemo } from 'react' +import { useMemo, type ComponentType } from 'react' import { mergeAttributes, Node } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' -import { Radio, Loader2 } from 'lucide-react' +import { Radio, Loader2, type LucideProps } from 'lucide-react' +import * as LucideIcons from 'lucide-react' import { parse as parseYaml } from 'yaml' import { TrackBlockSchema } from '@x/shared/dist/track-block.js' import { useTrackStatus } from '@/hooks/use-track-status' +function resolveIcon(iconName: string): ComponentType | null { + const key = iconName + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join('') + const component = (LucideIcons as Record)[key] + if (component != null) return component as ComponentType + return null +} + +function TrackIcon({ icon, size }: { icon?: string; size: number }) { + if (icon) { + const Icon = resolveIcon(icon) + if (Icon) return + } + return +} + function truncate(text: string, maxLen: number): string { const clean = text.replace(/\s+/g, ' ').trim() if (clean.length <= maxLen) return clean @@ -87,6 +106,7 @@ function TrackBlockView({ node, deleteNode, extension }: { data-type="track-block" data-trigger={triggerType} data-active={active ? 'true' : 'false'} + data-trackid={trackId} > + )} + +
+ )} + + {hasDraft && ( +
+
+ Reply + {config.from && {config.from}} +
+