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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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); + } +}