From 0d71ad33f5acb5e16fbdb89a2f7f13b86cd7ab0b Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:27:13 +0530 Subject: [PATCH 001/143] improve track skill re: yaml strings --- .../assistant/skills/tracks/skill.ts | 80 +++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) 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 781c9a03..f4f13f30 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 @@ -19,7 +19,8 @@ A track block is a scheduled, agent-run block embedded directly inside a markdow ` + "```" + `track trackId: chicago-time -instruction: Show the current time in Chicago, IL in 12-hour format. +instruction: | + Show the current time in Chicago, IL in 12-hour format. active: true schedule: type: cron @@ -79,6 +80,67 @@ Good: Bad: > Tell me about Chicago. +## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `) + +The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated. + +Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that. + +### The rule: always use a safe scalar style + +**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines. + +### Preferred: literal block scalar (` + "`" + `|` + "`" + `) + +` + "```" + `yaml +instruction: | + Show a side-by-side world clock for India, Chicago, and Indianapolis. + Return a compact markdown table with columns for location, current local + time, and relative offset vs India. Format with the same polished UI + style as before: clean, compact, visually pleasant, and table-first. +eventMatchCriteria: | + Emails from the finance team about Q3 budget or OKRs. +` + "```" + ` + +- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed. +- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs. +- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line. +- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them. +- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `). + +### Acceptable alternative: double-quoted on a single line + +Fine for short single-sentence fields with no newline needs: + +` + "```" + `yaml +instruction: "Show the current time in Chicago, IL in 12-hour format." +eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions." +` + "```" + ` + +- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `. +- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline. + +### Single-quoted on a single line (only if double-quoted would require heavy escaping) + +` + "```" + `yaml +instruction: 'He said "hi" at 9:00.' +` + "```" + ` + +- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `. +- No other escape sequences work. + +### Do NOT use plain (unquoted) scalars for these two fields + +Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not. + +### Editing an existing track + +If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt. + +### Never-hand-write fields + +` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged. + ## Schedules Schedule is an **optional** discriminated union. Three types: @@ -132,9 +194,12 @@ In addition to manual and scheduled, a track can be triggered by **events** — ` + "```" + `track trackId: q3-planning-emails -instruction: Maintain a running summary of decisions and open questions about Q3 planning, drawn from emails on the topic. +instruction: | + Maintain a running summary of decisions and open questions about Q3 + planning, drawn from emails on the topic. active: true -eventMatchCriteria: Emails about Q3 planning, roadmap decisions, or quarterly OKRs +eventMatchCriteria: | + Emails about Q3 planning, roadmap decisions, or quarterly OKRs. ` + "```" + ` How it works: @@ -201,7 +266,8 @@ Write it verbatim like this (including the blank line between fence and target): ` + "```" + `track trackId: -instruction: +instruction: | + active: true schedule: type: cron @@ -214,6 +280,7 @@ schedule: **Rules:** - One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `` + "`" + `. - Target pair is **empty on creation**. The runner fills it on the first run. +- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why. - **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `. - Use 2-space YAML indent. No tabs. - Top-level markdown only — never inside a code fence, blockquote, or table. @@ -317,7 +384,8 @@ Minimal template: ` + "```" + `track trackId: -instruction: +instruction: | + active: true schedule: type: cron @@ -328,6 +396,8 @@ schedule: Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m). + +YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing. `; export default skill; From 8e0a3e2991c771611bd8ad3808bd578d640be4dc Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:43:27 +0530 Subject: [PATCH 002/143] render tables in markdown --- apps/x/apps/renderer/package.json | 1 + .../src/components/markdown-editor.tsx | 18 ++++++++ apps/x/apps/renderer/src/styles/editor.css | 42 +++++++++++++++++++ apps/x/pnpm-lock.yaml | 14 +++++++ 4 files changed, 75 insertions(+) diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 4bb837c9..a8c67a43 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -28,6 +28,7 @@ "@tiptap/extension-image": "^3.16.0", "@tiptap/extension-link": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", + "@tiptap/extension-table": "^3.22.4", "@tiptap/extension-task-item": "^3.15.3", "@tiptap/extension-task-list": "^3.15.3", "@tiptap/pm": "^3.15.3", diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 49915dc0..f106c0a5 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -7,6 +7,8 @@ import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' +import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table' +import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' import { TrackBlockExtension } from '@/extensions/track-block' @@ -149,6 +151,17 @@ function serializeList(listNode: JsonNode, indent: number): string[] { return lines } +// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is +// actually invoked — the other helpers are stubs to satisfy the type. +const tableRenderHelpers: MarkdownRendererHelpers = { + renderChildren: (nodes) => { + const arr = Array.isArray(nodes) ? nodes : [nodes] + return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('') + }, + wrapInBlock: (prefix, content) => prefix + content, + indent: (content) => content, +} + // Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker // paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown. function blockToMarkdown(node: JsonNode): string { @@ -192,6 +205,8 @@ function blockToMarkdown(node: JsonNode): string { return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```' case 'mermaidBlock': return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```' + case 'table': + return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim() case 'codeBlock': { const lang = (node.attrs?.language as string) || '' return '```' + lang + '\n' + nodeToText(node) + '\n```' @@ -697,6 +712,9 @@ export const MarkdownEditor = forwardRef Date: Mon, 20 Apr 2026 14:30:50 +0530 Subject: [PATCH 003/143] improve track run prompts --- .../assistant/skills/tracks/skill.ts | 67 ++++++-- .../core/src/knowledge/track/run-agent.ts | 146 ++++++++++++++---- 2 files changed, 177 insertions(+), 36 deletions(-) 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 f4f13f30..cc03a34a 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 @@ -9,6 +9,14 @@ export const skill = String.raw` You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor. +## First: Just Do It — Do Not Ask About Edit Mode + +Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks. + +- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed. +- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit. +- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact. + ## What Is a Track Block A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has: @@ -69,16 +77,55 @@ ${schemaYaml} ## Writing a Good Instruction +### The Frame: This Is a Personal Knowledge Tracker + +Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration. + +### Core Rules + - **Specific and actionable.** State exactly what to fetch or compute. - **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle. - **Imperative voice, 1-3 sentences.** -- **Mention output style** if it matters ("markdown bullet list", "one sentence", "table with 5 rows"). +- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `°F, ` + "`" + `", "3-column markdown table", "bulleted digest of 5 items". -Good: -> Fetch the current temperature, feels-like, and conditions for Chicago, IL in Fahrenheit. Return as a single line: "72°F (feels like 70°F), partly cloudy". +### Self-Sufficiency (critical) -Bad: -> Tell me about Chicago. +The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone. + +**Never use phrases that depend on prior conversation or prior runs:** +- "as before", "same style as before", "like last time" +- "keep the format we discussed", "matching the previous output" +- "continue from where you left off" (without stating the state) + +If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction — not this chat, not what you produced last time. + +### Output Patterns — Match the Data + +Pick a shape that fits what the user is tracking. Four common patterns: + +**1. Single metric / status line.** +- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: (as of )` + "`" + `." +- Bad: "Give me a nice update about the dollar rate." + +**2. Compact table.** +- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose." +- Bad: "Show a polished, table-first world clock with a pleasant layout." + +**3. Rolling digest.** +- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- (<points> pts, <comments> comments)` + "`" + `. No commentary." +- Bad: "Give me the top HN stories with thoughtful takeaways." + +**4. Status / threshold watch.** +- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component." +- Bad: "Keep an eye on the status page and tell me how it looks." + +### Anti-Patterns + +- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete. +- **References to past state** without a mechanism to access it ("as before", "same as last time"). +- **Bundling multiple purposes** into one instruction — split into separate track blocks. +- **Open-ended prose requests** ("tell me about X", "give me thoughts on X"). +- **Output-shape words without a concrete shape** ("dashboard-like", "report-style"). ## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `) @@ -94,10 +141,10 @@ Real failure seen in the wild — an instruction containing the phrase ` + "`" + ` + "```" + `yaml instruction: | - Show a side-by-side world clock for India, Chicago, and Indianapolis. - Return a compact markdown table with columns for location, current local - time, and relative offset vs India. Format with the same polished UI - style as before: clean, compact, visually pleasant, and table-first. + Show current local time for India, Chicago, and Indianapolis as a + 3-column markdown table: Location | Local Time | Offset vs India. + One row per location, 24-hour time (HH:MM), no extra prose. + Note: when a location is in DST, reflect that in the offset column. eventMatchCriteria: | Emails from the finance team about Q3 budget or OKRs. ` + "```" + ` @@ -220,6 +267,8 @@ Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events en ## Insertion Workflow +**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode. + ### Cmd+K with cursor context When the user invokes Cmd+K, the context includes an attachment mention like: diff --git a/apps/x/packages/core/src/knowledge/track/run-agent.ts b/apps/x/packages/core/src/knowledge/track/run-agent.ts index 9edc7c4f..1e0a986d 100644 --- a/apps/x/packages/core/src/knowledge/track/run-agent.ts +++ b/apps/x/packages/core/src/knowledge/track/run-agent.ts @@ -3,50 +3,142 @@ import { Agent, ToolAttachment } from '@x/shared/dist/agent.js'; import { BuiltinTools } from '../../application/lib/builtin-tools.js'; import { WorkDir } from '../../config/config.js'; -const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that updates a specific section of a knowledge note. +const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date. -You will receive a message containing a track instruction, the current content of the target region, and optionally some context. Your job is to follow the instruction and produce updated content. +Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others — optimize for **information density and scannability**, not conversational prose. # Background Mode -You are running as a background task — there is no user present. -- Do NOT ask clarifying questions — make reasonable assumptions -- Be concise and action-oriented — just do the work +You are running as a scheduled or event-triggered background task — **there is no user present** to clarify, approve, or watch. +- Do NOT ask clarifying questions — make the most reasonable interpretation of the instruction and proceed. +- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work. +- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line. + +# Message Anatomy + +Every run message has this shape: + + Update track **<trackId>** in \`<filePath>\`. + + **Time:** <localized datetime> (<timezone>) + + **Instruction:** + <the user-authored track instruction — usually 1-3 sentences describing what to produce> + + **Current content:** + <the existing contents of the target region, or "(empty — first run)"> + + Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`. + +For **manual** runs, an optional trailing block may appear: + + **Context:** + <extra one-run-only guidance — a backfill hint, a focus window, extra data> + +Apply context for this run only — it is not a permanent edit to the instruction. + +For **event-triggered** runs, a trailing block appears instead: + + **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant) + **Event match criteria for this track:** <from the track's YAML> + **Event payload:** <the event body — e.g., an email> + **Decision:** ... skip if not relevant ... + +On event runs you are the Pass 2 judge — see "The No-Update Decision" below. + +# What Good Output Looks Like + +This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information. + +- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs. +- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative — do not improvise a different layout. +- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks. +- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know. +- **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately. + +If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest. + +# Interpreting the Instruction + +The instruction was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect: +- "Top 5" is a target — fewer is acceptable if that's all that exists. +- "Current" means as of now (use the **Time** block). +- Unspecified units → standard for the domain (USD for US markets, metric for scientific, the user's locale if inferable from the timezone). +- Unspecified sources → your best reliable source (web-search for public data, workspace for user data). + +Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") — these are decoration. + +# Current Content Handling + +The **Current content** block shows what lives in the target region right now. Three cases: + +1. **"(empty — first run)"** — produce the content from scratch. +2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep. +3. **Content that does NOT match the instruction's format** — the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch. + +You always write a **complete** replacement, not a diff. + +# The No-Update Decision + +You may finish a run without calling \`update-track-content\`. Two legitimate cases: + +1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update. +2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically. + +When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*. + +# Writing the Result + +Call \`update-track-content\` **at most once per run**: +- Pass \`filePath\` and \`trackId\` exactly as given in the message. +- Pass the **complete** new content as \`content\` — the entire replacement for the target region. +- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those. +- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only. + +# Tools + +You have the full workspace toolkit. Quick reference for common cases: + +- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace. +- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data. +- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files. +- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized. +- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering. # The Knowledge Graph -The knowledge graph is stored as plain markdown in \`${WorkDir}/knowledge/\` (inside the workspace). It's organized into: -- **People/** — Notes on individuals -- **Organizations/** — Notes on companies -- **Projects/** — Notes on initiatives -- **Topics/** — Notes on recurring themes +The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organized into: +- **People/** — individuals +- **Organizations/** — companies +- **Projects/** — initiatives +- **Topics/** — recurring themes -Use workspace tools to search and read the knowledge graph for context. +Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when an instruction references emails, meetings, or calendar events. -# How to Access the Knowledge Graph - -**CRITICAL:** Always include \`knowledge/\` in paths. +**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root. - \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\` - \`workspace-readFile("knowledge/People/Sarah Chen.md")\` -- \`workspace-readdir("knowledge/People")\` +- \`workspace-readdir("gmail_sync/")\` -**NEVER** use an empty path or root path. +# Failure & Fallback -# How to Write Your Result +If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration): +- Do **not** fabricate or speculate. +- Do **not** write partial or placeholder content into the target region — leave existing content intact by not calling \`update-track-content\`. +- Explain the failure in the summary line. -Use the \`update-track-content\` tool to write your result. The message will tell you the file path and track ID. +# Final Summary -- Produce the COMPLETE replacement content (not a diff) -- Preserve existing content that's still relevant -- Write in a clear, concise style appropriate for personal notes +End your response with **one line** (1-2 short sentences). The system stores this as \`lastRunSummary\` and surfaces it in the UI. -# Web Search +State the action and the substance. Good examples: +- "Updated — 3 new HN stories, top is 'Show HN: …' at 842 pts." +- "Updated — USD/INR 83.42 as of 14:05 IST." +- "No change — status page shows all operational." +- "Skipped — event was a calendar invite unrelated to Q3 planning." +- "Failed — web-search returned no results for the query." -You have access to \`web-search\` for tracks that need external information (news, trends, current events). Use it when the track instruction requires information beyond the knowledge graph. - -# After You're Done - -End your response with a brief summary of what you did (1-2 sentences). +Avoid: "I updated the track.", "Done!", "Here is the update:". The summary is a data point, not a sign-off. `; export function buildTrackRunAgent(): z.infer<typeof Agent> { From 5d65616cfbad18c301f8dd656c5ecf309411d561 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:42:13 +0530 Subject: [PATCH 004/143] add prompt block --- apps/x/apps/renderer/src/App.tsx | 21 +++ .../src/components/markdown-editor.tsx | 2 + .../renderer/src/extensions/prompt-block.tsx | 145 ++++++++++++++++++ apps/x/packages/shared/src/index.ts | 1 + apps/x/packages/shared/src/prompt-block.ts | 8 + 5 files changed, 177 insertions(+) create mode 100644 apps/x/apps/renderer/src/extensions/prompt-block.tsx create mode 100644 apps/x/packages/shared/src/prompt-block.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 96b409b9..f933b604 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2791,6 +2791,27 @@ function App() { return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener) }, [submitFromPalette]) + // Listener for prompt-block "Run" events + // (dispatched by apps/renderer/src/extensions/prompt-block.tsx) + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent<{ + instruction?: string + filePath?: string + label?: string + }> + const instruction = ev.detail?.instruction + const filePath = ev.detail?.filePath + if (!instruction) return + const mention = filePath + ? { path: filePath, displayName: filePath.split('/').pop() ?? filePath } + : null + submitFromPalette(instruction, mention) + } + window.addEventListener('rowboat:open-copilot-prompt', handler as EventListener) + return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener) + }, [submitFromPalette]) + const toggleKnowledgePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(prev => !prev) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index f106c0a5..e997141d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -12,6 +12,7 @@ import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' import { TrackBlockExtension } from '@/extensions/track-block' +import { PromptBlockExtension } from '@/extensions/prompt-block' import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' @@ -690,6 +691,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro ImageUploadPlaceholderExtension, TaskBlockExtension, TrackBlockExtension.configure({ notePath }), + PromptBlockExtension.configure({ notePath }), TrackTargetOpenExtension, TrackTargetCloseExtension, ImageBlockExtension, diff --git a/apps/x/apps/renderer/src/extensions/prompt-block.tsx b/apps/x/apps/renderer/src/extensions/prompt-block.tsx new file mode 100644 index 00000000..39e70e23 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/prompt-block.tsx @@ -0,0 +1,145 @@ +import { z } from 'zod' +import { useMemo } from 'react' +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { Sparkles } from 'lucide-react' +import { parse as parseYaml } from 'yaml' +import { PromptBlockSchema } from '@x/shared/dist/prompt-block.js' +import { Button } from '@/components/ui/button' + +function truncate(text: string, maxLen: number): string { + const clean = text.replace(/\s+/g, ' ').trim() + if (clean.length <= maxLen) return clean + return clean.slice(0, maxLen).trimEnd() + '…' +} + +function PromptBlockView({ node, extension }: { + node: { attrs: Record<string, unknown> } + extension: { options: { notePath?: string } } +}) { + const raw = node.attrs.data as string + + const prompt = useMemo<z.infer<typeof PromptBlockSchema> | null>(() => { + try { + return PromptBlockSchema.parse(parseYaml(raw)) + } catch { return null } + }, [raw]) + + const notePath = extension.options.notePath + + const handleRun = (e: React.MouseEvent) => { + e.stopPropagation() + if (!prompt) return + window.dispatchEvent(new CustomEvent('rowboat:open-copilot-prompt', { + detail: { + instruction: prompt.instruction, + label: prompt.label, + filePath: notePath, + }, + })) + } + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleRun(e as unknown as React.MouseEvent) + } + } + + if (!prompt) { + return ( + <NodeViewWrapper data-type="prompt-block"> + <div className="my-2 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"> + Invalid prompt block — expected YAML with <code>label</code> and <code>instruction</code>. + </div> + </NodeViewWrapper> + ) + } + + return ( + <NodeViewWrapper data-type="prompt-block"> + <div + role="button" + tabIndex={0} + onClick={handleRun} + onKeyDown={handleKey} + onMouseDown={(e) => e.stopPropagation()} + title={prompt.instruction} + className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2" + > + <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted"> + <Sparkles className="h-5 w-5 text-muted-foreground" /> + </div> + <div className="flex-1 min-w-0"> + <div className="truncate text-sm font-medium">{prompt.label}</div> + <div className="truncate text-xs text-muted-foreground">{truncate(prompt.instruction, 80)}</div> + </div> + <Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none"> + Run + </Button> + </div> + </NodeViewWrapper> + ) +} + +export const PromptBlockExtension = Node.create({ + name: 'promptBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + notePath: undefined as string | undefined, + } + }, + + addAttributes() { + return { + data: { + default: '', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'pre', + priority: 60, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + const cls = code.className || '' + if (cls.includes('language-prompt')) { + return { data: code.textContent || '' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prompt-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(PromptBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```prompt\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 5bdc49fd..cde673b8 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -10,6 +10,7 @@ export * as serviceEvents from './service-events.js' export * as inlineTask from './inline-task.js'; export * as blocks from './blocks.js'; export * as trackBlock from './track-block.js'; +export * as promptBlock from './prompt-block.js'; export * as frontmatter from './frontmatter.js'; export * as bases from './bases.js'; export * as browserControl from './browser-control.js'; diff --git a/apps/x/packages/shared/src/prompt-block.ts b/apps/x/packages/shared/src/prompt-block.ts new file mode 100644 index 00000000..a11d1af5 --- /dev/null +++ b/apps/x/packages/shared/src/prompt-block.ts @@ -0,0 +1,8 @@ +import z from 'zod'; + +export const PromptBlockSchema = z.object({ + label: z.string().min(1).describe('Short title shown on the card'), + instruction: z.string().min(1).describe('Full prompt sent to Copilot when Run is clicked'), +}); + +export type PromptBlock = z.infer<typeof PromptBlockSchema>; From 56edc5a7308c73d1b52b4e7e1c032b59ac8fe786 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:22:31 +0530 Subject: [PATCH 005/143] clean up invisible chars in yaml parse --- apps/x/apps/renderer/src/extensions/track-block.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/extensions/track-block.tsx b/apps/x/apps/renderer/src/extensions/track-block.tsx index c0704906..a87decc8 100644 --- a/apps/x/apps/renderer/src/extensions/track-block.tsx +++ b/apps/x/apps/renderer/src/extensions/track-block.tsx @@ -36,11 +36,12 @@ function TrackBlockView({ node, deleteNode, extension }: { extension: { options: { notePath?: string } } }) { const raw = node.attrs.data as string + const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, ""); const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => { try { - return TrackBlockSchema.parse(parseYaml(raw)) - } catch { return null } + return TrackBlockSchema.parse(parseYaml(cleaned)) + } catch(error) { console.error('error', error); return null } }, [raw]) as z.infer<typeof TrackBlockSchema> | null; const trackId = track?.trackId ?? '' From 1306b7f442ff7be29d262d2b920786de92435bfb Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:22:53 +0530 Subject: [PATCH 006/143] improve prompting around output blocks --- .../assistant/skills/tracks/skill.ts | 25 ++- .../core/src/knowledge/track/run-agent.ts | 161 +++++++++++++++++- 2 files changed, 184 insertions(+), 2 deletions(-) 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 cc03a34a..ff345acf 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 @@ -4,6 +4,27 @@ import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd(); +const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.** + +The track agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown: + +- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."* +- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."* +- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."* +- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."* +- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."* +- \`image\` — single image with caption. *"Render as an \`image\` block."* +- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."* +- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."* +- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."* +- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."* + +You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output. + +- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`." +- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate." +- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`; + export const skill = String.raw` # Tracks Skill @@ -101,7 +122,7 @@ If you want consistent style across runs, **describe the style inline** (e.g. "a ### Output Patterns — Match the Data -Pick a shape that fits what the user is tracking. Four common patterns: +Pick a shape that fits what the user is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block: **1. Single metric / status line.** - Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `." @@ -119,6 +140,8 @@ Pick a shape that fits what the user is tracking. Four common patterns: - Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component." - Bad: "Keep an eye on the status page and tell me how it looks." +${richBlockMenu} + ### Anti-Patterns - **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete. diff --git a/apps/x/packages/core/src/knowledge/track/run-agent.ts b/apps/x/packages/core/src/knowledge/track/run-agent.ts index 1e0a986d..d93366f3 100644 --- a/apps/x/packages/core/src/knowledge/track/run-agent.ts +++ b/apps/x/packages/core/src/knowledge/track/run-agent.ts @@ -56,7 +56,166 @@ This is a personal knowledge tracker. The user scans many such blocks across the - **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know. - **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately. -If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest. +If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events → \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.). + +# Output Block Types + +The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card. + +Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs. + +## \`table\` — tabular data (JSON) + +Use for: scoreboards, leaderboards, comparisons, multi-row status digests. + +\`\`\`table +{ + "title": "Top stories on Hacker News", + "columns": ["Rank", "Title", "Points", "Comments"], + "data": [ + {"Rank": 1, "Title": "Show HN: ...", "Points": 842, "Comments": 312}, + {"Rank": 2, "Title": "...", "Points": 530, "Comments": 144} + ] +} +\`\`\` + +Required: \`columns\` (string[]), \`data\` (array of objects keyed by column name). Optional: \`title\`. + +## \`chart\` — line / bar / pie chart (JSON) + +Use for: time series, categorical breakdowns, share-of-total. Skip if a single sentence carries the meaning. + +\`\`\`chart +{ + "chart": "line", + "title": "USD/INR — last 7 days", + "x": "date", + "y": "rate", + "data": [ + {"date": "2026-04-13", "rate": 83.41}, + {"date": "2026-04-14", "rate": 83.38} + ] +} +\`\`\` + +Required: \`chart\` ("line" | "bar" | "pie"), \`x\` (field name on each row), \`y\` (field name on each row), and **either** \`data\` (inline array of objects) **or** \`source\` (workspace path to a JSON-array file). Optional: \`title\`. + +## \`mermaid\` — diagrams (raw Mermaid source) + +Use for: relationship maps, flowcharts, sequence diagrams, gantt charts, mind maps. + +\`\`\`mermaid +graph LR + A[Project Alpha] --> B[Sarah Chen] + A --> C[Acme Corp] + B --> D[Q3 Launch] +\`\`\` + +Body is plain Mermaid source — no JSON wrapper. + +## \`calendar\` — list of events (JSON) + +Use for: upcoming meetings, agenda digests, day/week views. + +\`\`\`calendar +{ + "title": "Today", + "events": [ + { + "summary": "1:1 with Sarah", + "start": {"dateTime": "2026-04-20T10:00:00-07:00"}, + "end": {"dateTime": "2026-04-20T10:30:00-07:00"}, + "location": "Zoom", + "conferenceLink": "https://zoom.us/j/..." + } + ] +} +\`\`\` + +Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\`end\` (object with \`dateTime\` ISO string OR \`date\` "YYYY-MM-DD" for all-day), \`location\`, \`htmlLink\`, \`conferenceLink\`, \`source\`. Optional top-level: \`title\`, \`showJoinButton\` (bool). + +## \`email\` — single email or thread digest (JSON) + +Use for: surfacing one important thread — latest message body, summary of prior context, optional draft reply. + +\`\`\`email +{ + "subject": "Q3 launch readiness", + "from": "sarah@acme.com", + "date": "2026-04-19T16:42:00Z", + "summary": "Sarah confirms timeline; flagged blocker on infra capacity.", + "latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah" +} +\`\`\` + +Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both"). + +For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time. + +## \`image\` — single image (JSON) + +Use for: charts, screenshots, photos you have a URL or workspace path for. + +\`\`\`image +{ + "src": "https://example.com/forecast.png", + "alt": "Weather forecast", + "caption": "Bay Area · April 20" +} +\`\`\` + +Required: \`src\` (URL or workspace path). Optional: \`alt\`, \`caption\`. + +## \`embed\` — YouTube / Figma embed (JSON) + +Use for: linking to a video or design that should render inline. + +\`\`\`embed +{ + "provider": "youtube", + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "caption": "Latest demo" +} +\`\`\` + +Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form. + +## \`iframe\` — arbitrary embedded webpage (JSON) + +Use for: live dashboards, status pages, trackers — anything that has its own webpage and benefits from being live, not snapshotted. + +\`\`\`iframe +{ + "url": "https://status.example.com", + "title": "Service status", + "height": 600 +} +\`\`\` + +Required: \`url\` (must be \`https://\` or \`http://localhost\`). Optional: \`title\`, \`caption\`, \`height\` (240–1600), \`allow\` (Permissions-Policy string). + +## \`transcript\` — long transcript (JSON) + +Use for: meeting transcripts, voice-note dumps — bodies that benefit from a collapsible UI. + +\`\`\`transcript +{"transcript": "[00:00] Speaker A: Welcome everyone..."} +\`\`\` + +Required: \`transcript\` (string). + +## \`prompt\` — starter Copilot prompt (YAML) + +Use for: end-of-output "next step" cards. The user clicks **Run** and the chat sidebar opens with the underlying instruction submitted to Copilot, with this note attached as a file mention. + +\`\`\`prompt +label: Draft replies to today's emails +instruction: | + For each unanswered email in the digest above, draft a 2-line reply + in my voice and present them as a checklist for me to approve. +\`\`\` + +Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON. # Interpreting the Instruction From e8a7cd59c10603391c1dffc3f1f41c41573f0722 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:19:21 +0530 Subject: [PATCH 007/143] fix \n repitition in markdown editor --- .../renderer/src/components/markdown-editor.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index e997141d..a51c6e1a 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -58,17 +58,22 @@ function preprocessMarkdown(markdown: string): string { // line until a blank line terminates it, and markdown inline rules (bold, // italics, links) don't apply inside the block. Without surrounding blank // lines, the line right after our placeholder div gets absorbed as HTML and -// its markdown is not parsed. We consume any adjacent newlines in the match -// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on -// its own line. +// its markdown is not parsed. +// +// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n` +// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks +// on save; a `\n?` regex on reload would only consume one of those two +// newlines, so every cycle would add a net newline on each side of every +// marker — causing tracks running on an open note to steadily inflate the +// file with blank lines around target regions. function preprocessTrackTargets(md: string): string { return md .replace( - /\n?<!--track-target:([^\s>]+)-->\n?/g, + /\n*<!--track-target:([^\s>]+)-->\n*/g, (_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`, ) .replace( - /\n?<!--\/track-target:([^\s>]+)-->\n?/g, + /\n*<!--\/track-target:([^\s>]+)-->\n*/g, (_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`, ) } From 4c46bf4c251e5998d772d2787daf4f12ae88751e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:19:36 +0530 Subject: [PATCH 008/143] serialise prompt blocks to markdown --- apps/x/apps/renderer/src/components/markdown-editor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index a51c6e1a..e97f7c6e 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -187,6 +187,8 @@ function blockToMarkdown(node: JsonNode): string { return serializeList(node, 0).join('\n') case 'taskBlock': return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' + case 'promptBlock': + return '```prompt\n' + (node.attrs?.data as string || '') + '\n```' case 'trackBlock': return '```track\n' + (node.attrs?.data as string || '') + '\n```' case 'trackTargetOpen': From 93054066fa8f4550ff58e62a13bd622771d77a9c Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:20:21 +0530 Subject: [PATCH 009/143] add suggested topics using track blocks --- apps/x/packages/core/src/config/config.ts | 3 + .../knowledge/ensure_suggested_topics_note.ts | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index 3d320172..ad2766f0 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -53,6 +53,9 @@ ensureDefaultConfigs(); import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => { console.error('[DailyNote] Failed to ensure daily note:', err); }); +import('../knowledge/ensure_suggested_topics_note.js').then(m => m.ensureSuggestedTopicsNote()).catch(err => { + console.error('[SuggestedTopicsNote] Failed to ensure note:', err); +}); // Initialize version history repo (async, fire-and-forget on startup) import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => { diff --git a/apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts b/apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts new file mode 100644 index 00000000..8813448b --- /dev/null +++ b/apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts @@ -0,0 +1,71 @@ +import path from 'path'; +import fs from 'fs'; +import { stringify as stringifyYaml } from 'yaml'; +import { WorkDir } from '../config/config.js'; + +const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); +const NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Suggested Topics.md'); +const TRACK_ID = 'suggested-topics'; + +const INSTRUCTION = `Curate a shortlist of 8-12 items the user should consider exploring next: topics, projects, people, or organizations surfaced by recent activity. + +Read source material via workspace tools: +- Recent emails under \`gmail_sync/\` +- Meeting transcripts under \`knowledge/Meetings/\` (fireflies, granola) + +Prefer strategically relevant, near-term, actionable items. Skip transactional or low-signal noise. Skip entities that already have a canonical note under \`knowledge/People/\`, \`knowledge/Organizations/\`, \`knowledge/Topics/\`, or \`knowledge/Projects/\` unless there is a distinct new angle worth exploring. + +## Output format + +Write the target region as category-grouped stacks of fenced \`prompt\` code blocks. Omit empty categories. Use these headings in this order: + + ## Topics + ## Projects + ## People + ## Organizations + +Under each heading, emit one \`prompt\`-language fenced code block per item. Each block is YAML with two fields: + +- \`label\`: concise card title (~60 chars). +- \`instruction\`: multi-line block scalar (\`|\`). The full prompt Copilot runs when the user clicks Run. + +Write each \`instruction\` in first-person voice, asking Copilot to set up a track block for that suggestion. Include: +- Which item to track and its category (topic / project / person / organization). +- A 1-2 sentence hook explaining why it matters now. +- Target folder: \`knowledge/<Topics|Projects|People|Organizations>/\`. +- Ask Copilot to describe what the tracking note would monitor and confirm before creating or modifying anything. +- On confirmation, load the \`tracks\` skill, check whether a matching note already exists in the target folder, and update it with an appropriate track block. If none exists, create a new note there with a suitable filename. Use a track block rather than only static content. + +Rules: 8-12 cards total across categories. Be selective. Prefer freshness and near-term leverage.`; + +function buildNoteContent(): string { + const trackYaml = stringifyYaml({ + trackId: TRACK_ID, + active: true, + instruction: INSTRUCTION, + schedule: { + type: 'cron', + expression: '0 */4 * * *', + }, + }).trimEnd(); + + return [ + '# Suggested Topics', + '', + '> Auto-curated list of topics, people, organizations, and projects worth exploring next. Refreshes every 4 hours. Click any card to explore it with Copilot.', + '', + '```track', + trackYaml, + '```', + '', + `<!--track-target:${TRACK_ID}-->`, + `<!--/track-target:${TRACK_ID}-->`, + '', + ].join('\n'); +} + +export function ensureSuggestedTopicsNote(): void { + if (fs.existsSync(NOTE_PATH)) return; + fs.writeFileSync(NOTE_PATH, buildNoteContent(), 'utf-8'); + console.log('[SuggestedTopicsNote] Created Suggested Topics.md'); +} From dc3e25c98bd67517e5d14bad22f55263b0d06b06 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:20:30 +0530 Subject: [PATCH 010/143] add today.md using track blocks --- .../core/src/knowledge/ensure_daily_note.ts | 185 ++++++++++++++---- 1 file changed, 152 insertions(+), 33 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 55383a6b..6daff8ec 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -1,44 +1,163 @@ import path from 'path'; import fs from 'fs'; +import { stringify as stringifyYaml } from 'yaml'; +import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; import { WorkDir } from '../config/config.js'; const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md'); -const TARGET_ID = 'dailybrief'; + +interface Section { + heading: string; + track: unknown; +} + +const SECTIONS: Section[] = [ + { + heading: '## ⏱ Up Next', + track: { + trackId: 'up-next', + 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. + +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.") + +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. + +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. + +If nothing remains today, output exactly: Clear for the rest of the day. + +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 * * * *', + }, + }, + }, + { + heading: '## 📅 Calendar', + track: { + trackId: 'calendar', + instruction: +`Emit today's meetings as a calendar block titled "Today's Meetings". + +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. + +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 * * * *', + }, + }, + }, + { + heading: '## 📧 Emails', + track: { + trackId: 'emails', + 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. + +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. + +Each email block should include threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add a draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response. + +If there is genuinely nothing to surface, output the single line: No new emails. + +Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`, + eventMatchCriteria: +`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`, + active: true, + }, + }, + { + heading: '## 📰 What You Missed', + track: { + trackId: 'what-you-missed', + instruction: +`Short markdown summary of what happened yesterday that matters this morning. + +Data sources: +- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md — use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments. +- gmail_sync/ — skim for threads from yesterday that went unresolved or still need a reply. + +Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them. + +Write concise markdown — a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today. + +If nothing notable happened, output exactly: Quiet day yesterday — nothing to flag. + +Do NOT manufacture content to fill the section.`, + active: true, + schedule: { + type: 'cron', + expression: '0 7 * * *', + }, + }, + }, + { + heading: '## ✅ Today\'s Priorities', + track: { + trackId: 'priorities', + instruction: +`Ranked markdown list of the real, actionable items the user should focus on today. + +Data sources: +- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ — action items assigned to the user are often the most important source. +- knowledge/ — use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups. +- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section — useful for alignment. + +Rules: +- Do NOT list calendar events as tasks — they're already in the Calendar section. +- Do NOT list trivial admin (filing small invoices, archiving spam). +- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review"). +- Add a brief reason for each item when it's not self-evident. + +If nothing genuinely needs attention, output exactly: No pressing tasks today — good day to make progress on bigger items. + +Do NOT invent busywork.`, + active: true, + schedule: { + type: 'cron', + expression: '30 7 * * *', + }, + }, + }, +]; function buildDailyNoteContent(): string { - const now = new Date(); - const startDate = now.toISOString(); - const endDate = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString(); - - const instruction = 'Create a daily brief for me'; - - const taskBlock = JSON.stringify({ - instruction, - schedule: { - type: 'cron', - expression: '*/15 * * * *', - startDate, - endDate, - }, - 'schedule-label': 'runs every 15 minutes', - targetId: TARGET_ID, - }); - - return [ - '---', - 'live_note: true', - '---', - '# Today', - '', - '```task', - taskBlock, - '```', - '', - `<!--task-target:${TARGET_ID}-->`, - `<!--/task-target:${TARGET_ID}-->`, - '', - ].join('\n'); + const parts: string[] = ['# Today', '']; + for (const { heading, track } of SECTIONS) { + const parsed = TrackBlockSchema.parse(track); + const yaml = stringifyYaml(parsed, { lineWidth: 0, blockQuote: 'literal' }).trimEnd(); + parts.push( + heading, + '', + '```track', + yaml, + '```', + '', + `<!--track-target:${parsed.trackId}-->`, + `<!--/track-target:${parsed.trackId}-->`, + '', + ); + } + return parts.join('\n'); } export function ensureDailyNote(): void { From a80ef4d320a64b781217b9047f2ffb0116b9a866 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:21:58 +0530 Subject: [PATCH 011/143] Revert "add suggested topics using track blocks" This reverts commit 93054066fa8f4550ff58e62a13bd622771d77a9c. --- apps/x/packages/core/src/config/config.ts | 3 - .../knowledge/ensure_suggested_topics_note.ts | 71 ------------------- 2 files changed, 74 deletions(-) delete mode 100644 apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index ad2766f0..3d320172 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -53,9 +53,6 @@ ensureDefaultConfigs(); import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => { console.error('[DailyNote] Failed to ensure daily note:', err); }); -import('../knowledge/ensure_suggested_topics_note.js').then(m => m.ensureSuggestedTopicsNote()).catch(err => { - console.error('[SuggestedTopicsNote] Failed to ensure note:', err); -}); // Initialize version history repo (async, fire-and-forget on startup) import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => { diff --git a/apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts b/apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts deleted file mode 100644 index 8813448b..00000000 --- a/apps/x/packages/core/src/knowledge/ensure_suggested_topics_note.ts +++ /dev/null @@ -1,71 +0,0 @@ -import path from 'path'; -import fs from 'fs'; -import { stringify as stringifyYaml } from 'yaml'; -import { WorkDir } from '../config/config.js'; - -const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); -const NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Suggested Topics.md'); -const TRACK_ID = 'suggested-topics'; - -const INSTRUCTION = `Curate a shortlist of 8-12 items the user should consider exploring next: topics, projects, people, or organizations surfaced by recent activity. - -Read source material via workspace tools: -- Recent emails under \`gmail_sync/\` -- Meeting transcripts under \`knowledge/Meetings/\` (fireflies, granola) - -Prefer strategically relevant, near-term, actionable items. Skip transactional or low-signal noise. Skip entities that already have a canonical note under \`knowledge/People/\`, \`knowledge/Organizations/\`, \`knowledge/Topics/\`, or \`knowledge/Projects/\` unless there is a distinct new angle worth exploring. - -## Output format - -Write the target region as category-grouped stacks of fenced \`prompt\` code blocks. Omit empty categories. Use these headings in this order: - - ## Topics - ## Projects - ## People - ## Organizations - -Under each heading, emit one \`prompt\`-language fenced code block per item. Each block is YAML with two fields: - -- \`label\`: concise card title (~60 chars). -- \`instruction\`: multi-line block scalar (\`|\`). The full prompt Copilot runs when the user clicks Run. - -Write each \`instruction\` in first-person voice, asking Copilot to set up a track block for that suggestion. Include: -- Which item to track and its category (topic / project / person / organization). -- A 1-2 sentence hook explaining why it matters now. -- Target folder: \`knowledge/<Topics|Projects|People|Organizations>/\`. -- Ask Copilot to describe what the tracking note would monitor and confirm before creating or modifying anything. -- On confirmation, load the \`tracks\` skill, check whether a matching note already exists in the target folder, and update it with an appropriate track block. If none exists, create a new note there with a suitable filename. Use a track block rather than only static content. - -Rules: 8-12 cards total across categories. Be selective. Prefer freshness and near-term leverage.`; - -function buildNoteContent(): string { - const trackYaml = stringifyYaml({ - trackId: TRACK_ID, - active: true, - instruction: INSTRUCTION, - schedule: { - type: 'cron', - expression: '0 */4 * * *', - }, - }).trimEnd(); - - return [ - '# Suggested Topics', - '', - '> Auto-curated list of topics, people, organizations, and projects worth exploring next. Refreshes every 4 hours. Click any card to explore it with Copilot.', - '', - '```track', - trackYaml, - '```', - '', - `<!--track-target:${TRACK_ID}-->`, - `<!--/track-target:${TRACK_ID}-->`, - '', - ].join('\n'); -} - -export function ensureSuggestedTopicsNote(): void { - if (fs.existsSync(NOTE_PATH)) return; - fs.writeFileSync(NOTE_PATH, buildNoteContent(), 'utf-8'); - console.log('[SuggestedTopicsNote] Created Suggested Topics.md'); -} From a86f555cbbd94ea287b2df22452f2b59abb32088 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:13:40 +0530 Subject: [PATCH 012/143] refresh rowboat access token on every gateway request Wire a custom fetch into the OpenRouter gateway provider so each outbound request resolves a fresh access token, instead of baking one token into the provider at turn start. Add a 60s expiry margin and serialize concurrent refreshes behind a single in-flight promise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/x/packages/core/src/auth/oauth-client.ts | 7 ++-- apps/x/packages/core/src/auth/tokens.ts | 36 +++++++++++++------ apps/x/packages/core/src/models/gateway.ts | 11 ++++-- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/apps/x/packages/core/src/auth/oauth-client.ts b/apps/x/packages/core/src/auth/oauth-client.ts index ccabab19..045ab920 100644 --- a/apps/x/packages/core/src/auth/oauth-client.ts +++ b/apps/x/packages/core/src/auth/oauth-client.ts @@ -216,12 +216,15 @@ export async function refreshTokens( return tokens; } +const EXPIRY_MARGIN_SECONDS = 60; + /** - * Check if tokens are expired + * Check if tokens are expired. Treats tokens as expired EXPIRY_MARGIN_SECONDS + * before the real expiry to absorb clock skew and in-flight request latency. */ export function isTokenExpired(tokens: OAuthTokens): boolean { const now = Math.floor(Date.now() / 1000); - return tokens.expires_at <= now; + return tokens.expires_at <= now + EXPIRY_MARGIN_SECONDS; } /** diff --git a/apps/x/packages/core/src/auth/tokens.ts b/apps/x/packages/core/src/auth/tokens.ts index 8a30bf9f..fe3afe0f 100644 --- a/apps/x/packages/core/src/auth/tokens.ts +++ b/apps/x/packages/core/src/auth/tokens.ts @@ -3,18 +3,12 @@ import { IOAuthRepo } from './repo.js'; import { IClientRegistrationRepo } from './client-repo.js'; import { getProviderConfig } from './providers.js'; import * as oauthClient from './oauth-client.js'; +import { OAuthTokens } from './types.js'; -export async function getAccessToken(): Promise<string> { - const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); - const { tokens } = await oauthRepo.read('rowboat'); - if (!tokens) { - throw new Error('Not signed into Rowboat'); - } - - if (!oauthClient.isTokenExpired(tokens)) { - return tokens.access_token; - } +let refreshInFlight: Promise<OAuthTokens> | null = null; +async function performRefresh(tokens: OAuthTokens): Promise<OAuthTokens> { + console.log("Refreshing rowboat access token"); if (!tokens.refresh_token) { throw new Error('Rowboat token expired and no refresh token available. Please sign in again.'); } @@ -40,7 +34,29 @@ export async function getAccessToken(): Promise<string> { tokens.refresh_token, tokens.scopes, ); + + const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); await oauthRepo.upsert('rowboat', { tokens: refreshed }); + return refreshed; +} + +export async function getAccessToken(): Promise<string> { + const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); + const { tokens } = await oauthRepo.read('rowboat'); + if (!tokens) { + throw new Error('Not signed into Rowboat'); + } + + if (!oauthClient.isTokenExpired(tokens)) { + return tokens.access_token; + } + + if (!refreshInFlight) { + refreshInFlight = performRefresh(tokens).finally(() => { + refreshInFlight = null; + }); + } + const refreshed = await refreshInFlight; return refreshed.access_token; } diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts index a18a37f5..df9b413c 100644 --- a/apps/x/packages/core/src/models/gateway.ts +++ b/apps/x/packages/core/src/models/gateway.ts @@ -3,11 +3,18 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { getAccessToken } from '../auth/tokens.js'; import { API_URL } from '../config/env.js'; +const authedFetch: typeof fetch = async (input, init) => { + const token = await getAccessToken(); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + return fetch(input, { ...init, headers }); +}; + export async function getGatewayProvider(): Promise<ProviderV2> { - const accessToken = await getAccessToken(); return createOpenRouter({ baseURL: `${API_URL}/v1/llm`, - apiKey: accessToken, + apiKey: 'managed-by-rowboat', + fetch: authedFetch, }); } From fbbaeea1df002584d78e243d641026808ab059fc Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:06:09 +0530 Subject: [PATCH 013/143] refactor ensure-daily-note --- .../x/packages/core/src/knowledge/ensure_daily_note.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 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 6daff8ec..4a6872f4 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -3,13 +3,14 @@ import fs from 'fs'; import { stringify as stringifyYaml } from 'yaml'; import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; import { WorkDir } from '../config/config.js'; +import z from 'zod'; const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md'); interface Section { heading: string; - track: unknown; + track: z.infer<typeof TrackBlockSchema>; } const SECTIONS: Section[] = [ @@ -143,8 +144,7 @@ Do NOT invent busywork.`, function buildDailyNoteContent(): string { const parts: string[] = ['# Today', '']; for (const { heading, track } of SECTIONS) { - const parsed = TrackBlockSchema.parse(track); - const yaml = stringifyYaml(parsed, { lineWidth: 0, blockQuote: 'literal' }).trimEnd(); + const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd(); parts.push( heading, '', @@ -152,8 +152,8 @@ function buildDailyNoteContent(): string { yaml, '```', '', - `<!--track-target:${parsed.trackId}-->`, - `<!--/track-target:${parsed.trackId}-->`, + `<!--track-target:${track.trackId}-->`, + `<!--/track-target:${track.trackId}-->`, '', ); } From ae296c77235df3766ddc60314fca906d5d807c45 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:11:33 +0530 Subject: [PATCH 014/143] serialize knowledge file writes behind a per-path mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent track runs on the same note were corrupting the file. In a fresh workspace, four tracks fired on cron at 05:09:17Z (all failed on AI_LoadAPIKeyError, but each still wrote lastRunAt/lastRunId before the agent ran) and three more fired at 05:09:32Z. The resulting Today.md ended with stray fragments "\n>\nes-->\n-->" — tail pieces of <!--/track-target:priorities--> that a mis-aimed splice had truncated — and the priorities YAML lost its lastRunId entirely. Two compounding issues in knowledge/track/fileops.ts: 1. updateTrackBlock read the file twice: once via fetch() to resolve fenceStart/fenceEnd, and again via fs.readFile to get the bytes to splice. If another writer landed between the reads, the line indices from read #1 pointed into unrelated content in read #2, so the splice replaced the wrong range and left tag fragments behind. 2. None of the mutators (updateContent, updateTrackBlock, replaceTrackBlockYaml, deleteTrackBlock) held any lock, so concurrent read-modify-writes clobbered each other's updates. The missing lastRunId was exactly that: set by one run, overwritten by another run's stale snapshot. The fix: introduce withFileLock(absPath, fn) in knowledge/file-lock.ts, a per-path Promise-chain mutex modeled on the commitLock pattern in knowledge/version_history.ts. Callers append onto that file's chain and await — wait-queue semantics, FIFO, no timeout. The map self-cleans when a file's chain goes idle so it stays bounded across a long-running process. Wrap all four fileops mutators in it, and also wrap workspace.writeFile (which can touch the same files from the agent's tool surface and previously raced with fileops). Both callers key on the resolved absolute path so they share the same lock for the same file. Reads (fetchAll, fetch, fetchYaml) stay lock-free — fs.writeFile on files this size is atomic enough that readers see either pre- or post-state, never corruption, and stale reads are not a correctness issue for the callers that use them (scheduler, event dispatcher). The debounced version-history commit in workspace.writeFile stays outside the lock; it's deferred work that shouldn't hold up the write. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../packages/core/src/knowledge/file-lock.ts | 18 ++ .../core/src/knowledge/track/fileops.ts | 173 +++++++++--------- .../packages/core/src/workspace/workspace.ts | 65 ++++--- 3 files changed, 144 insertions(+), 112 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/file-lock.ts diff --git a/apps/x/packages/core/src/knowledge/file-lock.ts b/apps/x/packages/core/src/knowledge/file-lock.ts new file mode 100644 index 00000000..157188cb --- /dev/null +++ b/apps/x/packages/core/src/knowledge/file-lock.ts @@ -0,0 +1,18 @@ +const locks = new Map<string, Promise<void>>(); + +export async function withFileLock<T>(absPath: string, fn: () => Promise<T>): Promise<T> { + const prev = locks.get(absPath) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise<void>((r) => { release = r; }); + const myTail = prev.then(() => gate); + locks.set(absPath, myTail); + try { + await prev; + return await fn(); + } finally { + release(); + if (locks.get(absPath) === myTail) { + locks.delete(absPath); + } + } +} diff --git a/apps/x/packages/core/src/knowledge/track/fileops.ts b/apps/x/packages/core/src/knowledge/track/fileops.ts index bc741936..bd731823 100644 --- a/apps/x/packages/core/src/knowledge/track/fileops.ts +++ b/apps/x/packages/core/src/knowledge/track/fileops.ts @@ -5,6 +5,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { WorkDir } from '../../config/config.js'; import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; import { TrackStateSchema } from './types.js'; +import { withFileLock } from '../file-lock.js'; const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); @@ -81,42 +82,46 @@ export async function fetchYaml(filePath: string, trackId: string): Promise<stri } export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> { - let content = await fs.readFile(absPath(filePath), 'utf-8'); - const openTag = `<!--track-target:${trackId}-->`; - const closeTag = `<!--/track-target:${trackId}-->`; - const openIdx = content.indexOf(openTag); - const closeIdx = content.indexOf(closeTag); - if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { - content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx); - } else { - const block = await fetch(filePath, trackId); - if (!block) { - throw new Error(`Track ${trackId} not found in ${filePath}`); + return withFileLock(absPath(filePath), async () => { + let content = await fs.readFile(absPath(filePath), 'utf-8'); + const openTag = `<!--track-target:${trackId}-->`; + const closeTag = `<!--/track-target:${trackId}-->`; + const openIdx = content.indexOf(openTag); + const closeIdx = content.indexOf(closeTag); + if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { + content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx); + } else { + const block = await fetch(filePath, trackId); + if (!block) { + throw new Error(`Track ${trackId} not found in ${filePath}`); + } + const lines = content.split('\n'); + const insertAt = Math.min(block.fenceEnd + 1, lines.length); + const contentFence = [openTag, newContent, closeTag]; + lines.splice(insertAt, 0, ...contentFence); + content = lines.join('\n'); } - const lines = content.split('\n'); - const insertAt = Math.min(block.fenceEnd + 1, lines.length); - const contentFence = [openTag, newContent, closeTag]; - lines.splice(insertAt, 0, ...contentFence); - content = lines.join('\n'); - } - await fs.writeFile(absPath(filePath), content, 'utf-8'); + await fs.writeFile(absPath(filePath), content, 'utf-8'); + }); } export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> { - const block = await fetch(filepath, trackId); - if (!block) { - throw new Error(`Track ${trackId} not found in ${filepath}`); - } - block.track = { ...block.track, ...updates }; + return withFileLock(absPath(filepath), async () => { + const block = await fetch(filepath, trackId); + if (!block) { + throw new Error(`Track ${trackId} not found in ${filepath}`); + } + block.track = { ...block.track, ...updates }; - // read file contents - let content = await fs.readFile(absPath(filepath), 'utf-8'); - const lines = content.split('\n'); - const yaml = stringifyYaml(block.track).trimEnd(); - const yamlLines = yaml ? yaml.split('\n') : []; - lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); - content = lines.join('\n'); - await fs.writeFile(absPath(filepath), content, 'utf-8'); + // read file contents + let content = await fs.readFile(absPath(filepath), 'utf-8'); + const lines = content.split('\n'); + const yaml = stringifyYaml(block.track).trimEnd(); + const yamlLines = yaml ? yaml.split('\n') : []; + lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); + content = lines.join('\n'); + await fs.writeFile(absPath(filepath), content, 'utf-8'); + }); } /** @@ -127,64 +132,68 @@ export async function updateTrackBlock(filepath: string, trackId: string, update * otherwise the write is rejected. */ export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> { - const block = await fetch(filePath, trackId); - if (!block) { - throw new Error(`Track ${trackId} not found in ${filePath}`); - } - const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml)); - if (!parsed.success) { - throw new Error(`Invalid track YAML: ${parsed.error.message}`); - } - if (parsed.data.trackId !== trackId) { - throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`); - } + return withFileLock(absPath(filePath), async () => { + const block = await fetch(filePath, trackId); + if (!block) { + throw new Error(`Track ${trackId} not found in ${filePath}`); + } + const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml)); + if (!parsed.success) { + throw new Error(`Invalid track YAML: ${parsed.error.message}`); + } + if (parsed.data.trackId !== trackId) { + throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`); + } - const content = await fs.readFile(absPath(filePath), 'utf-8'); - const lines = content.split('\n'); - const yamlLines = newYaml.trimEnd().split('\n'); - lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); - await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const lines = content.split('\n'); + const yamlLines = newYaml.trimEnd().split('\n'); + lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); + await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + }); } /** * Remove a track block and its sibling target region from the file. */ export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> { - const block = await fetch(filePath, trackId); - if (!block) { - // Already gone — treat as success. - return; - } - - const content = await fs.readFile(absPath(filePath), 'utf-8'); - const lines = content.split('\n'); - const openTag = `<!--track-target:${trackId}-->`; - const closeTag = `<!--/track-target:${trackId}-->`; - - // Find target region (may not exist) - let targetStart = -1; - let targetEnd = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(openTag)) { targetStart = i; } - if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; } - } - - // Build a list of [start, end] ranges to remove, sorted descending so - // indices stay valid as we splice. - const ranges: Array<[number, number]> = []; - ranges.push([block.fenceStart, block.fenceEnd]); - if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) { - ranges.push([targetStart, targetEnd]); - } - ranges.sort((a, b) => b[0] - a[0]); - - for (const [start, end] of ranges) { - lines.splice(start, end - start + 1); - // Also drop a trailing blank line if the removal left two in a row. - if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') { - lines.splice(start, 1); + return withFileLock(absPath(filePath), async () => { + const block = await fetch(filePath, trackId); + if (!block) { + // Already gone — treat as success. + return; } - } - await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const lines = content.split('\n'); + const openTag = `<!--track-target:${trackId}-->`; + const closeTag = `<!--/track-target:${trackId}-->`; + + // Find target region (may not exist) + let targetStart = -1; + let targetEnd = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(openTag)) { targetStart = i; } + if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; } + } + + // Build a list of [start, end] ranges to remove, sorted descending so + // indices stay valid as we splice. + const ranges: Array<[number, number]> = []; + ranges.push([block.fenceStart, block.fenceEnd]); + if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) { + ranges.push([targetStart, targetEnd]); + } + ranges.sort((a, b) => b[0] - a[0]); + + for (const [start, end] of ranges) { + lines.splice(start, end - start + 1); + // Also drop a trailing blank line if the removal left two in a row. + if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') { + lines.splice(start, 1); + } + } + + await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + }); } \ No newline at end of file diff --git a/apps/x/packages/core/src/workspace/workspace.ts b/apps/x/packages/core/src/workspace/workspace.ts index de1fe212..c991ce16 100644 --- a/apps/x/packages/core/src/workspace/workspace.ts +++ b/apps/x/packages/core/src/workspace/workspace.ts @@ -7,6 +7,7 @@ import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/share import { WorkDir } from '../config/config.js'; import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js'; import { commitAll } from '../knowledge/version_history.js'; +import { withFileLock } from '../knowledge/file-lock.js'; // ============================================================================ // Path Utilities @@ -249,38 +250,42 @@ export async function writeFile( await fs.mkdir(path.dirname(filePath), { recursive: true }); } - // Check expectedEtag if provided (conflict detection) - if (opts?.expectedEtag) { - const existingStats = await fs.lstat(filePath); - const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs); - if (existingEtag !== opts.expectedEtag) { - throw new Error('File was modified (ETag mismatch)'); + const result = await withFileLock(filePath, async () => { + // Check expectedEtag if provided (conflict detection) + if (opts?.expectedEtag) { + const existingStats = await fs.lstat(filePath); + const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs); + if (existingEtag !== opts.expectedEtag) { + throw new Error('File was modified (ETag mismatch)'); + } } - } - // Convert data to buffer based on encoding - let buffer: Buffer; - if (encoding === 'utf8') { - buffer = Buffer.from(data, 'utf8'); - } else if (encoding === 'base64') { - buffer = Buffer.from(data, 'base64'); - } else { - // binary: assume data is base64-encoded - buffer = Buffer.from(data, 'base64'); - } + // Convert data to buffer based on encoding + let buffer: Buffer; + if (encoding === 'utf8') { + buffer = Buffer.from(data, 'utf8'); + } else if (encoding === 'base64') { + buffer = Buffer.from(data, 'base64'); + } else { + // binary: assume data is base64-encoded + buffer = Buffer.from(data, 'base64'); + } - if (atomic) { - // Atomic write: write to temp file, then rename - const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2); - await fs.writeFile(tempPath, buffer); - await fs.rename(tempPath, filePath); - } else { - await fs.writeFile(filePath, buffer); - } + if (atomic) { + // Atomic write: write to temp file, then rename + const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2); + await fs.writeFile(tempPath, buffer); + await fs.rename(tempPath, filePath); + } else { + await fs.writeFile(filePath, buffer); + } - const stats = await fs.lstat(filePath); - const stat = statToSchema(stats, 'file'); - const etag = computeEtag(stats.size, stats.mtimeMs); + const stats = await fs.lstat(filePath); + const stat = statToSchema(stats, 'file'); + const etag = computeEtag(stats.size, stats.mtimeMs); + + return { stat, etag }; + }); // Schedule a debounced version history commit for knowledge files if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) { @@ -289,8 +294,8 @@ export async function writeFile( return { path: relPath, - stat, - etag, + stat: result.stat, + etag: result.etag, }; } From 0f051ea4675030fbdf80da0095780d9dbfe522b2 Mon Sep 17 00:00:00 2001 From: tusharmagar <tushmag@gmail.com> Date: Tue, 21 Apr 2026 13:02:44 +0530 Subject: [PATCH 015/143] fix: duplicate navigation button --- apps/x/apps/renderer/src/App.tsx | 45 +++----------------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f933b604..602c0956 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -127,8 +127,8 @@ const TITLEBAR_BUTTON_PX = 32 const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_HEADER_GAP_PX = 8 const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 -const TITLEBAR_BUTTONS_COLLAPSED = 4 -const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3 +const TITLEBAR_BUTTONS_COLLAPSED = 1 +const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' @@ -506,22 +506,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } -/** Sidebar toggle + utility buttons (fixed position, top-left) */ +/** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, leftInsetPx, }: { - onNavigateBack: () => void - onNavigateForward: () => void - canNavigateBack: boolean - canNavigateForward: boolean leftInsetPx: number }) { - const { toggleSidebar, state } = useSidebar() - const isCollapsed = state === "collapsed" + const { toggleSidebar } = useSidebar() return ( <div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}> <div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} /> @@ -535,30 +526,6 @@ function FixedSidebarToggle({ > <PanelLeftIcon className="size-5" /> </button> - {/* Back / Forward navigation */} - {isCollapsed && ( - <> - <button - type="button" - onClick={onNavigateBack} - disabled={!canNavigateBack} - className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none" - style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }} - aria-label="Go back" - > - <ChevronLeftIcon className="size-5" /> - </button> - <button - type="button" - onClick={onNavigateForward} - disabled={!canNavigateForward} - className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none" - aria-label="Go forward" - > - <ChevronRightIcon className="size-5" /> - </button> - </> - )} </div> ) } @@ -4756,10 +4723,6 @@ function App() { )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} <FixedSidebarToggle - onNavigateBack={() => { void navigateBack() }} - onNavigateForward={() => { void navigateForward() }} - canNavigateBack={canNavigateBack} - canNavigateForward={canNavigateForward} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} /> </SidebarProvider> From c81d3cb27b73e1b485a9ade643ac1f794f1b73a2 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:36:00 +0530 Subject: [PATCH 016/143] surface silent runtime failures as error events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentRuntime.trigger() wrapped its body in try/finally with no outer catch. An inner catch around the streamAgent for-await only handled AbortError and rethrew everything else. Call sites fire-and-forget trigger (runs.ts:26,60,72), so any thrown error became an unhandled promise rejection. The finally still ran and published run-processing-end, but nothing told the renderer why — the chat showed the spinner, then an empty assistant bubble. Provider misconfig, invalid API keys, unknown model ids, streamText setup throws, runsRepo.fetch or loadAgent failing, and provider auth/rate-limit rejections on the first chunk all hit this path on a first message. All invisible. Add a top-level catch that formats the error to a string and emits a {type: "error"} RunEvent via the existing runsRepo/bus path. The renderer already renders those as a chat bubble plus toast (App.tsx:2069) — no UI work needed. No changes to the abort path: user-initiated stops still flow through the existing inner catch and the signal.aborted branch that emits run-stopped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/x/packages/core/src/agents/runtime.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index f978449b..81421358 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -194,6 +194,19 @@ export class AgentRuntime implements IAgentRuntime { await this.runsRepo.appendEvents(runId, [stoppedEvent]); await this.bus.publish(stoppedEvent); } + } catch (error) { + console.error(`Run ${runId} failed:`, error); + const message = error instanceof Error + ? (error.stack || error.message || error.name) + : typeof error === "string" ? error : JSON.stringify(error); + const errorEvent: z.infer<typeof RunEvent> = { + runId, + type: "error", + error: message, + subflow: [], + }; + await this.runsRepo.appendEvents(runId, [errorEvent]); + await this.bus.publish(errorEvent); } finally { this.abortRegistry.cleanup(runId); await this.runsLock.release(runId); From 15567cd1dd47190ff165a76b28dd888e83e942a3 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:38:19 +0530 Subject: [PATCH 017/143] let tool failures be observed by the model instead of killing the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamAgent executed tools with no try/catch around the call. A throw from execTool or from a subflow agent streamed up through streamAgent, out of trigger's inner catch (which rethrows non-abort errors), and into the new top-level catch that the previous commit added. That surfaces the failure — but it ends the run. One misbehaving tool took down the whole conversation. Wrap the tool-execution block in a try/catch. On abort, rethrow so the existing AbortError path still fires. On any other error, convert the exception into a tool-result payload ({ success: false, error, toolName }) and keep going. The model then sees a tool-result message saying the tool failed with a specific message and can apologize, retry with different arguments, pick a different tool, or explain to the user — the normal recovery moves it already knows how to make. No change to happy-path tool execution, no change to abort handling, no change to subflow agent semantics (subflows that themselves error are treated identically to regular tool errors at the call site). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/x/packages/core/src/agents/runtime.ts | 51 ++++++++++++++-------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 81421358..ae69d60c 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -955,27 +955,40 @@ export async function* streamAgent({ subflow: [], }); let result: unknown = null; - if (agent.tools![toolCall.toolName].type === "agent") { - const subflowState = state.subflowStates[toolCallId]; - for await (const event of streamAgent({ - state: subflowState, - idGenerator, - runId, - messageQueue, - modelConfigRepo, - signal, - abortRegistry, - })) { - yield* processEvent({ - ...event, - subflow: [toolCallId, ...event.subflow], - }); + try { + if (agent.tools![toolCall.toolName].type === "agent") { + const subflowState = state.subflowStates[toolCallId]; + for await (const event of streamAgent({ + state: subflowState, + idGenerator, + runId, + messageQueue, + modelConfigRepo, + signal, + abortRegistry, + })) { + yield* processEvent({ + ...event, + subflow: [toolCallId, ...event.subflow], + }); + } + if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { + result = subflowState.finalResponse(); + } + } else { + result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); } - if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { - result = subflowState.finalResponse(); + } catch (error) { + if ((error instanceof Error && error.name === "AbortError") || signal.aborted) { + throw error; } - } else { - result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); + const message = error instanceof Error ? (error.message || error.name) : String(error); + _logger.log('tool failed', message); + result = { + success: false, + error: message, + toolName: toolCall.toolName, + }; } const resultPayload = result === undefined ? null : result; const resultMsg: z.infer<typeof ToolMessage> = { From 5c4aa772556d1f2431ec2fe17815979314861613 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:26:01 +0530 Subject: [PATCH 018/143] freeze model + provider per run at creation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model dropdown was broken in two ways: it wrote to ~/.rowboat/config/models.json (the BYOK creds file, stamped with a fake `flavor: 'openrouter'` to satisfy zod when signed in), and the runtime ignored that write entirely for signed-in users because `streamAgent` hard-coded `gpt-5.4`. Model selection was also globally scoped, so every chat shared one brain. This change moves model + provider out of the global config and onto the run itself, resolved once at runs:create and frozen for the run's lifetime. ## Resolution `runsCore.createRun` resolves per-field, falling through: run.model = opts.model ?? agent.model ?? defaults.model run.provider = opts.provider ?? agent.provider ?? defaults.provider A new `core/models/defaults.ts` is the only place in the codebase that branches on signed-in state. `getDefaultModelAndProvider()` returns name strings; `resolveProviderConfig(name)` does the name → full LlmProvider lookup at runtime. `createProvider` learns about `flavor: 'rowboat'` so the gateway is just another flavor. `provider` is stored as a name (e.g. `"rowboat"`, `"openai"`), not a full LlmProvider object. API keys never get written into the JSONL log; rotating a key in models.json applies to existing runs without re-creation. Cost: deleting a provider from settings breaks runs that referenced it (clear error surfaced via `resolveProviderConfig`). ## Runtime `streamAgent` no longer resolves anything — it reads `state.runModel` / `state.runProvider`, looks up the provider config, instantiates. Subflows inherit the parent run's pair, so KG / inline-task subagents run on whatever the main run resolved to at creation. The `knowledgeGraphAgents` array, `isKgAgent`, and the per-agent default constants are gone. KG / inline-task / pre-built agents declare their preferred model in YAML frontmatter (claude-haiku-4.5 / claude-sonnet-4.6) — used at resolution time when those agents are themselves the top-level agent of a run (background triggers, scheduled tasks, etc.). ## Standalone callers Non-run LLM call sites (summarize_meeting, track/routing, builtin-tools parseFile) and `agent-schedule/runner` were branching on signed-in independently. They all route through `getDefaultModelAndProvider` + `resolveProviderConfig` + `createProvider` now; `agent-schedule/runner` switched from raw `runsRepo.create` to `runsCore.createRun` so resolution applies to scheduled-agent runs too. ## UI `chat-input-with-mentions` stops calling `models:saveConfig`. The dropdown notifies the parent via `onSelectedModelChange` ({provider, model} as names); App.tsx stashes selection per-tab and passes it to the next `runs:create`. When a run already exists, the input fetches it and renders a static label — model can't change mid-run. ## Legacy runs A lenient zod schema in `repo.ts` (`StartEvent.extend(...optional)` plus `RunEvent.or(LegacyStartEvent)`) parses pre-existing runs. `repo.fetch` fills missing model/provider from current defaults and returns the strict canonical `Run` type. No file-rewriting migration; no impact on the canonical schema in `@x/shared`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/x/apps/renderer/src/App.tsx | 18 +++ .../components/chat-input-with-mentions.tsx | 142 +++++++----------- .../renderer/src/components/chat-sidebar.tsx | 5 +- .../core/src/agent-schedule/runner.ts | 5 +- apps/x/packages/core/src/agents/runtime.ts | 42 +++--- .../core/src/application/lib/builtin-tools.ts | 13 +- .../core/src/knowledge/agent_notes_agent.ts | 1 + .../core/src/knowledge/inline_task_agent.ts | 2 +- .../core/src/knowledge/labeling_agent.ts | 2 +- .../core/src/knowledge/note_creation.ts | 2 +- .../core/src/knowledge/note_tagging_agent.ts | 2 +- .../core/src/knowledge/summarize_meeting.ts | 17 +-- .../core/src/knowledge/track/routing.ts | 17 +-- apps/x/packages/core/src/models/defaults.ts | 53 +++++++ apps/x/packages/core/src/models/gateway.ts | 2 +- apps/x/packages/core/src/models/models.ts | 7 +- .../core/src/pre_built/email-draft.md | 2 +- .../core/src/pre_built/meeting-prep.md | 2 +- apps/x/packages/core/src/runs/repo.ts | 64 ++++++-- apps/x/packages/core/src/runs/runs.ts | 14 +- apps/x/packages/shared/src/models.ts | 11 +- apps/x/packages/shared/src/runs.ts | 12 +- 22 files changed, 256 insertions(+), 179 deletions(-) create mode 100644 apps/x/packages/core/src/models/defaults.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 602c0956..de75fb4a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -817,6 +817,7 @@ function App() { const chatTabIdCounterRef = useRef(0) const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const chatDraftsRef = useRef(new Map<string, string>()) + const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>()) const chatScrollTopByTabRef = useRef(new Map<string, number>()) const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({}) @@ -2165,8 +2166,10 @@ function App() { let isNewRun = false let newRunCreatedAt: string | null = null if (!currentRunId) { + const selected = selectedModelByTabRef.current.get(submitTabId) const run = await window.ipc.invoke('runs:create', { agentId, + ...(selected ? { model: selected.model, provider: selected.provider } : {}), }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2471,6 +2474,7 @@ function App() { return next }) chatDraftsRef.current.delete(tabId) + selectedModelByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setToolOpenByTab((prev) => { if (!(tabId in prev)) return prev @@ -4644,6 +4648,13 @@ function App() { runId={tabState.runId} initialDraft={chatDraftsRef.current.get(tab.id)} onDraftChange={(text) => setChatDraftForTab(tab.id, text)} + onSelectedModelChange={(m) => { + if (m) { + selectedModelByTabRef.current.set(tab.id, m) + } else { + selectedModelByTabRef.current.delete(tab.id) + } + }} isRecording={isActive && isRecording} recordingText={isActive ? voice.interimText : undefined} recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} @@ -4697,6 +4708,13 @@ function App() { onPresetMessageConsumed={() => setPresetMessage(undefined)} getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)} onDraftChangeForTab={setChatDraftForTab} + onSelectedModelChangeForTab={(tabId, m) => { + if (m) { + selectedModelByTabRef.current.set(tabId, m) + } else { + selectedModelByTabRef.current.delete(tabId) + } + }} pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 37d8d053..0d2eb13d 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -69,13 +69,16 @@ const providerDisplayNames: Record<string, string> = { rowboat: 'Rowboat', } +type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + interface ConfiguredModel { - flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + provider: ProviderName + model: string +} + +export interface SelectedModel { + provider: string model: string - apiKey?: string - baseURL?: string - headers?: Record<string, string> - knowledgeGraphModel?: string } function getAttachmentIcon(kind: AttachmentIconKind) { @@ -120,6 +123,8 @@ interface ChatInputInnerProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ + onSelectedModelChange?: (model: SelectedModel | null) => void } function ChatInputInner({ @@ -145,6 +150,7 @@ function ChatInputInner({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -155,10 +161,27 @@ function ChatInputInner({ const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([]) const [activeModelKey, setActiveModelKey] = useState('') + const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null) const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + // When a run exists, freeze the dropdown to the run's resolved model+provider. + useEffect(() => { + if (!runId) { + setLockedModel(null) + return + } + let cancelled = false + window.ipc.invoke('runs:fetch', { runId }).then((run) => { + if (cancelled) return + if (run.provider && run.model) { + setLockedModel({ provider: run.provider, model: run.model }) + } + }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) + return () => { cancelled = true } + }, [runId]) + // Check Rowboat sign-in state useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -176,42 +199,20 @@ function ChatInputInner({ return cleanup }, []) - // Load model config (gateway when signed in, local config when BYOK) + // Load the list of models the user can choose from. + // Signed-in: gateway model list. Signed-out: providers configured in models.json. const loadModelConfig = useCallback(async () => { try { if (isRowboatConnected) { - // Fetch gateway models const listResult = await window.ipc.invoke('models:list', null) const rowboatProvider = listResult.providers?.find( (p: { id: string }) => p.id === 'rowboat' ) const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( - (m: { id: string }) => ({ flavor: 'rowboat', model: m.id }) + (m: { id: string }) => ({ provider: 'rowboat', model: m.id }) ) - - // Read current default from config - let defaultModel = '' - try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - defaultModel = parsed?.model || '' - } catch { /* no config yet */ } - - if (defaultModel) { - models.sort((a, b) => { - if (a.model === defaultModel) return -1 - if (b.model === defaultModel) return 1 - return 0 - }) - } - setConfiguredModels(models) - const activeKey = defaultModel - ? `rowboat/${defaultModel}` - : models[0] ? `rowboat/${models[0].model}` : '' - if (activeKey) setActiveModelKey(activeKey) } else { - // BYOK: read from local models.json const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) const models: ConfiguredModel[] = [] @@ -223,32 +224,12 @@ function ChatInputInner({ const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] for (const model of allModels) { if (model) { - models.push({ - flavor: flavor as ConfiguredModel['flavor'], - model, - apiKey: (e.apiKey as string) || undefined, - baseURL: (e.baseURL as string) || undefined, - headers: (e.headers as Record<string, string>) || undefined, - knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, - }) + models.push({ provider: flavor as ProviderName, model }) } } } } - const defaultKey = parsed?.provider?.flavor && parsed?.model - ? `${parsed.provider.flavor}/${parsed.model}` - : '' - models.sort((a, b) => { - const aKey = `${a.flavor}/${a.model}` - const bKey = `${b.flavor}/${b.model}` - if (aKey === defaultKey) return -1 - if (bKey === defaultKey) return 1 - return 0 - }) setConfiguredModels(models) - if (defaultKey) { - setActiveModelKey(defaultKey) - } } } catch { // No config yet @@ -284,40 +265,15 @@ function ChatInputInner({ checkSearch() }, [isActive, isRowboatConnected]) - const handleModelChange = useCallback(async (key: string) => { - const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + // Selecting a model affects only the *next* run created from this tab. + // Once a run exists, model is frozen on the run and the dropdown is read-only. + const handleModelChange = useCallback((key: string) => { + if (lockedModel) return + const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key) if (!entry) return setActiveModelKey(key) - - try { - if (entry.flavor === 'rowboat') { - // Gateway model — save with valid Zod flavor, no credentials - await window.ipc.invoke('models:saveConfig', { - provider: { flavor: 'openrouter' as const }, - model: entry.model, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) - } else { - // BYOK — preserve full provider config - const providerModels = configuredModels - .filter((m) => m.flavor === entry.flavor) - .map((m) => m.model) - await window.ipc.invoke('models:saveConfig', { - provider: { - flavor: entry.flavor, - apiKey: entry.apiKey, - baseURL: entry.baseURL, - headers: entry.headers, - }, - model: entry.model, - models: providerModels, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) - } - } catch { - toast.error('Failed to switch model') - } - }, [configuredModels]) + onSelectedModelChange?.({ provider: entry.provider, model: entry.model }) + }, [configuredModels, lockedModel, onSelectedModelChange]) // Restore the tab draft when this input mounts. useEffect(() => { @@ -555,7 +511,14 @@ function ChatInputInner({ ) )} <div className="flex-1" /> - {configuredModels.length > 0 && ( + {lockedModel ? ( + <span + className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground" + title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`} + > + <span className="max-w-[150px] truncate">{lockedModel.model}</span> + </span> + ) : configuredModels.length > 0 ? ( <DropdownMenu> <DropdownMenuTrigger asChild> <button @@ -563,7 +526,7 @@ function ChatInputInner({ className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > <span className="max-w-[150px] truncate"> - {configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'} + {configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'} </span> <ChevronDown className="h-3 w-3" /> </button> @@ -571,18 +534,18 @@ function ChatInputInner({ <DropdownMenuContent align="end"> <DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}> {configuredModels.map((m) => { - const key = `${m.flavor}/${m.model}` + const key = `${m.provider}/${m.model}` return ( <DropdownMenuRadioItem key={key} value={key}> <span className="truncate">{m.model}</span> - <span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span> + <span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span> </DropdownMenuRadioItem> ) })} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> - )} + ) : null} {onToggleTts && ttsAvailable && ( <div className="flex shrink-0 items-center"> <Tooltip> @@ -729,6 +692,7 @@ export interface ChatInputWithMentionsProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onSelectedModelChange?: (model: SelectedModel | null) => void } export function ChatInputWithMentions({ @@ -757,6 +721,7 @@ export function ChatInputWithMentions({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputWithMentionsProps) { return ( <PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}> @@ -783,6 +748,7 @@ export function ChatInputWithMentions({ ttsMode={ttsMode} onToggleTts={onToggleTts} onTtsModeChange={onTtsModeChange} + onSelectedModelChange={onSelectedModelChange} /> </PromptInputProvider> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index e51d7c8f..852993a2 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -26,7 +26,7 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { @@ -158,6 +158,7 @@ interface ChatSidebarProps { onPresetMessageConsumed?: () => void getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void + onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] @@ -211,6 +212,7 @@ export function ChatSidebar({ onPresetMessageConsumed, getInitialDraft, onDraftChangeForTab, + onSelectedModelChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -662,6 +664,7 @@ export function ChatSidebar({ runId={tabState.runId} initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} + onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} isRecording={isActive && isRecording} recordingText={isActive ? recordingText : undefined} recordingState={isActive ? recordingState : undefined} diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts index 4eab6081..5fca6878 100644 --- a/apps/x/packages/core/src/agent-schedule/runner.ts +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -8,6 +8,7 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; import { MessageEvent } from "@x/shared/dist/runs.js"; +import { createRun } from "../runs/runs.js"; import z from "zod"; const DEFAULT_STARTING_MESSAGE = "go"; @@ -162,8 +163,8 @@ async function runAgent( }); try { - // Create a new run - const run = await runsRepo.create({ agentId: agentName }); + // Create a new run via core (resolves agent + default model+provider). + const run = await createRun({ agentId: agentName }); console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); // Add the starting message as a user message diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index ae69d60c..6c84ac8b 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -16,8 +16,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu import container from "../di/container.js"; import { IModelConfigRepo } from "../models/repo.js"; import { createProvider } from "../models/models.js"; -import { isSignedIn } from "../account/account.js"; -import { getGatewayProvider } from "../models/gateway.js"; +import { resolveProviderConfig } from "../models/defaults.js"; import { IAgentsRepo } from "./repo.js"; import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { IBus } from "../application/lib/bus.js"; @@ -649,6 +648,8 @@ export class AgentState { runId: string | null = null; agent: z.infer<typeof Agent> | null = null; agentName: string | null = null; + runModel: string | null = null; + runProvider: string | null = null; messages: z.infer<typeof MessageList> = []; lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null; subflowStates: Record<string, AgentState> = {}; @@ -762,13 +763,18 @@ export class AgentState { case "start": this.runId = event.runId; this.agentName = event.agentName; + this.runModel = event.model; + this.runProvider = event.provider; break; case "spawn-subflow": // Seed the subflow state with its agent so downstream loadAgent works. + // Subflows inherit the parent run's model+provider — there's one pair per run. if (!this.subflowStates[event.toolCallId]) { this.subflowStates[event.toolCallId] = new AgentState(); } this.subflowStates[event.toolCallId].agentName = event.agentName; + this.subflowStates[event.toolCallId].runModel = this.runModel; + this.subflowStates[event.toolCallId].runProvider = this.runProvider; break; case "message": this.messages.push(event.message); @@ -857,35 +863,23 @@ export async function* streamAgent({ yield event; } - const modelConfig = await modelConfigRepo.getConfig(); - if (!modelConfig) { - throw new Error("Model config not found"); - } - // set up agent const agent = await loadAgent(state.agentName!); // set up tools const tools = await buildTools(agent); - // set up provider + model - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(modelConfig.provider); - const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"]; - const isKgAgent = knowledgeGraphAgents.includes(state.agentName!); - const isInlineTaskAgent = state.agentName === "inline_task_agent"; - const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model; - const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel; - const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel; - const modelId = isInlineTaskAgent - ? defaultInlineTaskModel - : (isKgAgent && modelConfig.knowledgeGraphModel) - ? modelConfig.knowledgeGraphModel - : isKgAgent ? defaultKgModel : defaultModel; + // model+provider were resolved and frozen on the run at runs:create time. + // Look up the named provider's current credentials from models.json and + // instantiate the LLM client. No selection happens here. + if (!state.runModel || !state.runProvider) { + throw new Error(`Run ${runId} is missing model/provider on its start event`); + } + const modelId = state.runModel; + const providerConfig = await resolveProviderConfig(state.runProvider); + const provider = createProvider(providerConfig); const model = provider.languageModel(modelId); - logger.log(`using model: ${modelId}`); + logger.log(`using model: ${modelId} (provider: ${state.runProvider})`); let loopCounter = 0; let voiceInput = false; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index a2b68427..52083277 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -21,9 +21,8 @@ import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/d import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; -import { IModelConfigRepo } from "../../models/repo.js"; +import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js"; import { isSignedIn } from "../../account/account.js"; -import { getGatewayProvider } from "../../models/gateway.js"; import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; @@ -746,13 +745,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { const base64 = buffer.toString('base64'); - // Resolve model config from DI container - const modelConfigRepo = container.resolve<IModelConfigRepo>('modelConfigRepo'); - const modelConfig = await modelConfigRepo.getConfig(); - const provider = await isSignedIn() - ? await getGatewayProvider() - : createProvider(modelConfig.provider); - const model = provider.languageModel(modelConfig.model); + const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); const userPrompt = prompt || 'Convert this file to well-structured markdown.'; diff --git a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts index 58aa22a7..d7087405 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts @@ -1,5 +1,6 @@ export function getRaw(): string { return `--- +model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index d25ff74b..9c3e2568 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -13,7 +13,7 @@ export function getRaw(): string { const defaultEndISO = defaultEnd.toISOString(); return `--- -model: gpt-5.2 +model: anthropic/claude-sonnet-4.6 tools: ${toolEntries} --- diff --git a/apps/x/packages/core/src/knowledge/labeling_agent.ts b/apps/x/packages/core/src/knowledge/labeling_agent.ts index d28649b1..bb4a6efe 100644 --- a/apps/x/packages/core/src/knowledge/labeling_agent.ts +++ b/apps/x/packages/core/src/knowledge/labeling_agent.ts @@ -2,7 +2,7 @@ import { renderTagSystemForEmails } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 1740bdb7..283c77ec 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -3,7 +3,7 @@ import { renderNoteEffectRules } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts index 0dc581f1..71b10910 100644 --- a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts +++ b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts @@ -2,7 +2,7 @@ import { renderTagSystemForNotes } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index 30e3c5d4..a10aac28 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -1,11 +1,8 @@ import fs from 'fs'; import path from 'path'; import { generateText } from 'ai'; -import container from '../di/container.js'; -import type { IModelConfigRepo } from '../models/repo.js'; import { createProvider } from '../models/models.js'; -import { isSignedIn } from '../account/account.js'; -import { getGatewayProvider } from '../models/gateway.js'; +import { getDefaultModelAndProvider, resolveProviderConfig } from '../models/defaults.js'; import { WorkDir } from '../config/config.js'; const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -138,15 +135,9 @@ function loadCalendarEventContext(calendarEventJson: string): string { } export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> { - const repo = container.resolve<IModelConfigRepo>('modelConfigRepo'); - const config = await repo.getConfig(); - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(config.provider); - const modelId = config.meetingNotesModel - || (signedIn ? "gpt-5.4" : config.model); - const model = provider.languageModel(modelId); + const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); // If a specific calendar event was linked, use it directly. // Otherwise fall back to scanning events within ±3 hours. diff --git a/apps/x/packages/core/src/knowledge/track/routing.ts b/apps/x/packages/core/src/knowledge/track/routing.ts index f876106e..53e6f7b3 100644 --- a/apps/x/packages/core/src/knowledge/track/routing.ts +++ b/apps/x/packages/core/src/knowledge/track/routing.ts @@ -1,11 +1,8 @@ import { generateObject } from 'ai'; import { trackBlock, PrefixLogger } from '@x/shared'; import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; -import container from '../../di/container.js'; -import type { IModelConfigRepo } from '../../models/repo.js'; import { createProvider } from '../../models/models.js'; -import { isSignedIn } from '../../account/account.js'; -import { getGatewayProvider } from '../../models/gateway.js'; +import { getDefaultModelAndProvider, resolveProviderConfig } from '../../models/defaults.js'; const log = new PrefixLogger('TrackRouting'); @@ -37,15 +34,9 @@ Rules: - For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`; async function resolveModel() { - const repo = container.resolve<IModelConfigRepo>('modelConfigRepo'); - const config = await repo.getConfig(); - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(config.provider); - const modelId = config.knowledgeGraphModel - || (signedIn ? 'gpt-5.4' : config.model); - return provider.languageModel(modelId); + const { model, provider } = await getDefaultModelAndProvider(); + const config = await resolveProviderConfig(provider); + return createProvider(config).languageModel(model); } function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string { diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts new file mode 100644 index 00000000..b9df52da --- /dev/null +++ b/apps/x/packages/core/src/models/defaults.ts @@ -0,0 +1,53 @@ +import z from "zod"; +import { LlmProvider } from "@x/shared/dist/models.js"; +import { IModelConfigRepo } from "./repo.js"; +import { isSignedIn } from "../account/account.js"; +import container from "../di/container.js"; + +const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; +const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; + +/** + * The single source of truth for "what model+provider should we use when + * the caller didn't specify and the agent didn't declare". Returns names only. + * This is the only place that branches on signed-in state. + */ +export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> { + if (await isSignedIn()) { + return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; + } + const repo = container.resolve<IModelConfigRepo>("modelConfigRepo"); + const cfg = await repo.getConfig(); + return { model: cfg.model, provider: cfg.provider.flavor }; +} + +/** + * Resolve a provider name (as stored on a run, an agent, or returned by + * getDefaultModelAndProvider) into the full LlmProvider config that + * createProvider expects (apiKey/baseURL/headers). + * + * - "rowboat" → gateway provider (auth via OAuth bearer; no creds field). + * - other names → look up models.json's `providers[name]` map. + * - fallback: if the name matches the active default's flavor (legacy + * single-provider configs that didn't write to the providers map yet). + */ +export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> { + if (name === "rowboat") { + return { flavor: "rowboat" }; + } + const repo = container.resolve<IModelConfigRepo>("modelConfigRepo"); + const cfg = await repo.getConfig(); + const entry = cfg.providers?.[name]; + if (entry) { + return LlmProvider.parse({ + flavor: name, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }); + } + if (cfg.provider.flavor === name) { + return cfg.provider; + } + throw new Error(`Provider '${name}' is referenced but not configured`); +} diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts index df9b413c..6f613704 100644 --- a/apps/x/packages/core/src/models/gateway.ts +++ b/apps/x/packages/core/src/models/gateway.ts @@ -10,7 +10,7 @@ const authedFetch: typeof fetch = async (input, init) => { return fetch(input, { ...init, headers }); }; -export async function getGatewayProvider(): Promise<ProviderV2> { +export function getGatewayProvider(): ProviderV2 { return createOpenRouter({ baseURL: `${API_URL}/v1/llm`, apiKey: 'managed-by-rowboat', diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 38b6801f..92353f0a 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -8,7 +8,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; -import { isSignedIn } from "../account/account.js"; import { getGatewayProvider } from "./gateway.js"; export const Provider = LlmProvider; @@ -65,6 +64,8 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 { baseURL, headers, }) as unknown as ProviderV2; + case "rowboat": + return getGatewayProvider(); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } @@ -80,9 +81,7 @@ export async function testModelConnection( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), effectiveTimeout); try { - const provider = await isSignedIn() - ? await getGatewayProvider() - : createProvider(providerConfig); + const provider = createProvider(providerConfig); const languageModel = provider.languageModel(model); await generateText({ model: languageModel, diff --git a/apps/x/packages/core/src/pre_built/email-draft.md b/apps/x/packages/core/src/pre_built/email-draft.md index f863271b..7a353d26 100644 --- a/apps/x/packages/core/src/pre_built/email-draft.md +++ b/apps/x/packages/core/src/pre_built/email-draft.md @@ -1,5 +1,5 @@ --- -model: gpt-4.1 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/pre_built/meeting-prep.md b/apps/x/packages/core/src/pre_built/meeting-prep.md index ca6bb2fc..5dc46eda 100644 --- a/apps/x/packages/core/src/pre_built/meeting-prep.md +++ b/apps/x/packages/core/src/pre_built/meeting-prep.md @@ -1,5 +1,5 @@ --- -model: gpt-4.1 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 5d563f1f..502976e6 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -6,9 +6,28 @@ import fsp from "fs/promises"; import fs from "fs"; import readline from "readline"; import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js"; +import { getDefaultModelAndProvider } from "../models/defaults.js"; + +/** + * Reading-only schemas: extend the canonical `StartEvent` / `RunEvent` to + * accept legacy run files written before `model`/`provider` were required. + * + * `RunEvent.or(LegacyStartEvent)` works because zod unions try left-to-right: + * for any non-start event RunEvent matches first; for a strict start event + * RunEvent still matches; only a legacy start event falls through and parses + * as LegacyStartEvent. New event types stay maintained in one place + * (`@x/shared/dist/runs.js`) — the lenient form just adds one fallback variant. + */ +const LegacyStartEvent = StartEvent.extend({ + model: z.string().optional(), + provider: z.string().optional(), +}); +const ReadRunEvent = RunEvent.or(LegacyStartEvent); + +export type CreateRunRepoOptions = Required<z.infer<typeof CreateRunOptions>>; export interface IRunsRepo { - create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>; + create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>; fetch(id: string): Promise<z.infer<typeof Run>>; list(cursor?: string): Promise<z.infer<typeof ListRunsResponse>>; appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>; @@ -69,16 +88,19 @@ export class FSRunsRepo implements IRunsRepo { /** * Read file line-by-line using streams, stopping early once we have * the start event and title (or determine there's no title). + * + * Parses the start event with `LegacyStartEvent` so runs written before + * `model`/`provider` were required still surface in the list view. */ private async readRunMetadata(filePath: string): Promise<{ - start: z.infer<typeof StartEvent>; + start: z.infer<typeof LegacyStartEvent>; title: string | undefined; } | null> { return new Promise((resolve) => { const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - let start: z.infer<typeof StartEvent> | null = null; + let start: z.infer<typeof LegacyStartEvent> | null = null; let title: string | undefined; let lineIndex = 0; @@ -88,11 +110,10 @@ export class FSRunsRepo implements IRunsRepo { try { if (lineIndex === 0) { - // First line should be the start event - start = StartEvent.parse(JSON.parse(trimmed)); + start = LegacyStartEvent.parse(JSON.parse(trimmed)); } else { // Subsequent lines - look for first user message or assistant response - const event = RunEvent.parse(JSON.parse(trimmed)); + const event = ReadRunEvent.parse(JSON.parse(trimmed)); if (event.type === 'message') { const msg = event.message; if (msg.role === 'user') { @@ -157,13 +178,15 @@ export class FSRunsRepo implements IRunsRepo { ); } - async create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> { + async create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>> { const runId = await this.idGenerator.next(); const ts = new Date().toISOString(); const start: z.infer<typeof StartEvent> = { type: "start", runId, agentName: options.agentId, + model: options.model, + provider: options.provider, subflow: [], ts, }; @@ -172,24 +195,41 @@ export class FSRunsRepo implements IRunsRepo { id: runId, createdAt: ts, agentId: options.agentId, + model: options.model, + provider: options.provider, log: [start], }; } async fetch(id: string): Promise<z.infer<typeof Run>> { const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8'); - const events = contents.split('\n') + // Parse with the lenient schema so legacy start events (no model/provider) load. + const rawEvents = contents.split('\n') .filter(line => line.trim() !== '') - .map(line => RunEvent.parse(JSON.parse(line))); - if (events.length === 0 || events[0].type !== 'start') { + .map(line => ReadRunEvent.parse(JSON.parse(line))); + if (rawEvents.length === 0 || rawEvents[0].type !== 'start') { throw new Error('Corrupt run data'); } + // Backfill model/provider on the start event from current defaults if missing, + // then promote to the canonical strict types for callers. + const rawStart = rawEvents[0]; + const defaults = (!rawStart.model || !rawStart.provider) + ? await getDefaultModelAndProvider() + : null; + const start: z.infer<typeof StartEvent> = { + ...rawStart, + model: rawStart.model ?? defaults!.model, + provider: rawStart.provider ?? defaults!.provider, + }; + const events: z.infer<typeof RunEvent>[] = [start, ...rawEvents.slice(1) as z.infer<typeof RunEvent>[]]; const title = this.extractTitle(events); return { id, title, - createdAt: events[0].ts!, - agentId: events[0].agentName, + createdAt: start.ts!, + agentId: start.agentName, + model: start.model, + provider: start.provider, log: events, }; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 8ea4688b..5b8395a9 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -10,11 +10,21 @@ import { IRunsLock } from "./lock.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { extractCommandNames } from "../application/lib/command-executor.js"; import { addToSecurityConfig } from "../config/security.js"; +import { loadAgent } from "../agents/runtime.js"; +import { getDefaultModelAndProvider } from "../models/defaults.js"; export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> { const repo = container.resolve<IRunsRepo>('runsRepo'); const bus = container.resolve<IBus>('bus'); - const run = await repo.create(opts); + + // Resolve model+provider once at creation: opts > agent declaration > defaults. + // Both fields are plain strings (provider is a name, looked up at runtime). + const agent = await loadAgent(opts.agentId); + const defaults = await getDefaultModelAndProvider(); + const model = opts.model ?? agent.model ?? defaults.model; + const provider = opts.provider ?? agent.provider ?? defaults.provider; + + const run = await repo.create({ agentId: opts.agentId, model, provider }); await bus.publish(run.log[0]); return run; } @@ -110,4 +120,4 @@ export async function fetchRun(runId: string): Promise<z.infer<typeof Run>> { export async function listRuns(cursor?: string): Promise<z.infer<typeof ListRunsResponse>> { const repo = container.resolve<IRunsRepo>('runsRepo'); return repo.list(cursor); -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 2c1588e8..feec148f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]), + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), @@ -11,6 +11,15 @@ export const LlmModelConfig = z.object({ provider: LlmProvider, model: z.string(), models: z.array(z.string()).optional(), + providers: z.record(z.string(), z.object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + model: z.string().optional(), + models: z.array(z.string()).optional(), + })).optional(), + // Deprecated: per-run model+provider supersedes these. Kept on the schema so + // existing settings/onboarding UIs continue to compile until they're cleaned up. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 5f52f611..2c5bcc7a 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -19,6 +19,8 @@ export const RunProcessingEndEvent = BaseRunEvent.extend({ export const StartEvent = BaseRunEvent.extend({ type: z.literal("start"), agentName: z.string(), + model: z.string(), + provider: z.string(), }); export const SpawnSubFlowEvent = BaseRunEvent.extend({ @@ -121,6 +123,8 @@ export const Run = z.object({ title: z.string().optional(), createdAt: z.iso.datetime(), agentId: z.string(), + model: z.string(), + provider: z.string(), log: z.array(RunEvent), }); @@ -134,6 +138,8 @@ export const ListRunsResponse = z.object({ nextCursor: z.string().optional(), }); -export const CreateRunOptions = Run.pick({ - agentId: true, -}); \ No newline at end of file +export const CreateRunOptions = z.object({ + agentId: z.string(), + model: z.string().optional(), + provider: z.string().optional(), +}); From f4dbb58a7782a841895322691db06280641d6511 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:35:08 +0530 Subject: [PATCH 019/143] add rowboat meeting notes to graph --- apps/x/packages/core/src/knowledge/build_graph.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 100af5d8..60c0572e 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -38,6 +38,7 @@ const SOURCE_FOLDERS = [ 'gmail_sync', path.join('knowledge', 'Meetings', 'fireflies'), path.join('knowledge', 'Meetings', 'granola'), + path.join('knowledge', 'Meetings', 'rowboat'), ]; // Voice memos are now created directly in knowledge/Voice Memos/<date>/ From 75842fa06b1aa936eff45c03e07369fee92f8c86 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:49:06 +0530 Subject: [PATCH 020/143] assistant chat ui shows the model name properly --- .../renderer/src/components/chat-input-with-mentions.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0d2eb13d..e1fb950f 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -81,6 +81,10 @@ export interface SelectedModel { model: string } +function getSelectedModelDisplayName(model: string) { + return model.split('/').pop() || model +} + function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -516,7 +520,7 @@ function ChatInputInner({ className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground" title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`} > - <span className="max-w-[150px] truncate">{lockedModel.model}</span> + <span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span> </span> ) : configuredModels.length > 0 ? ( <DropdownMenu> @@ -526,7 +530,7 @@ function ChatInputInner({ className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > <span className="max-w-[150px] truncate"> - {configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'} + {getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')} </span> <ChevronDown className="h-3 w-3" /> </button> From 0bb256879c756f2bed40cc783d10d2492ed4f7d8 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:29:51 +0530 Subject: [PATCH 021/143] preserve formatting in chat input text --- apps/x/apps/renderer/package.json | 1 + apps/x/apps/renderer/src/App.tsx | 23 +++++++++++++++++-- .../renderer/src/components/chat-sidebar.tsx | 23 +++++++++++++++++-- apps/x/pnpm-lock.yaml | 20 ++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index a8c67a43..d9216de1 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -49,6 +49,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.8.0", + "remark-breaks": "^4.0.0", "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index de75fb4a..67f3f06a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -62,6 +62,8 @@ import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { defaultRemarkPlugins } from 'streamdown' +import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { type ChatMessage, @@ -104,6 +106,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +// Render user messages with markdown so bullets, bold, links, etc. survive the +// round-trip from the input textarea. `remarkBreaks` turns single newlines +// into <br> so typed line breaks are preserved without requiring blank lines. +const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] + function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { const smoothText = useSmoothedText(text) return <MessageResponse components={components}>{smoothText}</MessageResponse> @@ -3974,7 +3981,14 @@ function App() { <ChatMessageAttachments attachments={item.attachments} /> </MessageContent> {item.content && ( - <MessageContent>{item.content}</MessageContent> + <MessageContent> + <MessageResponse + components={streamdownComponents} + remarkPlugins={userMessageRemarkPlugins} + > + {item.content} + </MessageResponse> + </MessageContent> )} </Message> ) @@ -3995,7 +4009,12 @@ function App() { ))} </div> )} - {message} + <MessageResponse + components={streamdownComponents} + remarkPlugins={userMessageRemarkPlugins} + > + {message} + </MessageResponse> </MessageContent> </Message> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 852993a2..0a407d5d 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -25,6 +25,8 @@ import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { defaultRemarkPlugins } from 'streamdown' +import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab } from '@/components/tab-bar' import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' @@ -49,6 +51,11 @@ import { const streamdownComponents = { pre: MarkdownPreOverride } +// Render user messages with markdown so bullets, bold, links, etc. survive the +// round-trip from the input textarea. `remarkBreaks` turns single newlines +// into <br> so typed line breaks are preserved without requiring blank lines. +const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] + /* ─── Billing error helpers ─── */ const BILLING_ERROR_PATTERNS = [ @@ -353,7 +360,14 @@ export function ChatSidebar({ <ChatMessageAttachments attachments={item.attachments} /> </MessageContent> {item.content && ( - <MessageContent>{item.content}</MessageContent> + <MessageContent> + <MessageResponse + components={streamdownComponents} + remarkPlugins={userMessageRemarkPlugins} + > + {item.content} + </MessageResponse> + </MessageContent> )} </Message> ) @@ -374,7 +388,12 @@ export function ChatSidebar({ ))} </div> )} - {message} + <MessageResponse + components={streamdownComponents} + remarkPlugins={userMessageRemarkPlugins} + > + {message} + </MessageResponse> </MessageContent> </Message> ) diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 51248fff..ac219371 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: recharts: specifier: ^3.8.0 version: 3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5808,6 +5811,9 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -6768,6 +6774,9 @@ packages: rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-cjk-friendly-gfm-strikethrough@1.2.3: resolution: {integrity: sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA==} engines: {node: '>=16'} @@ -14414,6 +14423,11 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -15608,6 +15622,12 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + remark-cjk-friendly-gfm-strikethrough@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: micromark-extension-cjk-friendly-gfm-strikethrough: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2) From bdf270b7a1d93111b0967964048dc2b427499bd0 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:15:24 +0530 Subject: [PATCH 022/143] 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) <noreply@anthropic.com> --- .../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<void> { + 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<SyncedThread | null> { 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, unknown>): string { return ''; } -async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<string | null> { +interface ComposioThreadResult { + synced: SyncedThread | null; + newestIsoPlusOne: string | null; +} + +async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<ComposioThreadResult> { 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<string, unknown>; const messages = data.messages as Array<Record<string, unknown>> | 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 023/143] 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.<field> ?? 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) <noreply@anthropic.com> --- .../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<Record<string, LlmModelOption[]>>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState<string | null>(null) - const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({ - 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<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({ + 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) { </Select> )} </div> + + <div className="space-y-2"> + <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.meetingNotesModel} + onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.meetingNotesModel || "__same__"} + onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((model) => ( + <SelectItem key={model.id} value={model.id}> + {model.name || model.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + + <div className="space-y-2"> + <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.trackBlockModel} + onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.trackBlockModel || "__same__"} + onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((model) => ( + <SelectItem key={model.id} value={model.id}> + {model.name || model.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> </div> {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) { </Select> )} </div> + + <div className="space-y-2 min-w-0"> + <label className="text-xs font-medium text-muted-foreground"> + Meeting Notes Model + </label> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.meetingNotesModel} + onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.meetingNotesModel || "__same__"} + onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })} + > + <SelectTrigger className="w-full truncate"> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((model) => ( + <SelectItem key={model.id} value={model.id}> + {model.name || model.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + + <div className="space-y-2 min-w-0"> + <label className="text-xs font-medium text-muted-foreground"> + Track Block Model + </label> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.trackBlockModel} + onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.trackBlockModel || "__same__"} + onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })} + > + <SelectTrigger className="w-full truncate"> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((model) => ( + <SelectItem key={model.id} value={model.id}> + {model.name || model.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> </div> {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<Record<string, LlmModelOption[]>>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState<string | null>(null) - const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({ - 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<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({ + 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<Record<LlmProviderFlavor, string>> = { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState<LlmProviderFlavor>("openai") const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null) - const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({ - 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<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({ + 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<Record<string, LlmModelOption[]>>({}) 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 }) { </Select> )} </div> + + {/* Meeting notes model */} + <div className="space-y-2"> + <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.meetingNotesModel} + onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.meetingNotesModel || "__same__"} + onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((m) => ( + <SelectItem key={m.id} value={m.id}> + {m.name || m.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> + + {/* Track block model */} + <div className="space-y-2"> + <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span> + {modelsLoading ? ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="size-4 animate-spin" /> + Loading... + </div> + ) : showModelInput ? ( + <Input + value={activeConfig.trackBlockModel} + onChange={(e) => updateConfig(provider, { trackBlockModel: e.target.value })} + placeholder={primaryModel || "Enter model"} + /> + ) : ( + <Select + value={activeConfig.trackBlockModel || "__same__"} + onValueChange={(value) => updateConfig(provider, { trackBlockModel: value === "__same__" ? "" : value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__same__">Same as assistant</SelectItem> + {modelsForProvider.map((m) => ( + <SelectItem key={m.id} value={m.id}> + {m.name || m.id} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </div> </div> {/* 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<void> { 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<void> { 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<string> }> { 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<string> { - 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<string> }> { 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<z.infer<typeo } throw new Error(`Provider '${name}' is referenced but not configured`); } + +/** + * Model used by knowledge-graph agents (note_creation, labeling_agent, etc.) + * when they're the top-level of a run. Signed-in: curated default. + * BYOK: user override (`knowledgeGraphModel`) or assistant model. + */ +export async function getKgModel(): Promise<string> { + if (await isSignedIn()) return SIGNED_IN_KG_MODEL; + const cfg = await container.resolve<IModelConfigRepo>("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<string> { + if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL; + const cfg = await container.resolve<IModelConfigRepo>("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<string> { + if (await isSignedIn()) return SIGNED_IN_DEFAULT_MODEL; + const cfg = await container.resolve<IModelConfigRepo>("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<void> { // 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 024/143] 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 `<dt>/<dd>` style as the lastRun fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../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() { <dt>Track ID</dt><dd><code>{trackId}</code></dd> <dt>File</dt><dd><code>{detail.filePath}</code></dd> <dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd> + {model && (<> + <dt>Model</dt><dd><code>{model}</code></dd> + </>)} + {provider && (<> + <dt>Provider</dt><dd><code>{provider}</code></dd> + </>)} {lastRunAt && (<> <dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd> </>)} 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 025/143] 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) <noreply@anthropic.com> --- 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<typeof ServiceEvent>): 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( - <StrictMode> - <PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}> - <ThemeProvider defaultTheme="system"> - <App /> - </ThemeProvider> - </PostHogProvider> - </StrictMode>, -) + 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( + <StrictMode> + <PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}> + <ThemeProvider defaultTheme="system"> + <App /> + </ThemeProvider> + </PostHogProvider> + </StrictMode>, + ) + + // 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<typeof MessageList> = []; lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null; subflowStates: Record<string, AgentState> = {}; @@ -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<typeof MessageList>, instructions: string, tools: ToolSet, signal?: AbortSignal, + analytics?: StreamLlmAnalytics, ): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, 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<string, unknown>): 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<string, unknown>): 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<void> { + 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<string, unknown> = { + 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<UseCaseContext>(); + +export function withUseCase<T>(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<typeof BuiltinToolsSchema> = { ], }); + 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<void> { 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<void> { 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<string>(); 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<void> { 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<z.infer<typeof CreateRunOptions>>; +export type CreateRunRepoOptions = { + agentId: string; + model: string; + provider: string; + useCase: z.infer<typeof UseCase>; + subUseCase?: string; +}; export interface IRunsRepo { create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>; @@ -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<typeof CreateRunOptions>): 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 <gaganp000999@gmail.com> Date: Tue, 28 Apr 2026 20:10:13 +0530 Subject: [PATCH 026/143] 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() { </ConversationEmptyState> ) : ( <> - {tabState.conversation.map(item => { + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) + ).map(item => { + if (isToolGroup(item)) { + return ( + <ToolGroupComponent + key={item.groupId} + group={item} + isToolOpen={(toolId) => 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 = ({ </div> ); }; + +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 ( + <Collapsible + open={open} + onOpenChange={setOpen} + className="not-prose mb-4 w-full rounded-md border" + > + <CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3"> + <div className="flex min-w-0 flex-1 items-center gap-2"> + <WrenchIcon className="size-4 shrink-0 text-muted-foreground" /> + <div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}> + <AnimatePresence mode="popLayout" initial={false}> + <motion.span + key={summary} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -10 }} + transition={{ duration: 0.18, ease: 'easeOut' }} + className="absolute inset-0 truncate text-left font-medium text-sm leading-5" + title={summary} + > + {summary} + </motion.span> + </AnimatePresence> + </div> + </div> + <div className="flex shrink-0 items-center gap-3"> + {getStatusBadge(state)} + <ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} /> + </div> + </CollapsibleTrigger> + <CollapsibleContent className="border-t"> + <div className="flex flex-col gap-2 p-2"> + {group.items.map((tool) => { + const toolState = toToolState(tool.status) + const isOpen = isToolOpen(tool.id) + return ( + <Tool + key={tool.id} + open={isOpen} + onOpenChange={(o) => onToolOpenChange(tool.id, o)} + className="mb-0 border-border/60" + > + <ToolHeader + title={getToolDisplayName(tool)} + type={`tool-${tool.name}`} + state={toolState} + /> + <ToolContent> + <ToolTabbedContent + input={tool.input as ToolUIPart["input"]} + output={tool.result as ToolUIPart["output"]} + errorText={tool.status === 'error' ? 'Tool error' : undefined} + /> + </ToolContent> + </Tool> + ) + })} + </div> + </CollapsibleContent> + </Collapsible> + ) +} 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({ </ConversationEmptyState> ) : ( <> - {tabState.conversation.map((item) => { + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) + ).map((item) => { + if (isToolGroup(item)) { + return ( + <ToolGroupComponent + key={item.groupId} + group={item} + isToolOpen={(toolId) => 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<string>() + 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 027/143] 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) <noreply@anthropic.com> --- 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<void> { + try { + if (!(await isSignedIn())) return; + const billing = await getBillingInfo(); + if (!billing.userId) return; + identify(billing.userId, { + ...(billing.userEmail ? { email: billing.userEmail } : {}), + plan: billing.subscriptionPlan, + status: billing.subscriptionStatus, + }); + } catch (err) { + console.error('[Analytics] startup identify failed:', err); + } +} From 1c2b2ac1fc8d00fc7d4f09077a96b9e539e1b3a0 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Mon, 4 May 2026 15:47:30 +0530 Subject: [PATCH 028/143] feat: native desktop notifications + rowboat:// deep links Adds INotificationService with an Electron implementation, plus a deep-link dispatcher (rowboat://) for routing notification clicks back into the app. Notifications: - New `notify-user` skill + builtin tool. Title, message, optional primary link, optional secondary actions. Supports https:// (opens in browser) and rowboat:// (opens in app) targets. - ElectronNotificationService holds strong refs to active Notification instances so click handlers survive GC (otherwise macOS click silently no-ops). - Calendar meeting notifier fires 1-min warnings with "take notes" / "join + take notes" actions backed by deep links. Deep links (rowboat://): - forge.config.cjs declares the protocol; main.ts wires single-instance lock, setAsDefaultProtocolClient, open-url (mac), second-instance (win/ linux), and first-launch argv extraction. - New deeplink.ts dispatcher with dispatchUrl(url): main-handled actions (rowboat://action?type=...) vs renderer navigation (rowboat://open?...) via app:openUrl IPC. Includes pending-URL buffering for first-launch delivery before the renderer is ready. - Renderer parseDeepLink supports file / chat / graph / task / suggested-topics targets. - New app:consumePendingDeepLink IPC for renderer one-time drain on mount. Refactor: extractConferenceLink moved out of calendar-block.tsx into shared lib/calendar-event.ts (used by both the block and the take-notes deep-link handler) --- apps/x/apps/main/forge.config.cjs | 3 + apps/x/apps/main/src/deeplink.ts | 118 ++++++++++++ apps/x/apps/main/src/ipc.ts | 4 + apps/x/apps/main/src/main.ts | 54 +++++- .../electron-notification-service.ts | 84 ++++++++ apps/x/apps/renderer/src/App.tsx | 92 +++++++++ .../src/extensions/calendar-block.tsx | 20 +- .../x/apps/renderer/src/lib/calendar-event.ts | 15 ++ .../src/application/assistant/instructions.ts | 2 + .../src/application/assistant/skills/index.ts | 7 + .../assistant/skills/notify-user/skill.ts | 70 +++++++ .../core/src/application/lib/builtin-tools.ts | 41 ++++ .../src/application/notification/service.ts | 12 ++ apps/x/packages/core/src/di/container.ts | 7 + .../src/knowledge/notify_calendar_meetings.ts | 180 ++++++++++++++++++ .../core/src/knowledge/track/run-agent.ts | 1 + apps/x/packages/shared/src/ipc.ts | 22 +++ 17 files changed, 712 insertions(+), 20 deletions(-) create mode 100644 apps/x/apps/main/src/deeplink.ts create mode 100644 apps/x/apps/main/src/notification/electron-notification-service.ts create mode 100644 apps/x/apps/renderer/src/lib/calendar-event.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts create mode 100644 apps/x/packages/core/src/application/notification/service.ts create mode 100644 apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 178cb7e1..ad639a86 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,6 +11,9 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', + protocols: [ + { name: 'Rowboat', schemes: ['rowboat'] }, + ], extendInfo: { NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', }, diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts new file mode 100644 index 00000000..605990d1 --- /dev/null +++ b/apps/x/apps/main/src/deeplink.ts @@ -0,0 +1,118 @@ +import { BrowserWindow } from "electron"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { WorkDir } from "@x/core/dist/config/config.js"; + +export const DEEP_LINK_SCHEME = "rowboat"; +const URL_PREFIX = `${DEEP_LINK_SCHEME}://`; +const ACTION_HOST = "action"; + +let pendingUrl: string | null = null; +let mainWindowRef: BrowserWindow | null = null; + +export function setMainWindowForDeepLinks(win: BrowserWindow | null): void { + mainWindowRef = win; +} + +export function consumePendingDeepLink(): string | null { + const url = pendingUrl; + pendingUrl = null; + return url; +} + +export function extractDeepLinkFromArgv(argv: readonly string[]): string | null { + for (const arg of argv) { + if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg; + } + return null; +} + +/** + * Dispatch any rowboat:// URL — chooses navigation vs action automatically. + * Use this from notification click handlers and other URL entry points. + */ +export function dispatchUrl(url: string): void { + if (parseAction(url)) { + void dispatchAction(url); + } else { + dispatchDeepLink(url); + } +} + +export function dispatchDeepLink(url: string): void { + if (!url.startsWith(URL_PREFIX)) return; + + pendingUrl = url; + + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + if (win.webContents.isLoading()) return; + + win.webContents.send("app:openUrl", { url }); + pendingUrl = null; +} + +interface MeetingNotesAction { + type: "take-meeting-notes" | "join-and-take-meeting-notes"; + eventId: string; +} + +type ParsedAction = MeetingNotesAction; + +function parseAction(url: string): ParsedAction | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, ""); + if (host !== ACTION_HOST) return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const type = params.get("type"); + if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { + const eventId = params.get("eventId"); + return eventId ? { type, eventId } : null; + } + return null; +} + +async function dispatchAction(url: string): Promise<void> { + const parsed = parseAction(url); + if (!parsed) return; + + const openMeeting = parsed.type === "join-and-take-meeting-notes"; + await handleTakeMeetingNotes(parsed.eventId, openMeeting); +} + +async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> { + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); + let event: unknown; + try { + const raw = await fs.readFile(filePath, "utf-8"); + event = JSON.parse(raw); + } catch (err) { + console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); + return; + } + + const payload = { event, openMeeting }; + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("app:takeMeetingNotes", payload); + }); + return; + } + + win.webContents.send("app:takeMeetingNotes", payload); +} + +function focusWindow(win: BrowserWindow): void { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5e62e8ee..d70192cc 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -34,6 +34,7 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { consumePendingDeepLink } from './deeplink.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; @@ -417,6 +418,9 @@ export function setupIpcHandlers() { // args is null for this channel (no request payload) return getVersions(); }, + 'app:consumePendingDeepLink': async () => { + return { url: consumePendingDeepLink() }; + }, 'analytics:bootstrap': async () => { return { installationId: getInstallationId(), diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 99c77589..cd0717a4 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -23,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; +import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; @@ -34,10 +35,17 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService } from "@x/core/dist/di/container.js"; +import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; +import { ElectronNotificationService } from "./notification/electron-notification-service.js"; +import { + DEEP_LINK_SCHEME, + dispatchDeepLink, + extractDeepLinkFromArgv, + setMainWindowForDeepLinks, +} from "./deeplink.js"; const execAsync = promisify(exec); @@ -47,6 +55,43 @@ const __dirname = dirname(__filename); // run this as early in the main process as possible if (started) app.quit(); +// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link) +// back into the existing process via the 'second-instance' event. +if (!app.requestSingleInstanceLock()) { + app.quit(); + process.exit(0); +} + +// Register as the OS handler for rowboat:// URLs. +// In dev, point at the right argv so the OS can re-invoke us correctly. +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [ + path.resolve(process.argv[1]), + ]); + } +} else { + app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME); +} + +// First-launch URL on Windows/Linux comes through argv. +{ + const initialUrl = extractDeepLinkFromArgv(process.argv); + if (initialUrl) dispatchDeepLink(initialUrl); +} + +// macOS sends URLs via 'open-url' (both first launch and while running). +app.on("open-url", (event, url) => { + event.preventDefault(); + dispatchDeepLink(url); +}); + +// Subsequent launches on Windows/Linux land here via the single-instance lock. +app.on("second-instance", (_event, argv) => { + const url = extractDeepLinkFromArgv(argv); + if (url) dispatchDeepLink(url); +}); + // Fix PATH for packaged Electron apps on macOS/Linux. // Packaged apps inherit a minimal environment that doesn't include paths from // the user's shell profile (such as those provided by nvm, Homebrew, etc.). @@ -165,6 +210,9 @@ function createWindow() { configureSessionPermissions(session.defaultSession); configureSessionPermissions(session.fromPartition(BROWSER_PARTITION)); + setMainWindowForDeepLinks(win); + win.on("closed", () => setMainWindowForDeepLinks(null)); + // Show window when content is ready to prevent blank screen win.once("ready-to-show", () => { win.maximize(); @@ -240,6 +288,7 @@ app.whenReady().then(async () => { }); registerBrowserControlService(new ElectronBrowserControlService()); + registerNotificationService(new ElectronNotificationService()); setupIpcHandlers(); setupBrowserEventForwarding(); @@ -298,6 +347,9 @@ app.whenReady().then(async () => { // start agent notes learning service initAgentNotes(); + // start calendar meeting notification service (fires 1-minute warnings) + initCalendarNotifications(); + // start chrome extension sync server initChromeSync(); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts new file mode 100644 index 00000000..dd37e37d --- /dev/null +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -0,0 +1,84 @@ +import { BrowserWindow, Notification, shell } from "electron"; +import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { dispatchUrl } from "../deeplink.js"; + +const HTTP_URL = /^https?:\/\//i; +const ROWBOAT_URL = /^rowboat:\/\//i; + +export class ElectronNotificationService implements INotificationService { + // Holds strong references to active Notification instances so the GC can't + // collect them while they're still visible — without this, the click handler + // gets dropped and macOS clicks just focus the app silently. + private active = new Set<Notification>(); + + isSupported(): boolean { + return Notification.isSupported(); + } + + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + // Build the actions array AND a parallel index → link map. + // macOS shows actions[0] inline (Banner) or all of them (Alert); + // additional ones live behind the chevron menu. + const actionDefs: Electron.NotificationConstructorOptions["actions"] = []; + const actionLinks: string[] = []; + + const primaryLabel = actionLabel?.trim(); + if (link && primaryLabel) { + actionDefs!.push({ type: "button", text: primaryLabel }); + actionLinks.push(link); + } + if (secondaryActions) { + for (const sa of secondaryActions) { + actionDefs!.push({ type: "button", text: sa.label }); + actionLinks.push(sa.link); + } + } + + const notification = new Notification({ + title, + body: message, + actions: actionDefs, + }); + + this.active.add(notification); + const release = () => { this.active.delete(notification); }; + + const openLink = (target: string | undefined) => { + if (target && ROWBOAT_URL.test(target)) { + dispatchUrl(target); + } else if (target && HTTP_URL.test(target)) { + shell.openExternal(target).catch((err) => { + console.error("[notification] failed to open link:", err); + }); + } else { + this.focusMainWindow(); + } + release(); + }; + + // Body click: always opens the primary `link` (or focuses the app if none). + notification.on("click", () => openLink(link)); + + // Action button click: dispatch by index into the actions array. + notification.on("action", (_event, index) => { + if (index >= 0 && index < actionLinks.length) { + openLink(actionLinks[index]); + } else { + openLink(undefined); + } + }); + + notification.on("close", release); + notification.on("failed", release); + + notification.show(); + } + + private focusMainWindow(): void { + const [win] = BrowserWindow.getAllWindows(); + if (!win) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + } +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0e925e2e..7c749664 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -54,6 +54,7 @@ import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' +import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { TrackModal } from '@/components/track-modal' @@ -515,6 +516,45 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { return true // both graph } +/** + * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is + * malformed or names an unknown target. + * + * Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics>&... + * file: ?type=file&path=knowledge/foo.md + * chat: ?type=chat&runId=abc123 (runId optional) + * graph: ?type=graph + * task: ?type=task&name=daily-brief + * suggested-topics: ?type=suggested-topics + */ +function parseDeepLink(input: string): ViewState | null { + const SCHEME = 'rowboat://' + if (!input.startsWith(SCHEME)) return null + const rest = input.slice(SCHEME.length) + const queryIdx = rest.indexOf('?') + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '') + if (host !== 'open') return null + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '') + switch (params.get('type')) { + case 'file': { + const path = params.get('path') + return path ? { type: 'file', path } : null + } + case 'chat': + return { type: 'chat', runId: params.get('runId') || null } + case 'graph': + return { type: 'graph' } + case 'task': { + const name = params.get('name') + return name ? { type: 'task', name } : null + } + case 'suggested-topics': + return { type: 'suggested-topics' } + default: + return null + } +} + /** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ leftInsetPx, @@ -3050,6 +3090,58 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + // Deep-link handler kept in a ref so the useEffect below can register the + // IPC listener (and run the one-time pending-link drain) just once on mount, + // rather than re-running on every navigation when navigateToView's identity + // changes. + const navigateToViewRef = useRef(navigateToView) + useEffect(() => { navigateToViewRef.current = navigateToView }, [navigateToView]) + + useEffect(() => { + const handle = (url: string) => { + const view = parseDeepLink(url) + if (view) void navigateToViewRef.current(view) + } + void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => { + if (url) handle(url) + }) + return window.ipc.on('app:openUrl', ({ url }) => handle(url)) + }, []) + + // Triggered by main when the user clicks a calendar-meeting notification. + // Reuses the same flow as the in-app "Join meeting & take notes" button. + // When `openMeeting` is true, also opens the meeting URL in the system browser. + useEffect(() => { + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { + const e = event as { + summary?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } + location?: string + htmlLink?: string + hangoutLink?: string + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + } + if (!e || typeof e !== 'object') return + const conferenceLink = extractConferenceLink(e as Record<string, unknown>) + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) + } + window.__pendingCalendarEvent = { + summary: e.summary, + start: e.start, + end: e.end, + location: e.location, + htmlLink: e.htmlLink, + conferenceLink, + source: 'calendar-sync', + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) + }) + }, []) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) diff --git a/apps/x/apps/renderer/src/extensions/calendar-block.tsx b/apps/x/apps/renderer/src/extensions/calendar-block.tsx index 9f0eec02..ecc5403d 100644 --- a/apps/x/apps/renderer/src/extensions/calendar-block.tsx +++ b/apps/x/apps/renderer/src/extensions/calendar-block.tsx @@ -3,6 +3,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react' import { blocks } from '@x/shared' import { useState, useEffect, useRef } from 'react' +import { extractConferenceLink } from '../lib/calendar-event' function formatTime(dateStr: string): string { const d = new Date(dateStr) @@ -40,25 +41,6 @@ function getTimeRange(event: blocks.CalendarEvent): string { return `${startTime} \u2013 ${endTime}` } -/** - * Extract a video conference link from raw Google Calendar event JSON. - * Checks conferenceData.entryPoints (video type), hangoutLink, then falls back - * to conferenceLink if already set. - */ -function extractConferenceLink(raw: Record<string, unknown>): string | undefined { - // Check conferenceData.entryPoints for video entry - const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined - if (confData?.entryPoints) { - const video = confData.entryPoints.find(ep => ep.entryPointType === 'video') - if (video?.uri) return video.uri - } - // Check hangoutLink (Google Meet shortcut) - if (typeof raw.hangoutLink === 'string') return raw.hangoutLink - // Fall back to conferenceLink if present - if (typeof raw.conferenceLink === 'string') return raw.conferenceLink - return undefined -} - interface ResolvedEvent { event: blocks.CalendarEvent loaded: blocks.CalendarEvent | null diff --git a/apps/x/apps/renderer/src/lib/calendar-event.ts b/apps/x/apps/renderer/src/lib/calendar-event.ts new file mode 100644 index 00000000..b7ace75a --- /dev/null +++ b/apps/x/apps/renderer/src/lib/calendar-event.ts @@ -0,0 +1,15 @@ +/** + * Extract a video conference link from raw Google Calendar event JSON. + * Checks conferenceData.entryPoints (video type), hangoutLink, then falls back + * to a top-level conferenceLink if present. + */ +export function extractConferenceLink(raw: Record<string, unknown>): string | undefined { + const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined + if (confData?.entryPoints) { + const video = confData.entryPoints.find(ep => ep.entryPointType === 'video') + if (video?.uri) return video.uri + } + if (typeof raw.hangoutLink === 'string') return raw.hangoutLink + if (typeof raw.conferenceLink === 'string') return raw.conferenceLink + return undefined +} diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index af2d7a20..a455d845 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -85,6 +85,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards. **Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane. +**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it. + ## Learning About the User (save-to-memory) diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index cad23177..6d3cdc5b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -14,6 +14,7 @@ import appNavigationSkill from "./app-navigation/skill.js"; import browserControlSkill from "./browser-control/skill.js"; import composioIntegrationSkill from "./composio-integration/skill.js"; import tracksSkill from "./tracks/skill.js"; +import notifyUserSkill from "./notify-user/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -112,6 +113,12 @@ const definitions: SkillDefinition[] = [ summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.", content: browserControlSkill, }, + { + id: "notify-user", + title: "Notify User", + summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.", + content: notifyUserSkill, + }, ]; const skillEntries = definitions.map((definition) => ({ diff --git a/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts new file mode 100644 index 00000000..9bc619be --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts @@ -0,0 +1,70 @@ +export const skill = String.raw` +# Notify User + +Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result. + +## When to use +- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive. +- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit — restraint is on you). + +## The tool: \`notify-user\` + +Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click. + +### Parameters +- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top. +- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines. +- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted: + - **\`https://...\` / \`http://...\`** — opens in the default browser + - **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below) + - If omitted, clicking the notification focuses the Rowboat app. + +### Examples + +Plain alert (no link — clicking focuses the app): +\`\`\`json +{ + "title": "Backup complete", + "message": "All 142 files synced to iCloud." +} +\`\`\` + +External link: +\`\`\`json +{ + "title": "New email from Monica", + "message": "Re: Q4 planning — needs your input by Friday", + "link": "https://mail.google.com/mail/u/0/#inbox/abc123" +} +\`\`\` + +Deep link into a Rowboat note: +\`\`\`json +{ + "message": "Daily brief is ready", + "link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md" +} +\`\`\` + +## Deep links: \`rowboat://\` + +Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters. + +| Target | Format | Example | +|---|---|---| +| Open a file | \`rowboat://open?type=file&path=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` | +| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` | +| Knowledge graph | \`rowboat://open?type=graph\` | — | +| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` | +| Suggested topics | \`rowboat://open?type=suggested-topics\` | — | + +The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`). + +## Anti-patterns +- **Don't notify per step** of a multi-step task. Notify on completion, not on progress. +- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification. +- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link. +- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done". +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 4fd347b6..65b398a1 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -29,6 +29,7 @@ import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; import type { IBrowserControlService } from "../browser-control/service.js"; +import type { INotificationService } from "../notification/service.js"; // Parser libraries are loaded dynamically inside parseFile.execute() // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // Import paths are computed so esbuild cannot statically resolve them. @@ -1526,4 +1527,44 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { } }, }, + + 'notify-user': { + description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.", + inputSchema: z.object({ + title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."), + message: z.string().min(1).describe("Body text of the notification."), + link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { + message: "link must be an http(s):// or rowboat:// URL", + }).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."), + actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."), + secondaryActions: z.array(z.object({ + label: z.string().min(1).max(30), + link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { + message: "secondary action link must be an http(s):// or rowboat:// URL", + }), + })).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."), + }), + isAvailable: async () => { + try { + return container.resolve<INotificationService>('notificationService').isSupported(); + } catch { + return false; + } + }, + execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => { + try { + const service = container.resolve<INotificationService>('notificationService'); + if (!service.isSupported()) { + return { success: false, error: 'Notifications are not supported on this system' }; + } + service.notify({ title, message, link, actionLabel, secondaryActions }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, }; diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts new file mode 100644 index 00000000..195315b1 --- /dev/null +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -0,0 +1,12 @@ +export interface NotifyInput { + title?: string; + message: string; + link?: string; + actionLabel?: string; + secondaryActions?: Array<{ label: string; link: string }>; +} + +export interface INotificationService { + isSupported(): boolean; + notify(input: NotifyInput): void; +} diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 93ba9ebd..9382de8b 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -16,6 +16,7 @@ import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo. import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; import type { IBrowserControlService } from "../application/browser-control/service.js"; +import type { INotificationService } from "../application/notification/service.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService): browserControlService: asValue(service), }); } + +export function registerNotificationService(service: INotificationService): void { + container.register({ + notificationService: asValue(service), + }); +} diff --git a/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts new file mode 100644 index 00000000..cca9d230 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts @@ -0,0 +1,180 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import { WorkDir } from "../config/config.js"; +import container from "../di/container.js"; +import type { INotificationService } from "../application/notification/service.js"; + +const TICK_INTERVAL_MS = 30_000; +// Notify when an event is between 30s in the past (started just now) and +// 90s in the future (about to start). The window is wider than 60s so we +// don't miss an event if the tick lands slightly off the start time. +const NOTIFY_LEAD_MS = 90_000; +const NOTIFY_GRACE_MS = 30_000; +// Drop state entries older than 24h so the file doesn't grow forever. +const STATE_TTL_MS = 24 * 60 * 60 * 1000; + +const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync"); +const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json"); + +interface NotificationState { + notifiedEventIds: Record<string, { notifiedAt: string; startTime: string }>; +} + +interface CalendarEvent { + id?: string; + summary?: string; + status?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string }; + attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>; + hangoutLink?: string; + conferenceData?: { + entryPoints?: Array<{ entryPointType?: string; uri?: string }>; + }; +} + +async function loadState(): Promise<NotificationState> { + try { + const raw = await fs.readFile(STATE_FILE, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) { + return parsed as NotificationState; + } + } catch { + // No state file yet, or corrupt — start fresh. + } + return { notifiedEventIds: {} }; +} + +async function saveState(state: NotificationState): Promise<void> { + // Write to a sibling tmp file then rename so a mid-write crash can't leave + // the JSON corrupt — a corrupt state file would make every event in the + // 90s notify window re-fire on next start. + const tmp = `${STATE_FILE}.tmp`; + await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8"); + await fs.rename(tmp, STATE_FILE); +} + +function gcState(state: NotificationState): NotificationState { + const cutoff = Date.now() - STATE_TTL_MS; + const fresh: NotificationState["notifiedEventIds"] = {}; + for (const [id, entry] of Object.entries(state.notifiedEventIds)) { + const ts = Date.parse(entry.notifiedAt); + if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry; + } + return { notifiedEventIds: fresh }; +} + +function isAllDay(event: CalendarEvent): boolean { + // Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`. + return !event.start?.dateTime; +} + +function isDeclinedBySelf(event: CalendarEvent): boolean { + if (!event.attendees) return false; + const self = event.attendees.find((a) => a.self); + return self?.responseStatus === "declined"; +} + +async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> { + let entries: Dirent[]; + try { + entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true }); + } catch { + return { state, dirty: false }; + } + + let service: INotificationService; + try { + service = container.resolve<INotificationService>("notificationService"); + } catch { + // Notification service not registered yet (very early startup) — skip this tick. + return { state, dirty: false }; + } + if (!service.isSupported()) return { state, dirty: false }; + + const now = Date.now(); + let dirty = false; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue; + + const eventId = entry.name.replace(/\.json$/, ""); + if (state.notifiedEventIds[eventId]) continue; + + const filePath = path.join(CALENDAR_SYNC_DIR, entry.name); + let event: CalendarEvent; + try { + event = JSON.parse(await fs.readFile(filePath, "utf-8")); + } catch { + continue; + } + + if (event.status === "cancelled") continue; + if (isAllDay(event)) continue; + if (isDeclinedBySelf(event)) continue; + + const startStr = event.start?.dateTime; + if (!startStr) continue; + const startMs = Date.parse(startStr); + if (!Number.isFinite(startMs)) continue; + + const msUntilStart = startMs - now; + if (msUntilStart > NOTIFY_LEAD_MS) continue; + if (msUntilStart < -NOTIFY_GRACE_MS) continue; + + const summary = event.summary?.trim() || "Untitled meeting"; + const eid = encodeURIComponent(eventId); + + try { + service.notify({ + title: "Upcoming meeting", + message: `${summary} starts in 1 minute. Click to join and take notes.`, + // Single labeled button — adding a secondary action would force + // macOS to bundle them into an "Options" dropdown, hiding the + // primary label. + link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`, + actionLabel: "Join & Notes", + }); + console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`); + } catch (err) { + console.error(`[CalendarNotify] notify failed for ${eventId}:`, err); + continue; + } + + state.notifiedEventIds[eventId] = { + notifiedAt: new Date().toISOString(), + startTime: startStr, + }; + dirty = true; + } + + return { state, dirty }; +} + +export async function init(): Promise<void> { + console.log("[CalendarNotify] starting calendar notification service"); + console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`); + + let state = gcState(await loadState()); + + while (true) { + try { + const result = await tick(state); + state = result.state; + if (result.dirty) { + state = gcState(state); + try { + await saveState(state); + } catch (err) { + console.error("[CalendarNotify] failed to save state:", err); + } + } + } catch (err) { + console.error("[CalendarNotify] tick failed:", err); + } + await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS)); + } +} diff --git a/apps/x/packages/core/src/knowledge/track/run-agent.ts b/apps/x/packages/core/src/knowledge/track/run-agent.ts index d93366f3..685305b2 100644 --- a/apps/x/packages/core/src/knowledge/track/run-agent.ts +++ b/apps/x/packages/core/src/knowledge/track/run-agent.ts @@ -263,6 +263,7 @@ You have the full workspace toolkit. Quick reference for common cases: - **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files. - **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized. - **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering. +- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view). # The Knowledge Graph diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 575f8395..ab7d7f73 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -299,6 +299,28 @@ const ipcSchemas = { }), res: z.null(), }, + 'app:openUrl': { + req: z.object({ + url: z.string(), + }), + res: z.null(), + }, + 'app:takeMeetingNotes': { + req: z.object({ + // Pass the raw calendar event JSON through; renderer adapts to its existing flow. + event: z.unknown(), + // When true, the renderer should also open the meeting URL (Zoom/Meet/etc.) + // in addition to triggering the take-notes flow. + openMeeting: z.boolean().optional(), + }), + res: z.null(), + }, + 'app:consumePendingDeepLink': { + req: z.null(), + res: z.object({ + url: z.string().nullable(), + }), + }, 'granola:getConfig': { req: z.null(), res: z.object({ From 93feee15a051a0839141d2850c55e6fa80da7e0d Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 4 May 2026 17:20:19 +0530 Subject: [PATCH 029/143] fixed collapsed sidebar issue on chat --- apps/x/apps/renderer/src/App.tsx | 1 + .../x/apps/renderer/src/components/chat-sidebar.tsx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7c749664..0321aaed 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4850,6 +4850,7 @@ function App() { onToolOpenChangeForTab={setToolOpenForTab} onOpenKnowledgeFile={(path) => { navigateToFile(path) }} onActivate={() => setActiveShortcutPane('right')} + collapsedLeftPaddingPx={collapsedLeftPaddingPx} isRecording={isRecording} recordingText={voice.interimText} recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 07f1b637..6fa295b1 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -30,6 +30,7 @@ import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab } from '@/components/tab-bar' import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' +import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' import { type ChatViewportAnchorState, @@ -177,6 +178,7 @@ interface ChatSidebarProps { onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void onOpenKnowledgeFile?: (path: string) => void onActivate?: () => void + collapsedLeftPaddingPx?: number // Voice / TTS props isRecording?: boolean recordingText?: string @@ -231,6 +233,7 @@ export function ChatSidebar({ onToolOpenChangeForTab, onOpenKnowledgeFile, onActivate, + collapsedLeftPaddingPx = 196, isRecording, recordingText, recordingState, @@ -245,6 +248,7 @@ export function ChatSidebar({ onTtsModeChange, onComposioConnected, }: ChatSidebarProps) { + const { state: sidebarState } = useSidebar() const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth)) const [isResizing, setIsResizing] = useState(false) const [showContent, setShowContent] = useState(isOpen) @@ -519,7 +523,14 @@ export function ChatSidebar({ {showContent && ( <> - <header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar"> + <header + className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar" + style={{ + paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined, + paddingRight: isMaximized ? 12 : undefined, + transition: isMaximized ? 'padding-left 200ms linear' : undefined, + }} + > <TabBar tabs={chatTabs} activeTabId={activeChatTabId} From 0bd234ddf62e824d114c56d111a188ba82018a7e Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 4 May 2026 17:28:04 +0530 Subject: [PATCH 030/143] fix browser reload issue --- apps/x/apps/main/src/browser/view.ts | 35 ++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index d319c5fb..b540809d 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -109,10 +109,31 @@ export class BrowserViewManager extends EventEmitter { private visible = false; private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; private snapshotCache = new Map<string, CachedSnapshot>(); + private cleanupWindowListeners: (() => void) | null = null; attach(window: BrowserWindow): void { + this.cleanupWindowListeners?.(); this.window = window; - window.on('closed', () => { + + const resetForHostWindowNavigation = () => { + // Renderer refreshes do not run React unmount cleanup reliably, so the + // native browser view must be detached from the main process side. + this.visible = false; + this.bounds = { x: 0, y: 0, width: 0, height: 0 }; + this.syncAttachedView(); + }; + + const handleDidStartLoading = () => { + resetForHostWindowNavigation(); + }; + + const handleRenderProcessGone = () => { + resetForHostWindowNavigation(); + }; + + const handleClosed = () => { + this.cleanupWindowListeners?.(); + this.cleanupWindowListeners = null; this.window = null; this.browserSession = null; this.tabs.clear(); @@ -121,7 +142,17 @@ export class BrowserViewManager extends EventEmitter { this.attachedTabId = null; this.visible = false; this.snapshotCache.clear(); - }); + }; + + window.webContents.on('did-start-loading', handleDidStartLoading); + window.webContents.on('render-process-gone', handleRenderProcessGone); + window.on('closed', handleClosed); + + this.cleanupWindowListeners = () => { + window.webContents.removeListener('did-start-loading', handleDidStartLoading); + window.webContents.removeListener('render-process-gone', handleRenderProcessGone); + window.removeListener('closed', handleClosed); + }; } private getSession(): Session { From a76f8bae14a4f7208d513144a19dd2ce539bfb1b Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 11:41:08 +0530 Subject: [PATCH 031/143] fix sticky browser issue --- apps/x/apps/renderer/src/App.tsx | 37 ++++++++++++++----- .../components/browser-pane/BrowserPane.tsx | 11 +++++- .../src/components/sidebar-content.tsx | 27 +++++++++++--- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0321aaed..d0ed5284 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2400,6 +2400,10 @@ function App() { } }, [runId]) + const dismissBrowserOverlay = useCallback(() => { + setIsBrowserOpen(false) + }, []) + const handleNewChat = useCallback(() => { // Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in) loadRunRequestIdRef.current += 1 @@ -2623,6 +2627,7 @@ function App() { // File tab operations const openFileInNewTab = useCallback((path: string) => { + dismissBrowserOverlay() const existingTab = fileTabs.find(t => t.path === path) if (existingTab) { setActiveFileTabId(existingTab.id) @@ -2635,11 +2640,12 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setSelectedPath(path) - }, [fileTabs]) + }, [fileTabs, dismissBrowserOverlay]) const switchFileTab = useCallback((tabId: string) => { const tab = fileTabs.find(t => t.id === tabId) if (!tab) return + dismissBrowserOverlay() setActiveFileTabId(tabId) setSelectedBackgroundTask(null) setExpandedFrom(null) @@ -2662,7 +2668,7 @@ function App() { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) setSelectedPath(tab.path) - }, [fileTabs, isRightPaneMaximized]) + }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) @@ -2734,8 +2740,9 @@ function App() { // Create a new tab const id = newChatTabId() setChatTabs(prev => [...prev, { id, runId: null }]) - setActiveChatTabId(id) + setActiveChatTabId(id) } + dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { @@ -2747,7 +2754,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2865,11 +2872,12 @@ function App() { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) } + dismissBrowserOverlay() setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { @@ -3004,8 +3012,7 @@ function App() { case 'chat': setSelectedPath(null) setIsGraphOpen(false) - // Don't touch isBrowserOpen here — chat navigation should land in - // the right sidebar when the browser overlay is active. + setIsBrowserOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) @@ -3021,7 +3028,12 @@ function App() { const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState - if (viewStatesEqual(current, nextView)) return + if (viewStatesEqual(current, nextView)) { + if (isBrowserOpen) { + dismissBrowserOverlay() + } + return + } cancelRecordingIfActive() const nextHistory = { @@ -3030,7 +3042,7 @@ function App() { } setHistory(nextHistory) await applyViewState(nextView) - }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory]) + }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -4329,6 +4341,8 @@ function App() { meetingSummarizing={meetingSummarizing} meetingAvailable={voiceAvailable} onToggleMeeting={() => { void handleToggleMeeting() }} + isSearchOpen={isSearchOpen} + isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} @@ -4463,7 +4477,10 @@ function App() { </ContentHeader> {isBrowserOpen ? ( - <BrowserPane onClose={handleCloseBrowser} /> + <BrowserPane + onClose={handleCloseBrowser} + forceHidden={isSearchOpen || showMeetingPermissions} + /> ) : isSuggestedTopicsOpen ? ( <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <SuggestedTopicsView diff --git a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx index 8777c035..a1270706 100644 --- a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx @@ -49,6 +49,7 @@ const BLOCKING_OVERLAY_SLOTS = new Set([ interface BrowserPaneProps { onClose: () => void + forceHidden?: boolean } const getActiveTab = (state: BrowserState) => @@ -85,7 +86,7 @@ const getBrowserTabTitle = (tab: BrowserTabState) => { } } -export function BrowserPane({ onClose }: BrowserPaneProps) { +export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) { const [state, setState] = useState<BrowserState>(EMPTY_STATE) const [addressValue, setAddressValue] = useState('') @@ -175,6 +176,12 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { }, []) const syncView = useCallback(() => { + if (forceHidden) { + lastBoundsRef.current = null + setViewVisible(false) + return null + } + const doc = viewportRef.current?.ownerDocument if (doc && hasBlockingOverlay(doc)) { lastBoundsRef.current = null @@ -191,7 +198,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { pushBounds(bounds) setViewVisible(true) return bounds - }, [measureBounds, pushBounds, setViewVisible]) + }, [forceHidden, measureBounds, pushBounds, setViewVisible]) useEffect(() => { syncView() diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 41d6b622..7e204781 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -186,6 +186,8 @@ type SidebarContentPanelProps = { meetingSummarizing?: boolean meetingAvailable?: boolean onToggleMeeting?: () => void + isSearchOpen?: boolean + isMeetingActionActive?: boolean isBrowserOpen?: boolean onToggleBrowser?: () => void isSuggestedTopicsOpen?: boolean @@ -420,6 +422,8 @@ export function SidebarContentPanel({ meetingSummarizing = false, meetingAvailable = false, onToggleMeeting, + isSearchOpen = false, + isMeetingActionActive = false, isBrowserOpen = false, onToggleBrowser, isSuggestedTopicsOpen = false, @@ -436,6 +440,9 @@ export function SidebarContentPanel({ const [loggingIn, setLoggingIn] = useState(false) const [appUrl, setAppUrl] = useState<string | null>(null) const { billing } = useBilling(isRowboatConnected) + const isMeetingQuickActionSelected = isMeetingActionActive + const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected + const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen const handleRowboatLogin = useCallback(async () => { try { @@ -533,7 +540,12 @@ export function SidebarContentPanel({ <button type="button" onClick={onOpenSearch} - className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors" + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", + isSearchOpen + ? "bg-sidebar-accent text-sidebar-accent-foreground" + : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + )} > <SearchIcon className="size-4" /> <span>Search</span> @@ -546,9 +558,14 @@ export function SidebarContentPanel({ disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing} className={cn( "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none", + isMeetingQuickActionSelected + ? "bg-sidebar-accent" + : "hover:bg-sidebar-accent", meetingState === 'recording' - ? "text-red-500 hover:bg-sidebar-accent" - : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + ? "text-red-500" + : isMeetingQuickActionSelected + ? "text-sidebar-accent-foreground" + : "text-sidebar-foreground/80 hover:text-sidebar-accent-foreground" )} > {meetingSummarizing || meetingState === 'connecting' ? ( @@ -575,7 +592,7 @@ export function SidebarContentPanel({ onClick={onToggleBrowser} className={cn( "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", - isBrowserOpen + isBrowserQuickActionSelected ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" )} @@ -590,7 +607,7 @@ export function SidebarContentPanel({ onClick={onOpenSuggestedTopics} className={cn( "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", - isSuggestedTopicsOpen + isSuggestedTopicsQuickActionSelected ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" )} From d4850dace70171afaf2b47389216b9aa919db23e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 5 May 2026 14:28:46 +0530 Subject: [PATCH 032/143] feat: native google sign-in for signed-in users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-in users can now connect Gmail and Calendar directly through Rowboat instead of going through Composio. Cleaner connection, no third-party in the data path. How it works: - Click "Connect Google" anywhere it appears (sidebar, onboarding, settings) and the system browser opens to a Rowboat-hosted page. Authorize Google there and the app picks up the connection automatically — no client id or secret to paste. - Token refresh happens through Rowboat's backend, so Google credentials never need to live on the user's machine. - Disconnect cleanly revokes access on Google's side too. Migration for existing Composio users: - A one-time modal explains that we've moved off Composio and asks the user to reconnect Google directly. - Their old Composio Gmail / Calendar connections are disconnected automatically when the modal first appears. - All previously-synced emails and calendar events are preserved on disk — the new connection picks up where Composio left off rather than re-downloading the last week from scratch. - "I'll do this later" dismisses the modal permanently; the user can still reconnect anytime via the connectors UI. (Sync stops in the meantime; nothing is deleted.) Other coverage: - BYOK mode (users who paste their own Google client id + secret) is unchanged — same modal, same local OAuth flow, same behavior. - Composio integrations for non-Google services (Slack, Linear, etc.) are unaffected. Only the Gmail and Calendar paths moved. - The "Connect Google" button label and connection state now apply uniformly to Gmail + Calendar (one OAuth grant covers both). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/x/apps/main/src/composio-handler.ts | 14 - apps/x/apps/main/src/deeplink.ts | 51 ++- apps/x/apps/main/src/ipc.ts | 8 +- apps/x/apps/main/src/main.ts | 8 +- apps/x/apps/main/src/oauth-handler.ts | 79 +++- apps/x/apps/renderer/src/App.tsx | 39 ++ .../composio-google-migration-modal.tsx | 81 ++++ .../src/components/onboarding-modal.tsx | 37 +- .../onboarding/use-onboarding-state.ts | 49 +-- .../apps/renderer/src/hooks/useConnectors.ts | 64 +-- .../core/src/auth/google-backend-oauth.ts | 113 +++++ apps/x/packages/core/src/auth/repo.ts | 7 + apps/x/packages/core/src/composio/client.ts | 20 - .../packages/core/src/config/remote-config.ts | 51 +++ .../core/src/knowledge/agent_notes.ts | 27 +- .../src/knowledge/google-client-factory.ts | 212 +++++++--- .../core/src/knowledge/sync_calendar.ts | 282 +----------- .../packages/core/src/knowledge/sync_gmail.ts | 400 +----------------- .../migrations/composio-google-migration.ts | 132 ++++++ apps/x/packages/shared/src/ipc.ts | 10 +- 20 files changed, 780 insertions(+), 904 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx create mode 100644 apps/x/packages/core/src/auth/google-backend-oauth.ts create mode 100644 apps/x/packages/core/src/config/remote-config.ts create mode 100644 apps/x/packages/core/src/migrations/composio-google-migration.ts diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 274cfb2a..8fc4b754 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -293,20 +293,6 @@ export function listConnected(): { toolkits: string[] } { return { toolkits: composioAccountsRepo.getConnectedToolkits() }; } -/** - * Check if Composio should be used for Google services (Gmail, etc.) - */ -export async function useComposioForGoogle(): Promise<{ enabled: boolean }> { - return { enabled: await composioClient.useComposioForGoogle() }; -} - -/** - * Check if Composio should be used for Google Calendar - */ -export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> { - return { enabled: await composioClient.useComposioForGoogleCalendar() }; -} - /** * List available Composio toolkits — filtered to curated list only. * Return type matches the ZToolkit schema from core/composio/types.ts. diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index 605990d1..aaaaa3bc 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -28,12 +28,19 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null } /** - * Dispatch any rowboat:// URL — chooses navigation vs action automatically. - * Use this from notification click handlers and other URL entry points. + * Dispatch any rowboat:// URL — chooses among action / oauth-completion / + * navigation automatically. Use this from notification click handlers and + * other URL entry points. + * + * OAuth completion (rowboat://oauth/google/done?session=<state>) is handled + * in main, not the renderer, because claiming tokens writes oauth.json and + * triggers sync — both main-process concerns. */ export function dispatchUrl(url: string): void { if (parseAction(url)) { void dispatchAction(url); + } else if (parseOAuthCompletion(url)) { + void dispatchOAuthCompletion(url); } else { dispatchDeepLink(url); } @@ -111,6 +118,46 @@ async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Pr win.webContents.send("app:takeMeetingNotes", payload); } +// --- OAuth completion (rowboat-mode Google connect) --- + +interface OAuthCompletion { + provider: "google"; + state: string; +} + +/** + * Match rowboat://oauth/google/done?session=<state>. Returns null for + * anything else — including paths with the right shape but wrong provider + * or a missing `session` query param. + */ +function parseOAuthCompletion(url: string): OAuthCompletion | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest; + const parts = path.split("/").filter(Boolean); + if (parts.length !== 3 || parts[0] !== "oauth" || parts[2] !== "done") return null; + if (parts[1] !== "google") return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const state = params.get("session"); + return state ? { provider: "google", state } : null; +} + +async function dispatchOAuthCompletion(url: string): Promise<void> { + const parsed = parseOAuthCompletion(url); + if (!parsed) return; + + // Bring the app to the front so the renderer can react to the + // oauthEvent IPC that completeRowboatGoogleConnect emits. + const win = mainWindowRef; + if (win && !win.isDestroyed()) focusWindow(win); + + // Lazy-import to keep deeplink.ts free of OAuth deps and avoid a + // potential circular dep with oauth-handler.ts. + const { completeRowboatGoogleConnect } = await import("./oauth-handler.js"); + await completeRowboatGoogleConnect(parsed.state); +} + function focusWindow(win: BrowserWindow): void { if (win.isMinimized()) win.restore(); win.show(); diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index d70192cc..056bb4c3 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -35,6 +35,7 @@ import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; import { consumePendingDeepLink } from './deeplink.js'; +import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; @@ -612,11 +613,8 @@ export function setupIpcHandlers() { 'composio:list-toolkits': async () => { return composioHandler.listToolkits(); }, - 'composio:use-composio-for-google': async () => { - return composioHandler.useComposioForGoogle(); - }, - 'composio:use-composio-for-google-calendar': async () => { - return composioHandler.useComposioForGoogleCalendar(); + 'migration:check-composio-google': async () => { + return qualifyAndDisconnectComposioGoogle(); }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index cd0717a4..c3618000 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -42,7 +42,7 @@ import { ElectronBrowserControlService } from "./browser/control-service.js"; import { ElectronNotificationService } from "./notification/electron-notification-service.js"; import { DEEP_LINK_SCHEME, - dispatchDeepLink, + dispatchUrl, extractDeepLinkFromArgv, setMainWindowForDeepLinks, } from "./deeplink.js"; @@ -77,19 +77,19 @@ if (process.defaultApp) { // First-launch URL on Windows/Linux comes through argv. { const initialUrl = extractDeepLinkFromArgv(process.argv); - if (initialUrl) dispatchDeepLink(initialUrl); + if (initialUrl) dispatchUrl(initialUrl); } // macOS sends URLs via 'open-url' (both first launch and while running). app.on("open-url", (event, url) => { event.preventDefault(); - dispatchDeepLink(url); + dispatchUrl(url); }); // Subsequent launches on Windows/Linux land here via the single-instance lock. app.on("second-instance", (_event, argv) => { const url = extractDeepLinkFromArgv(argv); - if (url) dispatchDeepLink(url); + if (url) dispatchUrl(url); }); // Fix PATH for packaged Electron apps on macOS/Linux. diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index d3caba38..f61b59cc 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -13,6 +13,9 @@ import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync 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'; +import { isSignedIn } from '@x/core/dist/account/account.js'; +import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; +import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -201,6 +204,23 @@ export async function connectProvider(provider: string, credentials?: { clientId if (provider === 'google') { if (!credentials?.clientId || !credentials?.clientSecret) { + // No credentials → rowboat mode if the user is signed in to Rowboat + // (we use the company-owned Google client via the api + webapp). + // Otherwise it's BYOK with missing creds → error. + if (await isSignedIn()) { + try { + const webappUrl = await getWebappUrl(); + await shell.openExternal(`${webappUrl}/oauth/google/start`); + console.log('[OAuth] Started rowboat-mode Google connect (browser opened to webapp)'); + return { success: true }; + } catch (error) { + console.error('[OAuth] Failed to start rowboat-mode Google connect:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to open browser', + }; + } + } return { success: false, error: 'Google client ID and client secret are required to connect.' }; } } @@ -257,11 +277,15 @@ export async function connectProvider(provider: string, credentials?: { clientId state ); - // Save tokens and credentials + // Save tokens and credentials. For Google, BYOK is the only path + // that reaches this token exchange (rowboat path returns above + // before any local server runs); stamp mode: 'byok' so a future + // refresh / reconnect can't get confused with a rowboat entry. console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.upsert(provider, { tokens, ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}), + ...(provider === 'google' ? { mode: 'byok' as const } : {}), error: null, }); @@ -358,12 +382,65 @@ export async function connectProvider(provider: string, credentials?: { clientId } } +/** + * Complete a rowboat-mode Google connect: claim the tokens parked under + * `state` by the webapp callback, persist them locally, and trigger sync. + * + * Called by the deep-link dispatcher (deeplink.ts) when the OS hands us a + * rowboat://oauth/google/done?session=<state> URL. + */ +export async function completeRowboatGoogleConnect(state: string): Promise<void> { + try { + console.log('[OAuth] Claiming rowboat-mode Google tokens...'); + const tokens = await claimTokensViaBackend(state); + const oauthRepo = getOAuthRepo(); + await oauthRepo.upsert('google', { + tokens, + mode: 'rowboat', + // Explicitly null these — no client_id/secret on the desktop in this mode. + clientId: null, + clientSecret: null, + error: null, + }); + triggerGmailSync(); + triggerCalendarSync(); + emitOAuthEvent({ provider: 'google', success: true }); + console.log('[OAuth] Rowboat-mode Google connect complete'); + } catch (error) { + console.error('[OAuth] Failed to complete rowboat-mode Google connect:', error); + emitOAuthEvent({ + provider: 'google', + success: false, + error: error instanceof Error ? error.message : 'Failed to claim Google tokens', + }); + } +} + /** * Disconnect a provider (clear tokens) */ export async function disconnectProvider(provider: string): Promise<{ success: boolean }> { try { const oauthRepo = getOAuthRepo(); + + // For rowboat-mode Google, best-effort revoke at Google before clearing + // local state. Google's revoke endpoint accepts an unauthenticated POST + // with the access_token; failure is logged but doesn't block disconnect. + if (provider === 'google') { + const connection = await oauthRepo.read(provider); + if (connection.mode === 'rowboat' && connection.tokens?.access_token) { + try { + const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; + const res = await fetch(revokeUrl, { method: 'POST' }); + if (!res.ok) { + console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); + } + } catch (error) { + console.warn('[OAuth] Google revoke failed; continuing with local disconnect:', error); + } + } + } + await oauthRepo.delete(provider); if (provider === 'rowboat') { analyticsCapture('user_signed_out'); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d0ed5284..07f91e0b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -56,6 +56,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' +import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { TrackModal } from '@/components/track-modal' import { BackgroundTaskDetail } from '@/components/background-task-detail' @@ -780,6 +781,30 @@ function App() { return cleanup }, [refreshVoiceAvailability]) + // One-time Composio→native Google migration check. Runs on mount and again + // after the user signs in to Rowboat (so we catch users who weren't signed + // in at startup). The IPC is idempotent — once `dismissed_at` is set on the + // main side, every subsequent call returns `{shouldShow: false}`. + useEffect(() => { + const run = async () => { + try { + const result = await window.ipc.invoke('migration:check-composio-google', null) + if (result.shouldShow) { + setShowComposioGoogleMigration(true) + } + } catch (error) { + console.error('[migration] check-composio-google failed:', error) + } + } + void run() + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat' && event.success) { + void run() + } + }) + return cleanup + }, []) + const handleStartRecording = useCallback(() => { setIsRecording(true) isRecordingRef.current = true @@ -1033,6 +1058,9 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // One-time Composio→native Google migration modal + const [showComposioGoogleMigration, setShowComposioGoogleMigration] = useState(false) + // Search state const [isSearchOpen, setIsSearchOpen] = useState(false) @@ -4904,6 +4932,17 @@ function App() { open={showOnboarding} onComplete={handleOnboardingComplete} /> + <ComposioGoogleMigrationModal + open={showComposioGoogleMigration} + onOpenChange={setShowComposioGoogleMigration} + onReconnect={() => { + // Trigger the rowboat-mode Google connect flow. With no credentials + // and the user signed in to Rowboat, the main process opens the + // webapp `/oauth/google/start` URL. The deep link returns and + // completeRowboatGoogleConnect persists the tokens. + void window.ipc.invoke('oauth:connect', { provider: 'google' }) + }} + /> <Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}> <DialogContent showCloseButton={false}> <DialogHeader> diff --git a/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx b/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx new file mode 100644 index 00000000..8afea839 --- /dev/null +++ b/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx @@ -0,0 +1,81 @@ +"use client" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +interface ComposioGoogleMigrationModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onReconnect: () => void +} + +/** + * One-time modal shown to signed-in users who had Gmail/Calendar connected + * via Composio before the native rowboat-mode OAuth flow shipped. By the + * time this opens, the Composio Google accounts have already been + * disconnected (fire-and-forget, on the qualification IPC) — the modal + * just explains what happened and offers a one-click reconnect. + * + * Both buttons close the modal. The qualification IPC marks the migration + * as dismissed before showing this, so neither button needs a follow-up + * IPC of its own. + */ +export function ComposioGoogleMigrationModal({ + open, + onOpenChange, + onReconnect, +}: ComposioGoogleMigrationModalProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl"> + <div className="p-6 pb-0"> + <DialogHeader className="space-y-1.5"> + <DialogTitle className="text-lg font-semibold"> + Reconnect Google to keep syncing + </DialogTitle> + <DialogDescription asChild> + <div className="space-y-3 text-sm leading-relaxed"> + <p> + Rowboat used to sync your Gmail and Calendar through{" "} + <span className="font-medium text-foreground">Composio</span>, a + third-party connector. We've now built a direct connection to + Google — it's faster, more private, and doesn't rely on a + middleman. + </p> + <p> + We've disconnected the Composio connection. Reconnect Google + directly to resume syncing — your existing emails and calendar + events stay exactly where they are. + </p> + </div> + </DialogDescription> + </DialogHeader> + </div> + <div className="flex justify-end gap-2 px-6 py-4 mt-6 border-t bg-muted/30"> + <Button + variant="ghost" + size="sm" + onClick={() => onOpenChange(false)} + > + I'll do this later + </Button> + <Button + size="sm" + onClick={() => { + onReconnect() + onOpenChange(false) + }} + > + Reconnect Google + </Button> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 469ac35d..33c89231 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -96,14 +96,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null) - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed — flags are seeded false and + // never flipped. Kept here so legacy gating expressions still type-check. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) @@ -151,25 +151,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { setProvidersLoading(false) } } - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } + // (Composio Gmail/Calendar flag fetches removed — sync was deleted.) loadProviders() - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -622,12 +605,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Falls back to BYOK modal + // for not-signed-in users. (Mirrors useConnectors.handleConnect.) + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index edb3616b..b06ec862 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -66,16 +66,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Inline upsell callout dismissed const [upsellDismissed, setUpsellDismissed] = useState(false) - // Composio/Gmail state (used when signed in with Rowboat account) - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed — flags are seeded false and + // never flipped. Kept here so legacy gating expressions still type-check. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) const [gmailLoading, setGmailLoading] = useState(true) const [gmailConnecting, setGmailConnecting] = useState(false) const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) @@ -123,25 +123,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setProvidersLoading(false) } } - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } + // (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.) loadProviders() - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() }, [open]) // Load LLM models catalog on open @@ -539,17 +522,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const cleanup = window.ipc.on('oauth:didConnect', async (event) => { if (event.provider === 'rowboat' && event.success) { - // Re-check composio flags now that the account is connected - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (error) { - console.error('Failed to re-check composio flags:', error) - } + // (Composio Gmail/Calendar flag re-check removed — sync was deleted.) setCurrentStep(2) // Go to Connect Accounts } }) @@ -609,12 +582,20 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { // Connect to a provider const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Falls back to BYOK modal + // for not-signed-in users. (Mirrors useConnectors.handleConnect.) + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) diff --git a/apps/x/apps/renderer/src/hooks/useConnectors.ts b/apps/x/apps/renderer/src/hooks/useConnectors.ts index 7285fe04..af56b921 100644 --- a/apps/x/apps/renderer/src/hooks/useConnectors.ts +++ b/apps/x/apps/renderer/src/hooks/useConnectors.ts @@ -38,16 +38,21 @@ export function useConnectors(active: boolean) { const [slackDiscovering, setSlackDiscovering] = useState(false) const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null) - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + // Composio Gmail/Calendar sync was removed. These flags are seeded false + // and never flipped — the IPC that used to set them is gone. The setters + // remain so the legacy Composio-Gmail handlers below still type-check, + // but those handlers are no longer reachable in the UI (the gating + // condition `useComposioForGoogle` stays false). + // TODO follow-up: drop these flags entirely and prune the dead UI branches + // in connectors-popover, connected-accounts-settings, and onboarding-modal. + const [useComposioForGoogle] = useState(false) const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) + const [gmailLoading, setGmailLoading] = useState(false) const [gmailConnecting, setGmailConnecting] = useState(false) - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [useComposioForGoogleCalendar] = useState(false) const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) - const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false) const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) // Load available providers on mount @@ -67,28 +72,7 @@ export function useConnectors(active: boolean) { loadProviders() }, []) - // Re-check composio-for-google flags when active - useEffect(() => { - if (!active) return - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() - }, [active]) + // (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.) // Load Granola config const refreshGranolaConfig = useCallback(async () => { @@ -346,13 +330,22 @@ export function useConnectors(active: boolean) { const handleConnect = useCallback(async (provider: string) => { if (provider === 'google') { + // Signed-in users use the rowboat (managed-credentials) flow: opens + // the webapp in the browser, no BYOK modal. Main process detects + // signed-in via isSignedIn() when oauth:connect arrives without creds. + // Falls back to the BYOK modal for not-signed-in users. + const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false + if (isSignedIntoRowboat) { + await startConnect('google') + return + } setGoogleClientIdDescription(undefined) setGoogleClientIdOpen(true) return } await startConnect(provider) - }, [startConnect]) + }, [startConnect, providerStates]) const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) @@ -485,19 +478,6 @@ export function useConnectors(active: boolean) { toast.success(`Connected to ${displayName}`) } - if (provider === 'rowboat') { - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (err) { - console.error('Failed to re-check composio flags:', err) - } - } - refreshAllStatuses() } }) diff --git a/apps/x/packages/core/src/auth/google-backend-oauth.ts b/apps/x/packages/core/src/auth/google-backend-oauth.ts new file mode 100644 index 00000000..a441d205 --- /dev/null +++ b/apps/x/packages/core/src/auth/google-backend-oauth.ts @@ -0,0 +1,113 @@ +import { API_URL } from "../config/env.js"; +import { getAccessToken } from "./tokens.js"; +import { OAuthTokens } from "./types.js"; + +/** + * Client for the rowboat-mode Google OAuth endpoints on the api: + * POST /v1/google-oauth/claim — one-shot retrieval of tokens parked by + * the webapp callback under a `state` ticket + * POST /v1/google-oauth/refresh — exchange a refresh_token for fresh tokens + * (the secret-requiring step that can't + * happen on the desktop) + * + * Both are called with the user's Rowboat Supabase bearer (via getAccessToken). + * + * The api response shape uses `scope: string` (space-delimited); we convert + * to the desktop's `scopes: string[]`. On refresh, api may omit `scope` and + * `refresh_token` — caller-provided existingScopes / refreshToken are + * preserved in those cases (Google rarely rotates refresh tokens). + */ + +/** Thrown when the api signals the user must reconnect (Google `invalid_grant`). */ +export class ReconnectRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "ReconnectRequiredError"; + } +} + +interface ApiTokenResponse { + access_token: string; + refresh_token?: string; + expires_at: number; + scope?: string; + token_type?: string; +} + +function toOAuthTokens( + body: ApiTokenResponse, + fallbackRefreshToken: string | null = null, + fallbackScopes?: string[], +): OAuthTokens { + const refresh_token = body.refresh_token ?? fallbackRefreshToken; + const scopes = body.scope + ? body.scope.split(" ").filter((s) => s.length > 0) + : fallbackScopes; + return { + access_token: body.access_token, + refresh_token, + expires_at: body.expires_at, + token_type: "Bearer", + scopes, + }; +} + +async function postWithBearer(path: string, body: unknown): Promise<Response> { + const bearer = await getAccessToken(); + return fetch(`${API_URL}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bearer}`, + }, + body: JSON.stringify(body), + }); +} + +interface ErrorBody { + error?: string; + reconnectRequired?: boolean; +} + +async function readError(res: Response): Promise<ErrorBody> { + try { + return (await res.json()) as ErrorBody; + } catch { + return {}; + } +} + +/** Claim the tokens parked under `state` after the webapp finished its callback. */ +export async function claimTokensViaBackend(state: string): Promise<OAuthTokens> { + const res = await postWithBearer("/v1/google-oauth/claim", { session: state }); + if (!res.ok) { + const err = await readError(res); + throw new Error(`claim failed: ${res.status} ${err.error ?? ""}`.trim()); + } + const body = (await res.json()) as ApiTokenResponse; + return toOAuthTokens(body); +} + +/** + * Refresh an access token via the api. Preserves caller's `refreshToken` and + * `existingScopes` when Google omits them on the refresh response. + */ +export async function refreshTokensViaBackend( + refreshToken: string, + existingScopes?: string[], +): Promise<OAuthTokens> { + const res = await postWithBearer("/v1/google-oauth/refresh", { refreshToken }); + if (res.status === 409) { + const err = await readError(res); + if (err.reconnectRequired) { + throw new ReconnectRequiredError(err.error ?? "Reconnect required"); + } + throw new Error(`refresh failed: 409 ${err.error ?? ""}`.trim()); + } + if (!res.ok) { + const err = await readError(res); + throw new Error(`refresh failed: ${res.status} ${err.error ?? ""}`.trim()); + } + const body = (await res.json()) as ApiTokenResponse; + return toOAuthTokens(body, refreshToken, existingScopes); +} diff --git a/apps/x/packages/core/src/auth/repo.ts b/apps/x/packages/core/src/auth/repo.ts index 5276faea..08f6b56d 100644 --- a/apps/x/packages/core/src/auth/repo.ts +++ b/apps/x/packages/core/src/auth/repo.ts @@ -8,6 +8,13 @@ const ProviderConnectionSchema = z.object({ tokens: OAuthTokens.nullable().optional(), clientId: z.string().nullable().optional(), clientSecret: z.string().nullable().optional(), + /** + * `byok` (default for absent) — user provides their own client_id+secret; + * tokens stored locally; refresh handled locally via openid-client. + * `rowboat` — signed-in user; client_id+secret never on the desktop; + * tokens stored locally but refresh goes through the api. + */ + mode: z.enum(['byok', 'rowboat']).optional(), error: z.string().nullable().optional(), }); diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 2844fc28..8080d923 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -49,8 +49,6 @@ async function getAuthHeaders(): Promise<Record<string, string>> { */ const ZComposioConfig = z.object({ apiKey: z.string().optional(), - use_composio_for_google: z.boolean().optional(), - use_composio_for_google_calendar: z.boolean().optional(), }); type ComposioConfig = z.infer<typeof ZComposioConfig>; @@ -106,24 +104,6 @@ export async function isConfigured(): Promise<boolean> { return !!getApiKey(); } -/** - * Check if Composio should be used for Google services (Gmail, etc.) - */ -export async function useComposioForGoogle(): Promise<boolean> { - if (await isSignedIn()) return true; - const config = loadConfig(); - return config.use_composio_for_google === true; -} - -/** - * Check if Composio should be used for Google Calendar - */ -export async function useComposioForGoogleCalendar(): Promise<boolean> { - if (await isSignedIn()) return true; - const config = loadConfig(); - return config.use_composio_for_google_calendar === true; -} - /** * Make an API call to Composio */ diff --git a/apps/x/packages/core/src/config/remote-config.ts b/apps/x/packages/core/src/config/remote-config.ts new file mode 100644 index 00000000..87174ef7 --- /dev/null +++ b/apps/x/packages/core/src/config/remote-config.ts @@ -0,0 +1,51 @@ +import { API_URL } from "./env.js"; + +/** + * Per-process cache of the unauthenticated `GET /v1/config` response from + * the api. The api returns `{ appUrl, supabaseUrl, websocketApiUrl }` — + * we use this to discover the webapp host (where the rowboat-mode OAuth + * flow runs) without hardcoding it on the desktop side. + * + * Cached as a Promise so concurrent first-callers all await the same fetch + * (no thundering herd). On failure the cache is cleared so the next call + * can retry. + */ + +interface RemoteConfig { + appUrl: string; + supabaseUrl: string; + websocketApiUrl: string; +} + +let _cached: Promise<RemoteConfig> | null = null; + +async function fetchRemoteConfig(): Promise<RemoteConfig> { + const res = await fetch(`${API_URL}/v1/config`); + if (!res.ok) { + throw new Error(`/v1/config returned ${res.status}`); + } + const body = (await res.json()) as Partial<RemoteConfig>; + if (!body.appUrl) { + throw new Error("/v1/config response missing appUrl"); + } + return { + appUrl: body.appUrl, + supabaseUrl: body.supabaseUrl ?? "", + websocketApiUrl: body.websocketApiUrl ?? "", + }; +} + +export async function getRemoteConfig(): Promise<RemoteConfig> { + if (!_cached) { + _cached = fetchRemoteConfig().catch((err) => { + _cached = null; // allow retry + throw err; + }); + } + return _cached; +} + +export async function getWebappUrl(): Promise<string> { + const config = await getRemoteConfig(); + return config.appUrl; +} diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 471bfecd..301c10a6 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -8,8 +8,6 @@ import { waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { loadUserConfig, updateUserEmail } from '../config/user_config.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { useComposioForGoogle, executeAction } from '../composio/client.js'; -import { composioAccountsRepo } from '../composio/repo.js'; import { loadAgentNotesState, saveAgentNotesState, @@ -199,30 +197,7 @@ async function ensureUserEmail(): Promise<string | null> { return existing.email; } - // Try Composio (used when signed in or composio configured) - try { - if (await useComposioForGoogle()) { - const account = composioAccountsRepo.getAccount('gmail'); - if (account && account.status === 'ACTIVE') { - const result = await executeAction('GMAIL_GET_PROFILE', { - connected_account_id: account.id, - user_id: 'rowboat-user', - version: 'latest', - arguments: { user_id: 'me' }, - }); - const email = (result.data as Record<string, unknown>)?.emailAddress as string | undefined; - if (email) { - updateUserEmail(email); - console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`); - return email; - } - } - } - } catch (error) { - console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error); - } - - // Try direct Google OAuth + // Try direct Google OAuth (covers both BYOK and rowboat modes) try { const auth = await GoogleClientFactory.getClient(); if (auth) { diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index 9e0ad2d1..0c48ae37 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -6,20 +6,44 @@ import { getProviderConfig } from '../auth/providers.js'; import * as oauthClient from '../auth/oauth-client.js'; import type { Configuration } from '../auth/oauth-client.js'; import { OAuthTokens } from '../auth/types.js'; +import { + ReconnectRequiredError, + refreshTokensViaBackend, +} from '../auth/google-backend-oauth.js'; + +type Mode = 'byok' | 'rowboat'; /** * Factory for creating and managing Google OAuth2Client instances. * Handles caching, token refresh, and client reuse for Google API SDKs. + * + * Two connection modes share the same `oauth.json` provider entry: + * - `byok` user supplied client_id+secret; refresh runs locally via + * openid-client; OAuth2Client built with creds. + * - `rowboat` signed-in user; client_id+secret never on the desktop; + * refresh goes through the api at /v1/google-oauth/refresh; + * OAuth2Client built without creds and without refresh_token + * (we own all refreshes — see note below). + * + * **Auto-refresh disabled in rowboat mode:** google-auth-library's + * OAuth2Client will, on a 401 from a Google API call, try to refresh using + * the refresh_token + client secret it has on hand. In rowboat mode we have + * no secret, so that would 401-loop. We block this by passing only + * access_token + expiry_date in setCredentials (no refresh_token), which + * leaves the library nothing to refresh with. Our proactive expiry check + * in getClient() is the only refresh path. */ export class GoogleClientFactory { private static readonly PROVIDER_NAME = 'google'; private static cache: { + mode: Mode | null; config: Configuration | null; client: OAuth2Client | null; tokens: OAuthTokens | null; clientId: string | null; clientSecret: string | null; } = { + mode: null, config: null, client: null, tokens: null, @@ -27,7 +51,14 @@ export class GoogleClientFactory { clientSecret: null, }; - private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> { + /** + * Promise singleton so a burst of getClient() calls during the brief + * expiry window all wait on a single refresh round-trip rather than + * fanning out parallel refreshes. + */ + private static refreshInFlight: Promise<OAuth2Client | null> | null = null; + + private static async resolveByokCredentials(): Promise<{ clientId: string; clientSecret?: string }> { const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const connection = await oauthRepo.read(this.PROVIDER_NAME); if (!connection.clientId) { @@ -41,80 +72,116 @@ export class GoogleClientFactory { * Get or create OAuth2Client, reusing cached instance when possible */ static async getClient(): Promise<OAuth2Client | null> { + if (this.refreshInFlight) { + return this.refreshInFlight; + } + const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); - const { tokens } = await oauthRepo.read(this.PROVIDER_NAME); + const connection = await oauthRepo.read(this.PROVIDER_NAME); + const tokens = connection.tokens ?? null; + const mode: Mode = connection.mode ?? 'byok'; if (!tokens) { this.clearCache(); return null; } - // Initialize config cache if needed - try { - await this.initializeConfigCache(); - } catch (error) { - console.error("[OAuth] Failed to initialize Google OAuth configuration:", error); + // Mode flipped (e.g. user disconnected then reconnected differently) — invalidate. + if (this.cache.mode && this.cache.mode !== mode) { this.clearCache(); - return null; - } - if (!this.cache.config) { - return null; } - // Check if token is expired + // BYOK needs an openid-client Configuration for local refresh; rowboat doesn't. + if (mode === 'byok') { + try { + await this.initializeConfigCache(); + } catch (error) { + console.error('[OAuth] Failed to initialize Google OAuth configuration:', error); + this.clearCache(); + return null; + } + if (!this.cache.config) { + return null; + } + } + + // Check expiry against the cached tokens. Note: oauthClient.isTokenExpired + // applies a small clock-skew margin so we refresh slightly before real + // expiry — keeps long-running calls from racing the boundary. if (oauthClient.isTokenExpired(tokens)) { - // Token expired, try to refresh if (!tokens.refresh_token) { - console.log("[OAuth] Token expired and no refresh token available for Google."); + console.log('[OAuth] Token expired and no refresh token available for Google.'); await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' }); this.clearCache(); return null; } - try { - console.log(`[OAuth] Token expired, refreshing access token...`); - const existingScopes = tokens.scopes; - const refreshedTokens = await oauthClient.refreshTokens( - this.cache.config, - tokens.refresh_token, - existingScopes - ); - await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens }); - - // Update cached tokens and recreate client - this.cache.tokens = refreshedTokens; - if (!this.cache.clientId) { - const creds = await this.resolveCredentials(); - this.cache.clientId = creds.clientId; - this.cache.clientSecret = creds.clientSecret ?? null; - } - this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined); - console.log(`[OAuth] Token refreshed successfully`); - return this.cache.client; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to refresh token for Google'; - await oauthRepo.upsert(this.PROVIDER_NAME, { error: message }); - console.error("[OAuth] Failed to refresh token for Google:", error); - this.clearCache(); - return null; - } + this.refreshInFlight = this.refreshAndBuild(tokens, mode).finally(() => { + this.refreshInFlight = null; + }); + return this.refreshInFlight; } // Reuse client if tokens haven't changed - if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token) { + if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token && this.cache.mode === mode) { return this.cache.client; } - // Create new client with current tokens - console.log(`[OAuth] Creating new OAuth2Client instance`); - this.cache.tokens = tokens; - if (!this.cache.clientId) { - const creds = await this.resolveCredentials(); + // Build a fresh client for current tokens + return this.buildAndCacheClient(tokens, mode); + } + + private static async refreshAndBuild(tokens: OAuthTokens, mode: Mode): Promise<OAuth2Client | null> { + const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); + + try { + console.log(`[OAuth] Token expired, refreshing via ${mode}...`); + const existingScopes = tokens.scopes; + + let refreshedTokens: OAuthTokens; + if (mode === 'rowboat') { + refreshedTokens = await refreshTokensViaBackend(tokens.refresh_token!, existingScopes); + } else { + if (!this.cache.config) { + // Should not happen — initializeConfigCache ran above for byok. + throw new Error('Google OAuth config not initialized'); + } + refreshedTokens = await oauthClient.refreshTokens(this.cache.config, tokens.refresh_token!, existingScopes); + } + + await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens, error: null }); + console.log('[OAuth] Token refreshed successfully'); + return this.buildAndCacheClient(refreshedTokens, mode); + } catch (error) { + if (error instanceof ReconnectRequiredError) { + console.log('[OAuth] Reconnect required for Google'); + await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Reconnect Google' }); + this.clearCache(); + return null; + } + const message = error instanceof Error ? error.message : 'Failed to refresh token for Google'; + await oauthRepo.upsert(this.PROVIDER_NAME, { error: message }); + console.error('[OAuth] Failed to refresh token for Google:', error); + this.clearCache(); + return null; + } + } + + private static async buildAndCacheClient(tokens: OAuthTokens, mode: Mode): Promise<OAuth2Client> { + if (mode === 'byok' && !this.cache.clientId) { + const creds = await this.resolveByokCredentials(); this.cache.clientId = creds.clientId; this.cache.clientSecret = creds.clientSecret ?? null; } - this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined); - return this.cache.client; + + const client = mode === 'rowboat' + ? this.createRowboatClient(tokens) + : this.createByokClient(tokens, this.cache.clientId!, this.cache.clientSecret ?? undefined); + + this.cache.mode = mode; + this.cache.tokens = tokens; + this.cache.client = client; + return client; } /** @@ -139,7 +206,8 @@ export class GoogleClientFactory { * Clear cache (useful for testing or when credentials are revoked) */ static clearCache(): void { - console.log(`[OAuth] Clearing Google auth cache`); + console.log('[OAuth] Clearing Google auth cache'); + this.cache.mode = null; this.cache.config = null; this.cache.client = null; this.cache.tokens = null; @@ -148,10 +216,10 @@ export class GoogleClientFactory { } /** - * Initialize cached configuration (called once) + * Initialize cached configuration for BYOK mode (rowboat doesn't need it). */ private static async initializeConfigCache(): Promise<void> { - const { clientId, clientSecret } = await this.resolveCredentials(); + const { clientId, clientSecret } = await this.resolveByokCredentials(); if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) { return; // Already initialized for these credentials @@ -161,13 +229,13 @@ export class GoogleClientFactory { this.clearCache(); } - console.log(`[OAuth] Initializing Google OAuth configuration...`); + console.log('[OAuth] Initializing Google OAuth configuration...'); const providerConfig = await getProviderConfig(this.PROVIDER_NAME); if (providerConfig.discovery.mode === 'issuer') { if (providerConfig.client.mode === 'static') { // Discover endpoints, use static client ID - console.log(`[OAuth] Discovery mode: issuer with static client ID`); + console.log('[OAuth] Discovery mode: issuer with static client ID'); this.cache.config = await oauthClient.discoverConfiguration( providerConfig.discovery.issuer, clientId, @@ -175,7 +243,7 @@ export class GoogleClientFactory { ); } else { // DCR mode - need existing registration - console.log(`[OAuth] Discovery mode: issuer with DCR`); + console.log('[OAuth] Discovery mode: issuer with DCR'); const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo'); const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME); @@ -194,7 +262,7 @@ export class GoogleClientFactory { throw new Error('DCR requires discovery mode "issuer", not "static"'); } - console.log(`[OAuth] Using static endpoints (no discovery)`); + console.log('[OAuth] Using static endpoints (no discovery)'); this.cache.config = oauthClient.createStaticConfiguration( providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.tokenEndpoint, @@ -206,27 +274,33 @@ export class GoogleClientFactory { this.cache.clientId = clientId; this.cache.clientSecret = clientSecret ?? null; - console.log(`[OAuth] Google OAuth configuration initialized`); + console.log('[OAuth] Google OAuth configuration initialized'); } - /** - * Create OAuth2Client from OAuthTokens - */ - private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client { - const client = new OAuth2Client( - clientId, - clientSecret ?? undefined, - undefined // redirect_uri not needed for token usage - ); - - // Set credentials + /** BYOK OAuth2Client — has client_id + secret + refresh_token. */ + private static createByokClient(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client { + const client = new OAuth2Client(clientId, clientSecret ?? undefined, undefined); client.setCredentials({ access_token: tokens.access_token, refresh_token: tokens.refresh_token || undefined, - expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds + expiry_date: tokens.expires_at * 1000, scope: tokens.scopes?.join(' ') || undefined, }); + return client; + } + /** + * Rowboat OAuth2Client — no client_id/secret, no refresh_token. + * Library auto-refresh is disabled by absence of refresh_token; our + * proactive refresh in getClient() is the only refresh path. + */ + private static createRowboatClient(tokens: OAuthTokens): OAuth2Client { + const client = new OAuth2Client(); + client.setCredentials({ + access_token: tokens.access_token, + expiry_date: tokens.expires_at * 1000, + scope: tokens.scopes?.join(' ') || undefined, + }); return client; } } diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index b6258975..b311dfa2 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -5,10 +5,8 @@ import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; +import { serviceLogger } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; -import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js'; -import { composioAccountsRepo } from '../composio/repo.js'; import { createEvent } from './track/events.js'; const MAX_EVENTS_IN_DIGEST = 50; @@ -138,7 +136,6 @@ async function publishCalendarSyncEvent( const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 7; -const COMPOSIO_LOOKBACK_DAYS = 7; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/drive.readonly' @@ -477,286 +474,17 @@ async function performSync(syncDir: string, lookbackDays: number) { } } -// --- Composio-based Sync --- - -interface ComposioCalendarState { - last_sync: string; // ISO string -} - -function loadComposioState(stateFile: string): ComposioCalendarState | null { - if (fs.existsSync(stateFile)) { - try { - const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (data.last_sync) { - return { last_sync: data.last_sync }; - } - } catch (e) { - console.error('[Calendar] Failed to load composio state:', e); - } - } - return null; -} - -function saveComposioState(stateFile: string, lastSync: string): void { - fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); -} - -/** - * Save a Composio calendar event as JSON (same format used by Google OAuth path). - * The event data from Composio is already structured similarly to Google Calendar API. - */ -function saveComposioEvent(eventData: Record<string, unknown>, syncDir: string): { changed: boolean; isNew: boolean; title: string } { - const eventId = eventData.id as string | undefined; - if (!eventId) return { changed: false, isNew: false, title: 'Unknown' }; - - const filePath = path.join(syncDir, `${eventId}.json`); - const content = JSON.stringify(eventData, null, 2); - const exists = fs.existsSync(filePath); - - try { - if (exists) { - const existing = fs.readFileSync(filePath, 'utf-8'); - if (existing === content) { - return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; - } - } - - fs.writeFileSync(filePath, content); - return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId }; - } catch (e) { - console.error(`[Calendar] Error saving event ${eventId}:`, e); - return { changed: false, isNew: false, title: (eventData.summary as string) || eventId }; - } -} - -async function performSyncComposio() { - const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json'); - - if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); - - const account = composioAccountsRepo.getAccount('googlecalendar'); - if (!account || account.status !== 'ACTIVE') { - console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.'); - return; - } - - const connectedAccountId = account.id; - - // Calculate time window: lookback + 14 days forward - const now = new Date(); - const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000; - const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; - - const timeMin = new Date(now.getTime() - lookbackMs).toISOString(); - const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString(); - - console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`); - - let run: ServiceRunContext | null = null; - const ensureRun = async (): Promise<ServiceRunContext> => { - if (!run) { - run = await serviceLogger.startRun({ - service: 'calendar', - message: 'Syncing calendar (Composio)', - trigger: 'timer', - }); - } - return run; - }; - - try { - const currentEventIds = new Set<string>(); - let newCount = 0; - let updatedCount = 0; - const changedTitles: string[] = []; - const newEvents: AnyEvent[] = []; - const updatedEvents: AnyEvent[] = []; - let pageToken: string | null = null; - const MAX_PAGES = 20; - - for (let page = 0; page < MAX_PAGES; page++) { - // Re-check connection in case user disconnected mid-sync - if (!composioAccountsRepo.isConnected('googlecalendar')) { - console.log('[Calendar] Account disconnected during sync. Stopping.'); - return; - } - - const args: Record<string, unknown> = { - calendar_id: 'primary', - time_min: timeMin, - time_max: timeMax, - single_events: true, - order_by: 'startTime', - }; - if (pageToken) { - args.page_token = pageToken; - } - - const result = await executeAction( - 'GOOGLECALENDAR_FIND_EVENT', - { - connected_account_id: connectedAccountId, - user_id: 'rowboat-user', - version: 'latest', - arguments: args, - } - ); - - if (!result.successful || !result.data) { - console.error('[Calendar] Failed to list events via Composio:', result.error); - return; - } - - const data = result.data as Record<string, unknown>; - // Composio may return events in different structures - let events: Array<Record<string, unknown>> = []; - - if (Array.isArray(data.items)) { - events = data.items as Array<Record<string, unknown>>; - } else if (Array.isArray(data.events)) { - events = data.events as Array<Record<string, unknown>>; - } else if (data.event_data && typeof data.event_data === 'object') { - const nested = data.event_data as Record<string, unknown>; - if (Array.isArray(nested.event_data)) { - events = nested.event_data as Array<Record<string, unknown>>; - } else if (Array.isArray(data.event_data)) { - events = data.event_data as Array<Record<string, unknown>>; - } - } else if (Array.isArray(data)) { - events = data as unknown as Array<Record<string, unknown>>; - } - - if (events.length === 0 && page === 0) { - console.log('[Calendar] No events found in this window.'); - } else if (events.length > 0) { - console.log(`[Calendar] Page ${page + 1}: found ${events.length} events.`); - for (const event of events) { - const eventId = event.id as string | undefined; - if (eventId) { - const saveResult = saveComposioEvent(event, SYNC_DIR); - currentEventIds.add(eventId); - - if (saveResult.changed) { - await ensureRun(); - changedTitles.push(saveResult.title); - if (saveResult.isNew) { - newCount++; - newEvents.push(event); - } else { - updatedCount++; - updatedEvents.push(event); - } - } - } - } - } - - // Check for next page - const nextToken = data.nextPageToken as string | undefined; - if (nextToken) { - pageToken = nextToken; - console.log(`[Calendar] Fetching next page...`); - } else { - break; - } - } - - // Clean up events no longer in the window - const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR); - let deletedCount = 0; - if (deletedFiles.length > 0) { - await ensureRun(); - deletedCount = deletedFiles.length; - } - - // Publish a single bundled event capturing all changes from this sync. - await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles); - - // Log results if any changes were detected (run was started by ensureRun) - if (run) { - const r = run as ServiceRunContext; - const totalChanges = newCount + updatedCount + deletedCount; - const limitedTitles = limitEventItems(changedTitles); - await serviceLogger.log({ - type: 'changes_identified', - service: r.service, - runId: r.runId, - level: 'info', - message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, - counts: { - newEvents: newCount, - updatedEvents: updatedCount, - deletedFiles: deletedCount, - }, - items: limitedTitles.items, - truncated: limitedTitles.truncated, - }); - await serviceLogger.log({ - type: 'run_complete', - service: r.service, - runId: r.runId, - level: 'info', - message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, - durationMs: Date.now() - r.startedAt, - outcome: 'ok', - summary: { - newEvents: newCount, - updatedEvents: updatedCount, - deletedFiles: deletedCount, - }, - }); - } - - // Save state - saveComposioState(STATE_FILE, new Date().toISOString()); - console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`); - } catch (error) { - console.error('[Calendar] Error during Composio sync:', error); - const errRun = await ensureRun(); - await serviceLogger.log({ - type: 'error', - service: errRun.service, - runId: errRun.runId, - level: 'error', - message: 'Calendar sync error', - error: error instanceof Error ? error.message : String(error), - }); - await serviceLogger.log({ - type: 'run_complete', - service: errRun.service, - runId: errRun.runId, - level: 'error', - message: 'Calendar sync failed', - durationMs: Date.now() - errRun.startedAt, - outcome: 'error', - }); - } -} - export async function init() { console.log("Starting Google Calendar & Notes Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - const composioMode = await useComposioForGoogleCalendar(); - if (composioMode) { - const isConnected = composioAccountsRepo.isConnected('googlecalendar'); - if (!isConnected) { - console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...'); - } else { - await performSyncComposio(); - } + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); } else { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); - } else { - // Perform one sync - await performSync(SYNC_DIR, LOOKBACK_DAYS); - } + await performSync(SYNC_DIR, LOOKBACK_DAYS); } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 2aa48944..81a63edf 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -7,8 +7,6 @@ import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; -import { executeAction, useComposioForGoogle } from '../composio/client.js'; -import { composioAccountsRepo } from '../composio/repo.js'; import { createEvent } from './track/events.js'; // Configuration @@ -225,7 +223,7 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri } } -function loadState(stateFile: string): { historyId?: string } { +function loadState(stateFile: string): { historyId?: string; last_sync?: string } { if (fs.existsSync(stateFile)) { return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } @@ -240,9 +238,24 @@ function saveState(historyId: string, stateFile: string) { } async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { - console.log(`Performing full sync of last ${lookbackDays} days...`); const gmail = google.gmail({ version: 'v1', auth }); + // If the state file holds a last_sync timestamp (e.g. left over from a + // prior Composio sync, or from a previous successful native sync that + // we're falling back to after a history.list 404), use that as the + // floor instead of the default lookback. Carries forward Composio's + // last_sync on first migration so we don't refetch the last 7 days. + const state = loadState(stateFile); + let pastDate: Date; + if (state.last_sync) { + pastDate = new Date(state.last_sync); + console.log(`Performing full sync from last_sync=${state.last_sync}...`); + } else { + pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - lookbackDays); + console.log(`Performing full sync of last ${lookbackDays} days...`); + } + let run: ServiceRunContext | null = null; const ensureRun = async () => { if (!run) { @@ -255,8 +268,6 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str }; try { - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); // Get History ID @@ -498,386 +509,17 @@ async function performSync() { } } -// --- Composio-based Sync --- - -const COMPOSIO_LOOKBACK_DAYS = 7; - -interface ComposioSyncState { - last_sync: string; // ISO string -} - -function loadComposioState(stateFile: string): ComposioSyncState | null { - if (fs.existsSync(stateFile)) { - try { - const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (data.last_sync) { - return { last_sync: data.last_sync }; - } - } catch (e) { - console.error('[Gmail] Failed to load composio state:', e); - } - } - return null; -} - -function saveComposioState(stateFile: string, lastSync: string): void { - fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2)); -} - -function tryParseDate(dateStr: string): Date | null { - const d = new Date(dateStr); - return isNaN(d.getTime()) ? null : d; -} - -interface ParsedMessage { - from: string; - date: string; - subject: string; - body: string; -} - -function parseMessageData(messageData: Record<string, unknown>): ParsedMessage { - const headers = messageData.payload && typeof messageData.payload === 'object' - ? (messageData.payload as Record<string, unknown>).headers as Array<{ name: string; value: string }> | undefined - : undefined; - - const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown'); - const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown'); - const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)'); - - let body = ''; - - if (messageData.payload && typeof messageData.payload === 'object') { - body = extractBodyFromPayload(messageData.payload as Record<string, unknown>); - } - - if (!body) { - if (typeof messageData.body === 'string') { - body = messageData.body; - } else if (typeof messageData.snippet === 'string') { - body = messageData.snippet; - } else if (typeof messageData.text === 'string') { - body = messageData.text; - } - } - - if (body && (body.includes('<html') || body.includes('<div') || body.includes('<p'))) { - body = nhm.translate(body); - } - - if (body) { - body = body.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); - } - - return { from, date, subject, body }; -} - -function extractBodyFromPayload(payload: Record<string, unknown>): string { - const parts = payload.parts as Array<Record<string, unknown>> | undefined; - - if (parts) { - for (const part of parts) { - const mimeType = part.mimeType as string | undefined; - const bodyData = part.body && typeof part.body === 'object' - ? (part.body as Record<string, unknown>).data as string | undefined - : undefined; - - if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) { - const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); - if (mimeType === 'text/html') { - return nhm.translate(decoded); - } - return decoded; - } - - if (part.parts) { - const result = extractBodyFromPayload(part as Record<string, unknown>); - if (result) return result; - } - } - } - - const bodyData = payload.body && typeof payload.body === 'object' - ? (payload.body as Record<string, unknown>).data as string | undefined - : undefined; - - if (bodyData) { - const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); - const mimeType = payload.mimeType as string | undefined; - if (mimeType === 'text/html') { - return nhm.translate(decoded); - } - return decoded; - } - - return ''; -} - -interface ComposioThreadResult { - synced: SyncedThread | null; - newestIsoPlusOne: string | null; -} - -async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<ComposioThreadResult> { - let threadResult; - try { - threadResult = await executeAction( - 'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', - { - connected_account_id: connectedAccountId, - user_id: 'rowboat-user', - version: 'latest', - arguments: { thread_id: threadId, user_id: 'me' }, - } - ); - } catch (error) { - console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); - return { synced: null, newestIsoPlusOne: null }; - } - - if (!threadResult.successful || !threadResult.data) { - console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); - return { synced: null, newestIsoPlusOne: null }; - } - - const data = threadResult.data as Record<string, unknown>; - const messages = data.messages as Array<Record<string, unknown>> | undefined; - - let newestDate: Date | null = null; - let mdContent: string; - let subjectForLog: string; - - if (!messages || messages.length === 0) { - const parsed = parseMessageData(data); - mdContent = `# ${parsed.subject}\n\n` + - `**Thread ID:** ${threadId}\n` + - `**Message Count:** 1\n\n---\n\n` + - `### From: ${parsed.from}\n` + - `**Date:** ${parsed.date}\n\n` + - `${parsed.body}\n\n---\n\n`; - subjectForLog = parsed.subject; - newestDate = tryParseDate(parsed.date); - } else { - const firstParsed = parseMessageData(messages[0]); - mdContent = `# ${firstParsed.subject}\n\n`; - mdContent += `**Thread ID:** ${threadId}\n`; - mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; - - for (const msg of messages) { - const parsed = parseMessageData(msg); - mdContent += `### From: ${parsed.from}\n`; - mdContent += `**Date:** ${parsed.date}\n\n`; - mdContent += `${parsed.body}\n\n`; - mdContent += `---\n\n`; - - const msgDate = tryParseDate(parsed.date); - if (msgDate && (!newestDate || msgDate > newestDate)) { - newestDate = msgDate; - } - } - subjectForLog = firstParsed.subject; - } - - fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); - console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`); - - const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null; - return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne }; -} - -async function performSyncComposio() { - const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); - const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); - - if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); - if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); - - const account = composioAccountsRepo.getAccount('gmail'); - if (!account || account.status !== 'ACTIVE') { - console.log('[Gmail] Gmail not connected via Composio. Skipping sync.'); - return; - } - - const connectedAccountId = account.id; - - const state = loadComposioState(STATE_FILE); - let afterEpochSeconds: number; - - if (state) { - afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000); - console.log(`[Gmail] Syncing messages since ${state.last_sync}...`); - } else { - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS); - afterEpochSeconds = Math.floor(pastDate.getTime() / 1000); - console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`); - } - - let run: ServiceRunContext | null = null; - const ensureRun = async () => { - if (!run) { - run = await serviceLogger.startRun({ - service: 'gmail', - message: 'Syncing Gmail (Composio)', - trigger: 'timer', - }); - } - }; - - try { - const allThreadIds: string[] = []; - let pageToken: string | undefined; - - do { - const params: Record<string, unknown> = { - query: `after:${afterEpochSeconds}`, - max_results: 20, - user_id: 'me', - }; - if (pageToken) { - params.page_token = pageToken; - } - - const result = await executeAction( - 'GMAIL_LIST_THREADS', - { - connected_account_id: connectedAccountId, - user_id: 'rowboat-user', - version: 'latest', - arguments: params, - } - ); - - if (!result.successful || !result.data) { - console.error('[Gmail] Failed to list threads:', result.error); - return; - } - - const data = result.data as Record<string, unknown>; - const threads = data.threads as Array<Record<string, unknown>> | undefined; - - if (threads && threads.length > 0) { - for (const thread of threads) { - const threadId = thread.id as string | undefined; - if (threadId) { - allThreadIds.push(threadId); - } - } - } - - pageToken = data.nextPageToken as string | undefined; - } while (pageToken); - - if (allThreadIds.length === 0) { - console.log('[Gmail] No new threads.'); - return; - } - - console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`); - - await ensureRun(); - const limitedThreads = limitEventItems(allThreadIds); - await serviceLogger.log({ - type: 'changes_identified', - service: run!.service, - runId: run!.runId, - level: 'info', - message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`, - counts: { threads: allThreadIds.length }, - items: limitedThreads.items, - truncated: limitedThreads.truncated, - }); - - // Process oldest first so high-water mark advances chronologically - allThreadIds.reverse(); - - let highWaterMark: string | null = state?.last_sync ?? null; - let processedCount = 0; - const synced: SyncedThread[] = []; - for (const threadId of allThreadIds) { - // Re-check connection in case user disconnected mid-sync - if (!composioAccountsRepo.isConnected('gmail')) { - console.log('[Gmail] Account disconnected during sync. Stopping.'); - break; - } - try { - const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR); - processedCount++; - - if (result.synced) synced.push(result.synced); - - if (result.newestIsoPlusOne) { - if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) { - highWaterMark = result.newestIsoPlusOne; - } - saveComposioState(STATE_FILE, highWaterMark); - } - } catch (error) { - console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error); - } - } - - await publishGmailSyncEvent(synced); - - await serviceLogger.log({ - type: 'run_complete', - service: run!.service, - runId: run!.runId, - level: 'info', - message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`, - durationMs: Date.now() - run!.startedAt, - outcome: 'ok', - summary: { threads: processedCount }, - }); - - console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`); - } catch (error) { - console.error('[Gmail] Error during sync:', error); - await ensureRun(); - await serviceLogger.log({ - type: 'error', - service: run!.service, - runId: run!.runId, - level: 'error', - message: 'Gmail sync error', - error: error instanceof Error ? error.message : String(error), - }); - await serviceLogger.log({ - type: 'run_complete', - service: run!.service, - runId: run!.runId, - level: 'error', - message: 'Gmail sync failed', - durationMs: Date.now() - run!.startedAt, - outcome: 'error', - }); - } -} - export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - const composioMode = await useComposioForGoogle(); - if (composioMode) { - const isConnected = composioAccountsRepo.isConnected('gmail'); - if (!isConnected) { - console.log('[Gmail] Gmail not connected via Composio. Sleeping...'); - } else { - await performSyncComposio(); - } + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); } else { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); - } else { - // Perform one sync - await performSync(); - } + await performSync(); } } catch (error) { console.error("Error in main loop:", error); diff --git a/apps/x/packages/core/src/migrations/composio-google-migration.ts b/apps/x/packages/core/src/migrations/composio-google-migration.ts new file mode 100644 index 00000000..3e8f699d --- /dev/null +++ b/apps/x/packages/core/src/migrations/composio-google-migration.ts @@ -0,0 +1,132 @@ +import fs from 'fs'; +import path from 'path'; +import { z } from 'zod'; +import { WorkDir } from '../config/config.js'; +import { isSignedIn } from '../account/account.js'; +import { composioAccountsRepo } from '../composio/repo.js'; +import { deleteConnectedAccount } from '../composio/client.js'; +import container from '../di/container.js'; +import { IOAuthRepo } from '../auth/repo.js'; + +/** + * One-time migration that moves Composio-connected Gmail/Calendar users + * to the native rowboat-mode Google OAuth flow. + * + * Triggered by the renderer on app launch and after Rowboat sign-in. The + * single guard is `dismissed_at` in the migration state file — once set, + * none of the migration's side effects run again. This protects users who + * later re-add Composio Google for non-sync purposes (e.g. a tool that + * happens to use the Gmail toolkit) from having that connection blown + * away on a future launch. + */ + +const STATE_FILE = path.join(WorkDir, 'config', 'composio-google-migration.json'); + +const ZState = z.object({ + dismissed_at: z.string().min(1).optional(), +}); +type State = z.infer<typeof ZState>; + +function loadState(): State { + try { + if (fs.existsSync(STATE_FILE)) { + const raw = fs.readFileSync(STATE_FILE, 'utf-8'); + return ZState.parse(JSON.parse(raw)); + } + } catch (error) { + console.error('[composio-google-migration] failed to load state:', error); + } + return {}; +} + +function saveState(state: State): void { + const dir = path.dirname(STATE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +function markDismissed(): void { + saveState({ dismissed_at: new Date().toISOString() }); +} + +async function disconnectComposioGoogle(): Promise<void> { + for (const slug of ['gmail', 'googlecalendar'] as const) { + const account = composioAccountsRepo.getAccount(slug); + if (!account?.id) continue; + + try { + await deleteConnectedAccount(account.id); + console.log(`[composio-google-migration] composio: deleted ${slug} (${account.id})`); + } catch (error) { + // Best-effort — logged but doesn't block the local cleanup. + console.warn(`[composio-google-migration] composio delete failed for ${slug}:`, error); + } + + try { + composioAccountsRepo.deleteAccount(slug); + } catch (error) { + console.warn(`[composio-google-migration] local delete failed for ${slug}:`, error); + } + } +} + +function cleanupCalendarComposioState(): void { + const file = path.join(WorkDir, 'calendar_sync', 'composio_state.json'); + try { + if (fs.existsSync(file)) { + fs.unlinkSync(file); + console.log('[composio-google-migration] removed stale calendar composio_state.json'); + } + } catch (error) { + console.warn('[composio-google-migration] failed to remove composio_state.json:', error); + } +} + +/** + * Check whether the user qualifies for the migration. If they do, atomically + * mark `dismissed_at`, fire-and-forget the Composio disconnect, and return + * `{shouldShow: true}` so the renderer can show the modal. + * + * Idempotent: subsequent calls return `{shouldShow: false}` once `dismissed_at` + * is set, regardless of whether the modal was actually shown or the user + * completed the OAuth flow. + */ +export async function qualifyAndDisconnectComposioGoogle(): Promise<{ shouldShow: boolean }> { + // Rule 4 — already processed + const state = loadState(); + if (state.dismissed_at) { + return { shouldShow: false }; + } + + // Rule 1 — must be signed in to Rowboat + if (!(await isSignedIn())) { + return { shouldShow: false }; + } + + // Rule 3 — already on native rowboat-mode Google → silently mark dismissed + // (so we stop re-checking) and bail before touching Composio state. + const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); + const googleConnection = await oauthRepo.read('google'); + if (googleConnection.tokens && googleConnection.mode === 'rowboat') { + markDismissed(); + return { shouldShow: false }; + } + + // Rule 2 — must have at least one Composio Google toolkit connected + const hasGmail = composioAccountsRepo.isConnected('gmail'); + const hasCalendar = composioAccountsRepo.isConnected('googlecalendar'); + if (!hasGmail && !hasCalendar) { + return { shouldShow: false }; + } + + // All rules pass. Mark dismissed atomically before any side effects so + // a crash mid-migration leaves us in a deterministic post-migration state. + markDismissed(); + + // Fire-and-forget: disconnect Composio Google + clean up the stale + // calendar state file. Both are best-effort. + void disconnectComposioGoogle(); + cleanupCalendarComposioState(); + + return { shouldShow: true }; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index ab7d7f73..605b26d9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -429,16 +429,10 @@ const ipcSchemas = { toolkits: z.array(z.string()), }), }, - 'composio:use-composio-for-google': { + 'migration:check-composio-google': { req: z.null(), res: z.object({ - enabled: z.boolean(), - }), - }, - 'composio:use-composio-for-google-calendar': { - req: z.null(), - res: z.object({ - enabled: z.boolean(), + shouldShow: z.boolean(), }), }, 'composio:didConnect': { From c382e3ee8afa318a2dc53f7d615b65f804e86b92 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 16:07:37 +0530 Subject: [PATCH 033/143] use gemini as default kg model --- apps/x/packages/core/src/models/defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 66dda9e0..dc690d66 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -6,7 +6,7 @@ import container from "../di/container.js"; const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; -const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5"; +const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview"; const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5"; /** From c6083de05438a893e5f324fe472b2a2ccba74baf Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 19:21:32 +0530 Subject: [PATCH 034/143] show errors in activity tab for knowledge graph --- .../src/components/sidebar-content.tsx | 73 +++++++++++++++++-- apps/x/packages/core/src/agents/utils.ts | 62 +++++++++++++--- .../core/src/knowledge/agent_notes.ts | 15 +++- .../core/src/knowledge/build_graph.ts | 13 ++-- .../core/src/knowledge/label_emails.ts | 21 ++++-- .../packages/core/src/knowledge/tag_notes.ts | 21 ++++-- 6 files changed, 171 insertions(+), 34 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 7e204781..9c50c334 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -156,6 +156,28 @@ const SERVICE_LABELS: Record<string, string> = { granola: "Syncing Granola", graph: "Updating knowledge", voice_memo: "Processing voice memo", + email_labeling: "Labeling emails", + note_tagging: "Tagging notes", + agent_notes: "Updating agent notes", +} + +function summarizeServiceError(error: string): string { + const firstLine = error.split("\n").find((line) => line.trim().length > 0) + return firstLine?.trim() || error.trim() +} + +function collectServiceErrors(events: ServiceEventType[]): Map<string, string> { + const errors = new Map<string, string>() + for (const event of events) { + if (event.type === "error") { + errors.set(event.service, summarizeServiceError(event.error)) + continue + } + if (event.type === "run_complete" && event.outcome !== "error") { + errors.delete(event.service) + } + } + return errors } type TasksActions = { @@ -227,6 +249,7 @@ function formatRunTime(ts: string): string { function SyncStatusBar() { const { state } = useSidebar() const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map()) + const [serviceErrors, setServiceErrors] = useState<Map<string, string>>(new Map()) const [popoverOpen, setPopoverOpen] = useState(false) const [logEvents, setLogEvents] = useState<ServiceEventType[]>([]) const [logLoading, setLogLoading] = useState(false) @@ -260,11 +283,25 @@ function SyncStatusBar() { next.delete(nextEvent.runId) return next }) + if (nextEvent.outcome !== 'error') { + setServiceErrors((prev) => { + if (!prev.has(nextEvent.service)) return prev + const next = new Map(prev) + next.delete(nextEvent.service) + return next + }) + } const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) if (existingTimeout) { clearTimeout(existingTimeout) runTimeoutsRef.current.delete(nextEvent.runId) } + } else if (nextEvent.type === 'error') { + setServiceErrors((prev) => { + const next = new Map(prev) + next.set(nextEvent.service, summarizeServiceError(nextEvent.error)) + return next + }) } }) return cleanup @@ -298,10 +335,14 @@ function SyncStatusBar() { // skip malformed lines } } + setServiceErrors(collectServiceErrors(parsed)) // Newest first, limit to 1000 setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS)) } catch { - if (!cancelled) setLogEvents([]) + if (!cancelled) { + setLogEvents([]) + setServiceErrors(new Map()) + } } finally { if (!cancelled) setLogLoading(false) } @@ -312,12 +353,19 @@ function SyncStatusBar() { const isSyncing = activeServices.size > 0 const isCollapsed = state === "collapsed" + const errorEntries = Array.from(serviceErrors.entries()) + const primaryErrorService = errorEntries[0]?.[0] ?? null + const hasServiceErrors = errorEntries.length > 0 // Build status label from active services const activeServiceNames = [...new Set(activeServices.values())] const statusLabel = isSyncing ? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ") - : "All caught up" + : hasServiceErrors + ? errorEntries.length === 1 + ? `${SERVICE_LABELS[primaryErrorService ?? ""] || primaryErrorService} failed` + : "Recent sync issues" + : "All caught up" return ( <> @@ -335,11 +383,16 @@ function SyncStatusBar() { <PopoverTrigger asChild> <button type="button" - className="flex w-full items-center justify-between rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent" + className={cn( + "flex w-full items-center justify-between rounded-md px-2 py-1 text-xs hover:bg-sidebar-accent", + hasServiceErrors && !isSyncing ? "text-red-600 dark:text-red-400" : "text-muted-foreground", + )} > <span className="flex items-center gap-2 min-w-0"> {isSyncing ? ( <LoaderIcon className="h-3 w-3 shrink-0 animate-spin" /> + ) : hasServiceErrors ? ( + <AlertTriangle className="h-3 w-3 shrink-0" /> ) : ( <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60" /> )} @@ -357,7 +410,7 @@ function SyncStatusBar() { <div className="p-3 border-b"> <h4 className="font-semibold text-sm">Sync Activity</h4> <p className="text-xs text-muted-foreground mt-0.5"> - {isSyncing ? statusLabel : "All services up to date"} + {isSyncing || hasServiceErrors ? statusLabel : "All services up to date"} </p> </div> <div className="max-h-80 overflow-y-auto p-2"> @@ -389,7 +442,17 @@ function SyncStatusBar() { {SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service} </span> </span> - <span className="leading-4 text-foreground/80">{event.message}</span> + <div className="min-w-0 flex-1"> + <p className="leading-4 text-foreground/80">{event.message}</p> + {event.type === 'error' && ( + <p + className="truncate text-[11px] leading-4 text-red-600/90 dark:text-red-400/90" + title={event.error} + > + {summarizeServiceError(event.error)} + </p> + )} + </div> </div> ))} </div> diff --git a/apps/x/packages/core/src/agents/utils.ts b/apps/x/packages/core/src/agents/utils.ts index bd4c84af..3e5f472e 100644 --- a/apps/x/packages/core/src/agents/utils.ts +++ b/apps/x/packages/core/src/agents/utils.ts @@ -1,6 +1,35 @@ import { bus } from "../runs/bus.js"; import { fetchRun } from "../runs/runs.js"; +type RunRecord = Awaited<ReturnType<typeof fetchRun>>; + +function extractRunErrors(run: RunRecord): string[] { + return run.log.flatMap((event) => event.type === "error" ? [event.error] : []); +} + +export class RunFailedError extends Error { + readonly runId: string; + readonly errors: string[]; + + constructor(runId: string, errors: string[]) { + const firstError = errors.find(Boolean) ?? null; + super(firstError ? `Run ${runId} failed: ${firstError}` : `Run ${runId} failed`); + this.name = "RunFailedError"; + this.runId = runId; + this.errors = errors; + } +} + +export function getErrorDetails(error: unknown): string { + if (error instanceof RunFailedError) { + return error.errors.join("\n\n"); + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + /** * Extract the assistant's final text response from a run's log. * @param runId @@ -28,13 +57,28 @@ export async function extractAgentResponse(runId: string): Promise<string | null /** * Wait for a run to complete by listening for run-processing-end event */ -export async function waitForRunCompletion(runId: string): Promise<void> { - return new Promise(async (resolve) => { - const unsubscribe = await bus.subscribe('*', async (event) => { - if (event.type === 'run-processing-end' && event.runId === runId) { - unsubscribe(); - resolve(); - } - }); +export async function waitForRunCompletion( + runId: string, + opts: { throwOnError?: boolean } = {}, +): Promise<RunRecord> { + return new Promise((resolve, reject) => { + void (async () => { + const unsubscribe = await bus.subscribe('*', async (event) => { + if (event.type === 'run-processing-end' && event.runId === runId) { + unsubscribe(); + try { + const run = await fetchRun(runId); + const errors = extractRunErrors(run); + if (opts.throwOnError && errors.length > 0) { + reject(new RunFailedError(runId, errors)); + return; + } + resolve(run); + } catch (error) { + reject(error); + } + } + }); + })().catch(reject); }); -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts index 301c10a6..f1380c4a 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -4,7 +4,7 @@ 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 { getErrorDetails, waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { loadUserConfig, updateUserEmail } from '../config/user_config.js'; import { GoogleClientFactory } from './google-client-factory.js'; @@ -288,7 +288,7 @@ async function processAgentNotes(): Promise<void> { subUseCase: 'agent_notes', }); await createMessage(agentRun.id, message); - await waitForRunCompletion(agentRun.id); + await waitForRunCompletion(agentRun.id, { throwOnError: true }); // Mark everything as processed for (const p of emailPaths) { @@ -326,7 +326,16 @@ async function processAgentNotes(): Promise<void> { runId: serviceRun.runId, level: 'error', message: 'Error processing agent notes', - error: error instanceof Error ? error.message : String(error), + error: getErrorDetails(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: serviceRun.service, + runId: serviceRun.runId, + level: 'error', + message: 'Agent notes processing failed', + durationMs: Date.now() - serviceRun.startedAt, + outcome: 'error', }); } } diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index d47413ca..10be1d14 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -3,7 +3,7 @@ import path from 'path'; import { WorkDir } from '../config/config.js'; import { createRun, createMessage } from '../runs/runs.js'; import { bus } from '../runs/bus.js'; -import { waitForRunCompletion } from '../agents/utils.js'; +import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { loadState, @@ -312,8 +312,11 @@ async function createNotesFromBatch( await createMessage(run.id, message); // Wait for the run to complete - await waitForRunCompletion(run.id); - unsubscribe(); + try { + await waitForRunCompletion(run.id, { throwOnError: true }); + } finally { + unsubscribe(); + } return { runId: run.id, notesCreated, notesModified }; } @@ -428,7 +431,7 @@ async function buildGraphWithFiles( runId: run.runId, level: 'error', message: `Error processing batch ${batchNumber}`, - error: error instanceof Error ? error.message : String(error), + error: getErrorDetails(error), context: { batchNumber }, }); } @@ -600,7 +603,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> { runId: run.runId, level: 'error', message: `Error processing voice memo batch ${batchNumber}`, - error: error instanceof Error ? error.message : String(error), + error: getErrorDetails(error), context: { batchNumber }, }); } diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts index 9ee57798..76ae579c 100644 --- a/apps/x/packages/core/src/knowledge/label_emails.ts +++ b/apps/x/packages/core/src/knowledge/label_emails.ts @@ -4,7 +4,7 @@ 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 { getErrorDetails, waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; import { @@ -112,8 +112,11 @@ async function labelEmailBatch( }); await createMessage(run.id, message); - await waitForRunCompletion(run.id); - unsubscribe(); + try { + await waitForRunCompletion(run.id, { throwOnError: true }); + } finally { + unsubscribe(); + } return { runId: run.id, filesEdited }; } @@ -175,6 +178,7 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU const totalBatches = batches.length; let totalEdited = 0; let hadError = false; + let failedBatches = 0; // Process batches with concurrency limit for (let i = 0; i < batches.length; i += concurrency) { @@ -209,14 +213,16 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU return result.filesEdited.size; } catch (error) { hadError = true; + failedBatches++; + const errorDetails = getErrorDetails(error); console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error); await serviceLogger.log({ type: 'error', service: run.service, runId: run.runId, level: 'error', - message: `Error processing batch ${batchNumber}`, - error: error instanceof Error ? error.message : String(error), + message: `Email labeling batch ${batchNumber}/${totalBatches} failed`, + error: errorDetails, context: { batchNumber }, }); return 0; @@ -238,12 +244,15 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU service: run.service, runId: run.runId, level: hadError ? 'error' : 'info', - message: `Email labeling complete: ${totalEdited} files labeled`, + message: hadError + ? `Email labeling finished with errors: ${totalEdited} files labeled` + : `Email labeling complete: ${totalEdited} files labeled`, durationMs: Date.now() - run.startedAt, outcome: hadError ? 'error' : 'ok', summary: { totalEmails: unlabeled.length, filesLabeled: totalEdited, + failedBatches, }, }); diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts index 7a888725..c1c45ae6 100644 --- a/apps/x/packages/core/src/knowledge/tag_notes.ts +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -4,7 +4,7 @@ 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 { getErrorDetails, waitForRunCompletion } from '../agents/utils.js'; import { serviceLogger } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; import { @@ -125,8 +125,11 @@ async function tagNoteBatch( }); await createMessage(run.id, message); - await waitForRunCompletion(run.id); - unsubscribe(); + try { + await waitForRunCompletion(run.id, { throwOnError: true }); + } finally { + unsubscribe(); + } return { runId: run.id, filesEdited }; } @@ -169,6 +172,7 @@ export async function processUntaggedNotes(): Promise<void> { const totalBatches = Math.ceil(untagged.length / BATCH_SIZE); let totalEdited = 0; let hadError = false; + let failedBatches = 0; for (let i = 0; i < untagged.length; i += BATCH_SIZE) { const batchPaths = untagged.slice(i, i + BATCH_SIZE); @@ -217,14 +221,16 @@ export async function processUntaggedNotes(): Promise<void> { console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`); } catch (error) { hadError = true; + failedBatches++; + const errorDetails = getErrorDetails(error); console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error); await serviceLogger.log({ type: 'error', service: run.service, runId: run.runId, level: 'error', - message: `Error processing batch ${batchNumber}`, - error: error instanceof Error ? error.message : String(error), + message: `Note tagging batch ${batchNumber}/${totalBatches} failed`, + error: errorDetails, context: { batchNumber }, }); } @@ -238,12 +244,15 @@ export async function processUntaggedNotes(): Promise<void> { service: run.service, runId: run.runId, level: hadError ? 'error' : 'info', - message: `Note tagging complete: ${totalEdited} notes tagged`, + message: hadError + ? `Note tagging finished with errors: ${totalEdited} notes tagged` + : `Note tagging complete: ${totalEdited} notes tagged`, durationMs: Date.now() - run.startedAt, outcome: hadError ? 'error' : 'ok', summary: { totalNotes: untagged.length, notesTagged: totalEdited, + failedBatches, }, }); From 7b119fbfcd228f2f05f8bd7744d9db721cd3f726 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 5 May 2026 19:56:57 +0530 Subject: [PATCH 035/143] refine note-writing instructions for self-reference and relationship phrasing --- .../core/src/knowledge/note_creation.ts | 37 +++++++++++++++++++ .../core/src/knowledge/note_system.ts | 6 +-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 0a4d8981..2f58bf64 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -807,6 +807,43 @@ The summary should answer: **"Who is this person and why do I know them?"** **Focus on the relationship, not the communication method.** +## Knowing Vs Meeting + +Distinguish between **knowing someone** and **having met or heard from them once**. + +- Use **"I know X through Y"** only when there is an actual ongoing relationship +- In that construction, **Y** should be a person, organization, or recurring context such as YC, an investor relationship, a customer relationship, or an ongoing project +- For one-off encounters, use **"I met X at/on/during..."** or lead with what they did, such as **"X reached out about..."**, **"X joined..."**, or **"X was part of..."** +- Do **not** use **"I know X through [an event]"** when the thing is a specific meeting, dinner, conference, demo day, call, or other one-off event +- Events are **when or where I met someone**, not **how I know them** +- If the source only shows a single meeting, a single inbound email, or a one-time introduction, do not imply an ongoing relationship unless the broader context clearly supports it + +Examples: + +- Incorrect: \`I know him through a YC dinner.\` +- Correct: \`I met him at a YC dinner.\` +- Incorrect: \`I know her through a call about pricing.\` +- Correct: \`She reached out about pricing.\` +- Correct: \`I know her through YC and ongoing investor conversations.\` + +## Perspective And Self-Reference + +These knowledge notes are written from the **user's first-person perspective**. + +- When the user's identity is known, **"I / me / my" refer to the user** +- When the company or team is the actor, use **"we / us / our"** when natural +- Name other participants normally +- **Do not refer to the user by name, email, or in third person inside first-person narration** +- Do not write broken combinations like **"I know him ... that met with Arjun"** when Arjun is the user +- Apply this consistently across **all note types and sections**: summaries, activity entries, timelines, decisions, open items, and any narrative prose + +Examples: + +- Incorrect: \`I know him as part of the Standard Capital team that met with Arjun and Ramnique.\` +- Correct: \`I know him as part of the Standard Capital team that met with me and Ramnique.\` +- Incorrect: \`Arjun discussed pricing with [[People/Sarah Chen]].\` +- Correct: \`I discussed pricing with [[People/Sarah Chen]].\` + ## Activity Summary One line summarizing this source's relevance to the entity: diff --git a/apps/x/packages/core/src/knowledge/note_system.ts b/apps/x/packages/core/src/knowledge/note_system.ts index a62a4e37..b7becb3f 100644 --- a/apps/x/packages/core/src/knowledge/note_system.ts +++ b/apps/x/packages/core/src/knowledge/note_system.ts @@ -26,7 +26,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ **Last update:** {YYYY-MM-DD} ## Summary -{2-3 sentences: Who they are, why you know them, what you're working on together.} +{2-3 sentences: Who they are, whether I know them through an ongoing relationship or met them in a specific encounter, and what we're discussing or working on together if applicable.} ## Connected to - [[Organizations/{Organization}]] — works at @@ -59,7 +59,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ **Last update:** {YYYY-MM-DD} ## Summary -{2-3 sentences: What this org is, what your relationship is.} +{2-3 sentences: What this org is, how I know or work with them.} ## People - [[People/{Person}]] — {role} @@ -93,7 +93,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ **Last update:** {YYYY-MM-DD} ## Summary -{2-3 sentences: What this project is, goal, current state.} +{2-3 sentences: What this project is, the goal, current state, and my/our involvement where relevant.} ## People - [[People/{Person}]] — {role} From e54b5cd27fdf09c2c222e2ea69562ed3ca841cf9 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 11:59:37 +0530 Subject: [PATCH 036/143] Background agents (#530) a common place to track and add background agents --- apps/x/apps/main/src/ipc.ts | 23 + apps/x/apps/renderer/src/App.tsx | 171 +++++- .../src/components/background-agents-view.tsx | 250 ++++++++ .../src/components/sidebar-content.tsx | 20 + .../skills/background-agents/skill.ts | 555 ------------------ .../src/application/assistant/skills/index.ts | 7 - .../assistant/skills/tracks/skill.ts | 14 + .../core/src/knowledge/track/fileops.ts | 118 +++- apps/x/packages/shared/src/ipc.ts | 29 + 9 files changed, 596 insertions(+), 591 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/background-agents-view.tsx delete mode 100644 apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 056bb4c3..682d46e6 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,6 +52,8 @@ import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; import { fetchYaml, + listNotesWithTracks, + setNoteTracksActive, updateTrackBlock, replaceTrackBlockYaml, deleteTrackBlock, @@ -135,6 +137,14 @@ function resolveShellPath(filePath: string): string { return workspace.resolveWorkspacePath(filePath); } +function toKnowledgeTrackPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized.startsWith('knowledge/')) { + throw new Error('Track note path must be within knowledge/') + } + return normalized.slice('knowledge/'.length) +} + type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -832,6 +842,19 @@ export function setupIpcHandlers() { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, + 'track:setNoteActive': async (_event, args) => { + try { + const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active); + if (!note) return { success: false, error: 'No track blocks found in note' }; + return { success: true, note }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'track:listNotes': async () => { + const notes = await listNotesWithTracks(); + return { notes }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 07f91e0b..c2e35cb2 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; +import { BackgroundAgentsView } from '@/components/background-agents-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -142,6 +143,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' +const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -271,6 +273,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH +const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -327,6 +330,24 @@ const buildSuggestedTopicExplorePrompt = ({ ].join('\n') } +const buildBackgroundAgentSetupPrompt = () => [ + 'Help me set up a background agent.', + 'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.', + 'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.', + 'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.', + 'Start with a short, plain-English explanation of what a background agent is.', + 'Do not make the explanation too terse.', + 'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.', + 'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.', + 'In the first reply, tell me that you will create this in my Tasks folder by default.', + 'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.', + 'Then ask only what I want it to monitor or update and how often I want it to run.', + 'Keep it concise and friendly, but not abrupt.', + 'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.', + 'Do not create or modify anything yet.', + 'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.', +].join('\n') + const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => { if (!usage) return null const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') @@ -508,6 +529,7 @@ type ViewState = | { type: 'graph' } | { type: 'task'; name: string } | { type: 'suggested-topics' } + | { type: 'background-agents' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -521,12 +543,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics>&... + * Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|background-agents>&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph * task: ?type=task&name=daily-brief * suggested-topics: ?type=suggested-topics + * background-agents: ?type=background-agents */ function parseDeepLink(input: string): ViewState | null { const SCHEME = 'rowboat://' @@ -551,6 +574,8 @@ function parseDeepLink(input: string): ViewState | null { } case 'suggested-topics': return { type: 'suggested-topics' } + case 'background-agents': + return { type: 'background-agents' } default: return null } @@ -656,7 +681,13 @@ function App() { const [isGraphOpen, setIsGraphOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) - const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null) + const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false) + const [expandedFrom, setExpandedFrom] = useState<{ + path: string | null + graph: boolean + suggestedTopics: boolean + backgroundAgents: boolean + } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], @@ -977,6 +1008,7 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' + if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2660,6 +2692,8 @@ function App() { if (existingTab) { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(path) return } @@ -2667,6 +2701,8 @@ function App() { setFileTabs(prev => [...prev, { id, path }]) setActiveFileTabId(id) setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2685,16 +2721,26 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + return + } + if (isBackgroundAgentsTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) return } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) @@ -2723,6 +2769,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2736,13 +2783,21 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + } else if (isBackgroundAgentsTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2773,8 +2828,13 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + setExpandedFrom({ + path: selectedPath, + graph: isGraphOpen, + suggestedTopics: isSuggestedTopicsOpen, + backgroundAgents: isBackgroundAgentsOpen, + }) } else { setExpandedFrom(null) } @@ -2782,7 +2842,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen]) + setIsBackgroundAgentsOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2897,27 +2958,40 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) { - setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen }) + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + setExpandedFrom({ + path: selectedPath, + graph: isGraphOpen, + suggestedTopics: isSuggestedTopicsOpen, + backgroundAgents: isBackgroundAgentsOpen, + }) } dismissBrowserOverlay() setIsRightPaneMaximized(false) setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, dismissBrowserOverlay]) + setIsBackgroundAgentsOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) + } else if (expandedFrom.backgroundAgents) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -2927,11 +3001,12 @@ function App() { const currentViewState = React.useMemo<ViewState>(() => { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (isBackgroundAgentsOpen) return { type: 'background-agents' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -2988,6 +3063,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureBackgroundAgentsFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -2997,6 +3083,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3011,6 +3098,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3023,6 +3111,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3035,8 +3124,20 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) + setIsBackgroundAgentsOpen(false) ensureSuggestedTopicsFileTab() return + case 'background-agents': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(true) + ensureBackgroundAgentsFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3045,6 +3146,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) + setIsBackgroundAgentsOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3052,7 +3154,7 @@ function App() { } return } - }, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3374,7 +3476,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3452,15 +3554,17 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen ? SUGGESTED_TOPICS_TAB_PATH + : isBackgroundAgentsOpen + ? BACKGROUND_AGENTS_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3515,7 +3619,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3540,7 +3644,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3662,14 +3766,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4253,7 +4357,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4270,7 +4374,7 @@ function App() { return ( <TooltipProvider delayDuration={0}> <SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => { - if (section === 'knowledge' && !selectedPath && !isGraphOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4303,7 +4407,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4314,7 +4418,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4338,14 +4442,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4375,6 +4479,8 @@ function App() { onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} + isBackgroundAgentsOpen={isBackgroundAgentsOpen} + onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })} /> <SidebarInset className={cn( @@ -4394,7 +4500,7 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? ( <TabBar tabs={fileTabs} activeTabId={activeFileTabId ?? ''} @@ -4402,7 +4508,7 @@ function App() { getTabId={(t) => t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( <TabBar @@ -4455,7 +4561,7 @@ function App() { <TooltipContent side="bottom">Version history</TooltipContent> </Tooltip> )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && ( <Tooltip> <TooltipTrigger asChild> <button @@ -4470,7 +4576,7 @@ function App() { <TooltipContent side="bottom">New chat tab</TooltipContent> </Tooltip> )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && ( <Tooltip> <TooltipTrigger asChild> <button @@ -4485,7 +4591,7 @@ function App() { <TooltipContent side="bottom">Restore two-pane view</TooltipContent> </Tooltip> )} - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && ( <Tooltip> <TooltipTrigger asChild> <button @@ -4518,6 +4624,15 @@ function App() { }} /> </div> + ) : isBackgroundAgentsOpen ? ( + <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> + <BackgroundAgentsView + onOpenNote={(path) => navigateToFile(path)} + onAddNewBackgroundAgent={() => { + submitFromPalette(buildBackgroundAgentSetupPrompt(), null) + }} + /> + </div> ) : selectedPath && isBaseFilePath(selectedPath) ? ( <div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <BasesView diff --git a/apps/x/apps/renderer/src/components/background-agents-view.tsx b/apps/x/apps/renderer/src/components/background-agents-view.tsx new file mode 100644 index 00000000..d09e4380 --- /dev/null +++ b/apps/x/apps/renderer/src/components/background-agents-view.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useState } from 'react' +import { Bot, Loader2 } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links' +import { toast } from '@/lib/toast' + +type BackgroundAgentNote = { + path: string + trackCount: number + createdAt: string | null + lastRunAt: string | null + isActive: boolean +} + +type BackgroundAgentsViewProps = { + onOpenNote: (path: string) => void + onAddNewBackgroundAgent: () => void +} + +function formatDateLabel(iso: string | null): string { + if (!iso) return '—' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '—' + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatDateTimeLabel(iso: string | null): string { + if (!iso) return 'Never' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return 'Never' + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function isKnowledgeMarkdownPath(path: string | undefined): boolean { + return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md') +} + +export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: BackgroundAgentsViewProps) { + const [notes, setNotes] = useState<BackgroundAgentNote[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set()) + + const loadNotes = useCallback(async () => { + setLoading(true) + try { + const result = await window.ipc.invoke('track:listNotes', null) + setNotes(result.notes) + setError(null) + } catch (err) { + console.error('Failed to load background agent notes:', err) + setError('Could not load background agents.') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadNotes() + }, [loadNotes]) + + useEffect(() => { + let timeout: ReturnType<typeof setTimeout> | null = null + + const scheduleReload = () => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + timeout = null + void loadNotes() + }, 200) + } + + const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => { + switch (event.type) { + case 'created': + case 'changed': + case 'deleted': + if (isKnowledgeMarkdownPath(event.path)) scheduleReload() + break + case 'moved': + if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) { + scheduleReload() + } + break + case 'bulkChanged': + if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) { + scheduleReload() + } + break + } + }) + + const cleanupTracks = window.ipc.on('tracks:events', () => { + scheduleReload() + }) + + return () => { + cleanupWorkspace() + cleanupTracks() + if (timeout) clearTimeout(timeout) + } + }, [loadNotes]) + + const handleToggleState = useCallback(async (note: BackgroundAgentNote, active: boolean) => { + setUpdatingPaths((prev) => new Set(prev).add(note.path)) + try { + const result = await window.ipc.invoke('track:setNoteActive', { + path: note.path, + active, + }) + + if (!result.success || !result.note) { + throw new Error(result.error ?? 'Failed to update background agent state') + } + + const updatedNote = result.note + setNotes((prev) => prev.map((entry) => ( + entry.path === note.path ? updatedNote : entry + ))) + } catch (err) { + console.error('Failed to update background agent note state:', err) + toast(err instanceof Error ? err.message : 'Failed to update background agent state', 'error') + } finally { + setUpdatingPaths((prev) => { + const next = new Set(prev) + next.delete(note.path) + return next + }) + } + }, []) + + return ( + <div className="flex h-full flex-col overflow-hidden"> + <div className="shrink-0 border-b border-border px-6 py-5"> + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-2"> + <Bot className="size-5 text-primary" /> + <h2 className="text-base font-semibold text-foreground">Background agents</h2> + </div> + <Button type="button" size="sm" onClick={onAddNewBackgroundAgent}> + Add new background agent + </Button> + </div> + <p className="mt-1 text-xs text-muted-foreground"> + Notes that contain track blocks. Toggle a note inactive to pause every background agent in it. + </p> + </div> + <div className="flex-1 overflow-auto p-6"> + {loading ? ( + <div className="flex h-full items-center justify-center"> + <Loader2 className="size-5 animate-spin text-muted-foreground" /> + </div> + ) : error ? ( + <div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center"> + <div className="rounded-full bg-muted p-3"> + <Bot className="size-6 text-muted-foreground" /> + </div> + <p className="text-sm text-muted-foreground">{error}</p> + </div> + ) : notes.length === 0 ? ( + <div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center"> + <div className="rounded-full bg-muted p-3"> + <Bot className="size-6 text-muted-foreground" /> + </div> + <p className="text-sm text-muted-foreground"> + No notes with background agents yet. + </p> + </div> + ) : ( + <div className="overflow-hidden rounded-xl border border-border/60 bg-card"> + <table className="min-w-full border-collapse"> + <thead> + <tr className="border-b border-border/60 bg-muted/30 text-left"> + <th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th> + <th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created date</th> + <th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th> + <th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th> + </tr> + </thead> + <tbody> + {notes.map((note) => { + const isUpdating = updatingPaths.has(note.path) + return ( + <tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20"> + <td className="px-4 py-3 align-top"> + <div className="flex min-w-0 flex-col gap-1"> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => onOpenNote(note.path)} + className="truncate text-left text-sm font-medium text-foreground hover:text-primary" + title={note.path} + > + {wikiLabel(note.path)} + </button> + <span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> + {note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'} + </span> + </div> + <div className="truncate text-xs text-muted-foreground"> + {stripKnowledgePrefix(note.path)} + </div> + </div> + </td> + <td className="px-4 py-3 text-sm text-foreground/80"> + {formatDateLabel(note.createdAt)} + </td> + <td className="px-4 py-3 text-sm text-foreground/80"> + {formatDateTimeLabel(note.lastRunAt)} + </td> + <td className="px-4 py-3"> + <div className="flex items-center gap-3"> + {isUpdating ? ( + <Loader2 className="size-4 animate-spin text-muted-foreground" /> + ) : ( + <span className="size-4 shrink-0" aria-hidden="true" /> + )} + <Switch + checked={note.isActive} + onCheckedChange={(checked) => { void handleToggleState(note, checked) }} + disabled={isUpdating} + /> + <span className="min-w-16 text-xs font-medium text-foreground/80"> + {note.isActive ? 'Active' : 'Inactive'} + </span> + </div> + </td> + </tr> + ) + })} + </tbody> + </table> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 9c50c334..dc49307c 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -214,6 +214,8 @@ type SidebarContentPanelProps = { onToggleBrowser?: () => void isSuggestedTopicsOpen?: boolean onOpenSuggestedTopics?: () => void + isBackgroundAgentsOpen?: boolean + onOpenBackgroundAgents?: () => void } & React.ComponentProps<typeof Sidebar> const sectionTabs: { id: ActiveSection; label: string }[] = [ @@ -491,6 +493,8 @@ export function SidebarContentPanel({ onToggleBrowser, isSuggestedTopicsOpen = false, onOpenSuggestedTopics, + isBackgroundAgentsOpen = false, + onOpenBackgroundAgents, ...props }: SidebarContentPanelProps) { const { activeSection, setActiveSection } = useSidebarSection() @@ -506,6 +510,7 @@ export function SidebarContentPanel({ const isMeetingQuickActionSelected = isMeetingActionActive const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen + const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen const handleRowboatLogin = useCallback(async () => { try { @@ -679,6 +684,21 @@ export function SidebarContentPanel({ <span>Suggested Topics</span> </button> )} + {onOpenBackgroundAgents && ( + <button + type="button" + onClick={onOpenBackgroundAgents} + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", + isBackgroundAgentsQuickActionSelected + ? "bg-sidebar-accent text-sidebar-accent-foreground" + : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + )} + > + <Bot className="size-4" /> + <span>Background agents</span> + </button> + )} </div> </SidebarHeader> <SidebarContent> diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts deleted file mode 100644 index b8e481b6..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts +++ /dev/null @@ -1,555 +0,0 @@ -export const skill = String.raw` -# Background Agents - -Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace. - -## Core Concepts - -**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. - -- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter -- Agents configure a model, tools (in frontmatter), and instructions (in the body) -- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** -- **"Workflows" are just agents that orchestrate other agents** by having them as tools -- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root - -## How multi-agent workflows work - -1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + ` -2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below) -3. The orchestrator calls other agents as tools when needed -4. Data flows through tool call parameters and responses - -## Scheduling Background Agents - -Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root. - -### Schedule Configuration File - -` + "```json" + ` -{ - "agents": { - "agent_name": { - "schedule": { ... }, - "enabled": true - } - } -} -` + "```" + ` - -### Schedule Types - -**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat). - -**1. Cron Schedule** - Runs at exact times defined by cron expression -` + "```json" + ` -{ - "schedule": { - "type": "cron", - "expression": "0 8 * * *" - }, - "enabled": true -} -` + "```" + ` - -Common cron expressions: -- ` + "`*/5 * * * *`" + ` - Every 5 minutes -- ` + "`0 8 * * *`" + ` - Every day at 8am -- ` + "`0 9 * * 1`" + ` - Every Monday at 9am -- ` + "`0 0 1 * *`" + ` - First day of every month at midnight - -**2. Window Schedule** - Runs once during a time window -` + "```json" + ` -{ - "schedule": { - "type": "window", - "cron": "0 0 * * *", - "startTime": "08:00", - "endTime": "10:00" - }, - "enabled": true -} -` + "```" + ` - -The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am"). - -**3. Once Schedule** - Runs exactly once at a specific time -` + "```json" + ` -{ - "schedule": { - "type": "once", - "runAt": "2024-02-05T10:30:00" - }, - "enabled": true -} -` + "```" + ` - -Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix). - -### Starting Message - -You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `. - -` + "```json" + ` -{ - "schedule": { "type": "cron", "expression": "0 8 * * *" }, - "enabled": true, - "startingMessage": "Please summarize my emails from the last 24 hours" -} -` + "```" + ` - -### Description - -You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI. - -` + "```json" + ` -{ - "schedule": { "type": "cron", "expression": "0 8 * * *" }, - "enabled": true, - "description": "Summarizes emails and calendar events every morning" -} -` + "```" + ` - -### Complete Schedule Example - -` + "```json" + ` -{ - "agents": { - "daily_digest": { - "schedule": { - "type": "cron", - "expression": "0 8 * * *" - }, - "enabled": true, - "description": "Daily email and calendar summary", - "startingMessage": "Summarize my emails and calendar for today" - }, - "morning_briefing": { - "schedule": { - "type": "window", - "cron": "0 0 * * *", - "startTime": "07:00", - "endTime": "09:00" - }, - "enabled": true, - "description": "Morning news and updates briefing" - }, - "one_time_setup": { - "schedule": { - "type": "once", - "runAt": "2024-12-01T12:00:00" - }, - "enabled": true, - "description": "One-time data migration task" - } - } -} -` + "```" + ` - -### Schedule State (Read-Only) - -**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. - -The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root: -- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) -- ` + "`lastRunAt`" + `: When the agent last ran -- ` + "`nextRunAt`" + `: When the agent will run next -- ` + "`lastError`" + `: Error message if the last run failed -- ` + "`runCount`" + `: Total number of runs - -When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `. - -## Agent File Format - -Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. - -### Basic Structure -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - tool_key: - type: builtin - name: tool_name ---- -# Instructions - -Your detailed instructions go here in Markdown format. -` + "```" + ` - -### Frontmatter Fields -- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') -- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json -- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions - -### Instructions (Body) -The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. - -### Naming Rules -- Agent filename determines the agent name (without .md extension) -- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent" -- Use lowercase with underscores for multi-word names -- No spaces or special characters in names -- **The agent name in agent-schedule.json must match the filename** (without .md) - -### Agent Format Example -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query ---- -# Web Search Agent - -You are a web search agent. When asked a question: - -1. Use the search tool to find relevant information -2. Summarize the results clearly -3. Cite your sources - -Be concise and accurate. -` + "```" + ` - -## Tool Types & Schemas - -Tools in agents must follow one of three types. Each has specific required fields. - -### 1. Builtin Tools -Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) - -**YAML Schema:** -` + "```yaml" + ` -tool_key: - type: builtin - name: tool_name -` + "```" + ` - -**Required fields:** -- ` + "`type`" + `: Must be "builtin" -- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile") - -**Example:** -` + "```yaml" + ` -bash: - type: builtin - name: executeCommand -` + "```" + ` - -**Available builtin tools:** -- ` + "`executeCommand`" + ` - Execute shell commands -- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations -- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations -- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management -- ` + "`analyzeAgent`" + ` - Analyze agent structure -- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management -- ` + "`loadSkill`" + ` - Load skill guidance - -### 2. MCP Tools -Tools from external MCP servers (APIs, databases, web scraping, etc.) - -**YAML Schema:** -` + "```yaml" + ` -tool_key: - type: mcp - name: tool_name_from_server - description: What the tool does - mcpServerName: server_name_from_config - inputSchema: - type: object - properties: - param: - type: string - description: Parameter description - required: - - param -` + "```" + ` - -**Required fields:** -- ` + "`type`" + `: Must be "mcp" -- ` + "`name`" + `: Exact tool name from MCP server -- ` + "`description`" + `: What the tool does (helps agent understand when to use it) -- ` + "`mcpServerName`" + `: Server name from config/mcp.json -- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters - -**Example:** -` + "```yaml" + ` -search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query -` + "```" + ` - -**Important:** -- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server -- Copy the schema exactly—don't modify property types or structure -- Only include ` + "`required`" + ` array if parameters are mandatory - -### 3. Agent Tools (for chaining agents) -Reference other agents as tools to build multi-agent workflows - -**YAML Schema:** -` + "```yaml" + ` -tool_key: - type: agent - name: target_agent_name -` + "```" + ` - -**Required fields:** -- ` + "`type`" + `: Must be "agent" -- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory) - -**Example:** -` + "```yaml" + ` -summariser: - type: agent - name: summariser_agent -` + "```" + ` - -**How it works:** -- Use ` + "`type: agent`" + ` to call other agents as tools -- The target agent will be invoked with the parameters you pass -- Results are returned as tool output -- This is how you build multi-agent workflows -- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `) - -## Complete Multi-Agent Workflow Example - -**Email digest workflow** - This is all done through agents calling other agents: - -**1. Task-specific agent** (` + "`agents/email_reader.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - read_file: - type: builtin - name: workspace-readFile - list_dir: - type: builtin - name: workspace-readdir ---- -# Email Reader Agent - -Read emails from the gmail_sync folder and extract key information. -Look for unread or recent emails and summarize the sender, subject, and key points. -Don't ask for human input. -` + "```" + ` - -**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - email_reader: - type: agent - name: email_reader - write_file: - type: builtin - name: workspace-writeFile ---- -# Daily Summary Agent - -1. Use the email_reader tool to get email summaries -2. Create a consolidated daily digest -3. Save the digest to ~/Desktop/daily_digest.md - -Don't ask for human input. -` + "```" + ` - -Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions. - -**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - daily_summary: - type: agent - name: daily_summary - search: - type: mcp - name: search - mcpServerName: exa - description: Search the web for news - inputSchema: - type: object - properties: - query: - type: string - description: Search query ---- -# Morning Briefing Workflow - -Create a morning briefing: - -1. Get email digest using daily_summary -2. Search for relevant news using the search tool -3. Compile a comprehensive morning briefing - -Execute these steps in sequence. Don't ask for human input. -` + "```" + ` - -**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `: -` + "```json" + ` -{ - "agents": { - "morning_briefing": { - "schedule": { - "type": "cron", - "expression": "0 7 * * *" - }, - "enabled": true, - "startingMessage": "Create my morning briefing for today" - } - } -} -` + "```" + ` - -This schedules the morning briefing workflow to run every day at 7am local time. - -## Naming and organization rules -- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- When referencing an agent as a tool, use its filename without extension -- When scheduling an agent, use its filename without extension in agent-schedule.json -- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users - -## Best practices for background agents -1. **Single responsibility**: Each agent should do one specific thing well -2. **Clear delegation**: Agent instructions should explicitly say when to call other agents -3. **Autonomous operation**: Add "Don't ask for human input" for background agents -4. **Data passing**: Make it clear what data to extract and pass between agents -5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") -6. **Orchestration**: Create a top-level agent that coordinates the workflow -7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks -8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene -9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations -10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md" - -## Validation & Best Practices - -### CRITICAL: Schema Compliance -- Agent files MUST be valid Markdown with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent") -- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema -- Agent tools MUST reference existing agent files -- Invalid agents will fail to load and prevent workflow execution - -### File Creation/Update Process -1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter -2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + ` -3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent -4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `) -5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + ` - -### Common Validation Errors to Avoid - -❌ **WRONG - Missing frontmatter delimiters:** -` + "```markdown" + ` -model: gpt-5.1 -# My Agent -Instructions here -` + "```" + ` - -❌ **WRONG - Invalid YAML indentation:** -` + "```markdown" + ` ---- -tools: -bash: - type: builtin ---- -` + "```" + ` -(bash should be indented under tools) - -❌ **WRONG - Invalid tool type:** -` + "```yaml" + ` -tools: - tool1: - type: custom - name: something -` + "```" + ` -(type must be builtin, mcp, or agent) - -❌ **WRONG - Unquoted strings containing colons:** -` + "```yaml" + ` -tools: - search: - description: Number of results (default: 8) -` + "```" + ` -(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `) - -❌ **WRONG - MCP tool missing required fields:** -` + "```yaml" + ` -tools: - search: - type: mcp - name: firecrawl_search -` + "```" + ` -(Missing: description, mcpServerName, inputSchema) - -✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 ---- -# Simple Agent - -Do simple tasks as instructed. -` + "```" + ` - -✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `): -` + "```markdown" + ` ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string ---- -# Search Agent - -Use the search tool to find information on the web. -` + "```" + ` - -## Capabilities checklist -1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing -2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes -3. Validate YAML frontmatter syntax before creating/updating agents -4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update -5. When creating multi-agent workflows, create an orchestrator agent -6. Add other agents as tools with ` + "`type: agent`" + ` for chaining -7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations -8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) -9. Confirm work done and outline next steps once changes are complete -`; - -export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 6d3cdc5b..f4ba9b1d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -7,7 +7,6 @@ import draftEmailsSkill from "./draft-emails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; -import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; import appNavigationSkill from "./app-navigation/skill.js"; @@ -65,12 +64,6 @@ const definitions: SkillDefinition[] = [ summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.", content: organizeFilesSkill, }, - { - id: "background-agents", - title: "Background Agents", - summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.", - content: backgroundAgentsSkill, - }, { id: "builtin-tools", title: "Builtin Tools Reference", diff --git a/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts b/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts index 17521806..c9624c66 100644 --- a/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/tracks/skill.ts @@ -349,6 +349,20 @@ In that flow: 6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. 7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed. +### Background agent setup flow + +Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet. + +In this flow, treat "background agent" and "track block" as the same feature. The user-facing term can stay "background agent", but the implementation is a track block inside a note. Do **not** claim these are different systems, and do **not** redirect the user toward standalone agent files or ` + "`" + `agent-schedule.json` + "`" + ` unless they explicitly ask for that architecture. + +In that flow: +1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run. +2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder or there is a real ambiguity you cannot resolve. +3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder. +4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists. +5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup instead of bouncing back to ask. +6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. + ## The Exact Text to Insert Write it verbatim like this (including the blank line between fence and target): diff --git a/apps/x/packages/core/src/knowledge/track/fileops.ts b/apps/x/packages/core/src/knowledge/track/fileops.ts index bd731823..b7ade8de 100644 --- a/apps/x/packages/core/src/knowledge/track/fileops.ts +++ b/apps/x/packages/core/src/knowledge/track/fileops.ts @@ -70,6 +70,122 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer< return blocks.find(b => b.track.trackId === trackId) ?? null; } +type TrackNoteSummary = { + path: string; + trackCount: number; + createdAt: string | null; + lastRunAt: string | null; + isActive: boolean; +}; + +async function summarizeTrackNote( + filePath: string, + tracks: z.infer<typeof TrackStateSchema>[], +): Promise<TrackNoteSummary | null> { + if (tracks.length === 0) return null; + + const stats = await fs.stat(absPath(filePath)); + const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs; + + let latestRunAt: string | null = null; + let latestRunMs = -1; + for (const { track } of tracks) { + if (!track.lastRunAt) continue; + const candidateMs = Date.parse(track.lastRunAt); + if (Number.isNaN(candidateMs) || candidateMs <= latestRunMs) continue; + latestRunMs = candidateMs; + latestRunAt = track.lastRunAt; + } + + return { + path: `knowledge/${filePath}`, + trackCount: tracks.length, + createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null, + lastRunAt: latestRunAt, + isActive: tracks.every(({ track }) => track.active !== false), + }; +} + +export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> { + async function walk(relativeDir = ''): Promise<string[]> { + const dirPath = absPath(relativeDir); + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + + const childRelPath = relativeDir + ? path.posix.join(relativeDir, entry.name) + : entry.name; + + if (entry.isDirectory()) { + files.push(...await walk(childRelPath)); + continue; + } + + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + files.push(childRelPath); + } + } + + return files; + } catch { + return []; + } + } + + const markdownFiles = await walk(); + const notes = await Promise.all(markdownFiles.map(async (relativePath) => { + try { + const tracks = await fetchAll(relativePath); + return await summarizeTrackNote(relativePath, tracks); + } catch { + return null; + } + })); + + return notes + .filter((note): note is TrackNoteSummary => note !== null) + .sort((a, b) => { + const aName = path.basename(a.path, '.md').toLowerCase(); + const bName = path.basename(b.path, '.md').toLowerCase(); + if (aName !== bName) return aName.localeCompare(bName); + return a.path.localeCompare(b.path); + }); +} + +export async function setNoteTracksActive(filePath: string, active: boolean): Promise<TrackNoteSummary | null> { + return withFileLock(absPath(filePath), async () => { + const blocks = await fetchAll(filePath); + if (blocks.length === 0) return null; + + const alreadyMatches = blocks.every(({ track }) => (track.active !== false) === active); + if (alreadyMatches) { + return summarizeTrackNote(filePath, blocks); + } + + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const lines = content.split('\n'); + const updatedBlocks = blocks + .map((block) => ({ + ...block, + track: { ...block.track, active }, + })) + .sort((a, b) => b.fenceStart - a.fenceStart); + + for (const block of updatedBlocks) { + const yaml = stringifyYaml(block.track).trimEnd(); + const yamlLines = yaml ? yaml.split('\n') : []; + lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); + } + + await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + return summarizeTrackNote(filePath, updatedBlocks); + }); +} + /** * Fetch a track block and return its canonical YAML string (or null if not found). * Useful for IPC handlers that need to return the fresh YAML without taking a @@ -196,4 +312,4 @@ export async function deleteTrackBlock(filePath: string, trackId: string): Promi await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); }); -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 605b26d9..9e62f3d9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -662,6 +662,35 @@ const ipcSchemas = { error: z.string().optional(), }), }, + 'track:setNoteActive': { + req: z.object({ + path: RelPath, + active: z.boolean(), + }), + res: z.object({ + success: z.boolean(), + note: z.object({ + path: RelPath, + trackCount: z.number().int().positive(), + createdAt: z.string().nullable(), + lastRunAt: z.string().nullable(), + isActive: z.boolean(), + }).optional(), + error: z.string().optional(), + }), + }, + 'track:listNotes': { + req: z.null(), + res: z.object({ + notes: z.array(z.object({ + path: RelPath, + trackCount: z.number().int().positive(), + createdAt: z.string().nullable(), + lastRunAt: z.string().nullable(), + isActive: z.boolean(), + })), + }), + }, // Embedded browser (WebContentsView) channels 'browser:setBounds': { req: z.object({ From 72ed4bd6d9f7caa3850c3105f7e128bf40c5e19f Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 12:25:10 +0530 Subject: [PATCH 037/143] pull browser-harness skills (#519) use browser-harness skill without eval or http-fetch --- .../apps/main/src/browser/control-service.ts | 30 ++- .../assistant/skills/browser-control/skill.ts | 17 +- .../src/application/browser-skills/index.ts | 3 + .../src/application/browser-skills/loader.ts | 215 ++++++++++++++++++ .../src/application/browser-skills/matcher.ts | 56 +++++ .../core/src/application/lib/builtin-tools.ts | 66 ++++++ apps/x/packages/shared/src/browser-control.ts | 8 + 7 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 apps/x/packages/core/src/application/browser-skills/index.ts create mode 100644 apps/x/packages/core/src/application/browser-skills/loader.ts create mode 100644 apps/x/packages/core/src/application/browser-skills/matcher.ts diff --git a/apps/x/apps/main/src/browser/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts index b83ea7cb..7c97ea7a 100644 --- a/apps/x/apps/main/src/browser/control-service.ts +++ b/apps/x/apps/main/src/browser/control-service.ts @@ -1,8 +1,24 @@ import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js'; -import type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js'; +import type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js'; +import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js'; import { browserViewManager } from './view.js'; import { normalizeNavigationTarget } from './navigation.js'; +async function getSuggestedSkills(url: string | undefined): Promise<SuggestedBrowserSkill[] | undefined> { + if (!url) return undefined; + try { + const status = await ensureLoaded(); + if (status.status === 'ready' || status.status === 'stale') { + const matched = matchSkillsForUrl(status.index, url); + if (matched.length === 0) return undefined; + return matched.map((e) => ({ id: e.id, title: e.title, path: e.path })); + } + } catch (err) { + console.warn('[browser-control] suggestedSkills lookup failed:', err); + } + return undefined; +} + function buildSuccessResult( action: BrowserControlAction, message: string, @@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult( 'new-tab', target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', page, ); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'switch-tab': { @@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('navigate', `Navigated to ${target}.`, page); + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'back': { @@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok || !result.page) { return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.'); } - return buildSuccessResult('read-page', 'Read the current page.', result.page); + const suggestedSkills = await getSuggestedSkills(result.page.url); + const success = buildSuccessResult('read-page', 'Read the current page.', result.page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'click': { diff --git a/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts b/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts index f1c06f0c..868ce8e8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/browser-control/skill.ts @@ -14,8 +14,10 @@ Use this skill when the user asks you to open a website, browse in-app, search t - page ` + "`url`" + ` and ` + "`title`" + ` - visible page text - interactable elements with numbered ` + "`index`" + ` values -4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `. -5. After each action, read the returned page snapshot before deciding the next step. + - ` + "`suggestedSkills`" + ` — site-specific and interaction-specific skill hints for the current page +4. **Always inspect ` + "`suggestedSkills`" + ` before acting.** If any skill in the list matches what the user asked for (site or task), call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` *first*, read it in full, then plan your actions. These skills encode selectors, timing, and gotchas that would otherwise cost you several failed attempts to rediscover. If no skill matches, proceed — but do not skip this check. +5. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `. +6. After each action, read the returned page snapshot before deciding the next step — including re-checking ` + "`suggestedSkills`" + ` if the navigation landed you on a new domain. ## Actions @@ -92,12 +94,23 @@ Wait for the page to settle, useful after async UI changes. Parameters: - ` + "`ms`" + `: milliseconds to wait (optional) +## Companion Tools + +### load-browser-skill +Rowboat caches a library of browser skills (from ` + "`browser-use/browser-harness`" + `) indexed by both **domain** (github, linkedin, amazon, booking, …) and **interaction type** within a domain (e.g. ` + "`github/repo-actions`" + `, ` + "`github/scraping`" + `, ` + "`arxiv-bulk/*`" + `). Whenever ` + "`browser-control`" + ` returns a ` + "`suggestedSkills`" + ` array — which it does on ` + "`navigate`" + `, ` + "`new-tab`" + `, and ` + "`read-page`" + ` — treat it as a required reading step, not optional. Pick the entry that matches the current task (domain match first, then the interaction-specific variant if one exists) and call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` before attempting the action. + +You can also proactively call ` + "`load-browser-skill({ action: \"list\", site: \"<site>\" })`" + ` when you know you're about to work on a site, to see what skills exist even if ` + "`suggestedSkills`" + ` is empty (e.g. before navigating). + +These skills are written against a Python harness, so treat them as **reference knowledge**. Reuse the selectors, timing, and sequencing, but adapt them to Rowboat's structured browser actions. **Do not look for or call ` + "`http-fetch`" + `.** If a browser-harness recipe suggests ` + "`js(...)`" + ` or ` + "`http_get(...)`" + ` style shortcuts, treat those as non-portable and fall back to reading and interacting with the page itself. + ## Important Rules - Prefer ` + "`read-page`" + ` before interacting. - Prefer element ` + "`index`" + ` over CSS selectors. - If the tool says the snapshot is stale, call ` + "`read-page`" + ` again. - After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state. +- **Always check ` + "`suggestedSkills`" + ` after ` + "`navigate`" + `, ` + "`new-tab`" + `, or ` + "`read-page`" + `, and load the matching domain or interaction skill before acting.** Skipping this step is the single most common way to waste a dozen failed clicks on a site whose quirks are already documented. If the array is empty, proceed normally — but don't skip the check. +- Do not try to use ` + "`http-fetch`" + `. If a browser-harness recipe mentions ` + "`http_get(...)`" + ` or a public API shortcut, adapt it to DOM-based browsing instead. - Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary. - Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs. - If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card. diff --git a/apps/x/packages/core/src/application/browser-skills/index.ts b/apps/x/packages/core/src/application/browser-skills/index.ts new file mode 100644 index 00000000..2040c963 --- /dev/null +++ b/apps/x/packages/core/src/application/browser-skills/index.ts @@ -0,0 +1,3 @@ +export { ensureLoaded, readSkillContent, refreshFromRemote } from './loader.js'; +export type { SkillEntry, SkillsIndex, LoaderStatus } from './loader.js'; +export { matchSkillsForUrl } from './matcher.js'; diff --git a/apps/x/packages/core/src/application/browser-skills/loader.ts b/apps/x/packages/core/src/application/browser-skills/loader.ts new file mode 100644 index 00000000..3e68d7ca --- /dev/null +++ b/apps/x/packages/core/src/application/browser-skills/loader.ts @@ -0,0 +1,215 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { WorkDir } from '../../config/config.js'; + +const REPO_OWNER = 'browser-use'; +const REPO_NAME = 'browser-harness'; +const REPO_BRANCH = 'main'; +const DOMAIN_SKILLS_PREFIX = 'domain-skills/'; + +const MANIFEST_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 20_000; + +export type SkillEntry = { + id: string; // e.g. "github/repo-actions" + site: string; // e.g. "github" + fileName: string; // e.g. "repo-actions.md" + title: string; // first H1 from the markdown, or a derived title + path: string; // relative repo path, e.g. "domain-skills/github/repo-actions.md" + localPath: string; // absolute path on disk +}; + +export type SkillsIndex = { + fetchedAt: number; + treeSha: string; + entries: SkillEntry[]; +}; + +export type LoaderStatus = + | { status: 'ready'; index: SkillsIndex } + | { status: 'stale'; index: SkillsIndex; refreshing: boolean } + | { status: 'empty' } + | { status: 'error'; error: string }; + +const cacheRoot = () => path.join(WorkDir, 'cache', 'browser-skills'); +const skillsDir = () => path.join(cacheRoot(), 'domain-skills'); +const manifestPath = () => path.join(cacheRoot(), 'manifest.json'); + +async function ensureCacheDir(): Promise<void> { + await fs.mkdir(skillsDir(), { recursive: true }); +} + +async function readManifest(): Promise<SkillsIndex | null> { + try { + const raw = await fs.readFile(manifestPath(), 'utf8'); + const parsed = JSON.parse(raw) as SkillsIndex; + if (!parsed.entries || !Array.isArray(parsed.entries)) return null; + return parsed; + } catch { + return null; + } +} + +async function writeManifest(index: SkillsIndex): Promise<void> { + await ensureCacheDir(); + await fs.writeFile(manifestPath(), JSON.stringify(index, null, 2), 'utf8'); +} + +function extractTitle(markdown: string, fallback: string): string { + const match = markdown.match(/^#\s+(.+?)\s*$/m); + if (match?.[1]) return match[1].trim(); + return fallback; +} + +async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { + ...init, + signal: controller.signal, + headers: { + 'User-Agent': 'rowboat-browser-skills', + Accept: 'application/vnd.github+json', + ...(init?.headers ?? {}), + }, + }); + } finally { + clearTimeout(timer); + } +} + +type GithubTreeNode = { path: string; type: string; sha: string }; + +async function fetchRepoTree(): Promise<{ treeSha: string; skillPaths: string[] }> { + const branchUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches/${REPO_BRANCH}`; + const branchRes = await fetchWithTimeout(branchUrl); + if (!branchRes.ok) { + throw new Error(`GitHub branch fetch failed: ${branchRes.status} ${branchRes.statusText}`); + } + const branch = (await branchRes.json()) as { commit: { commit: { tree: { sha: string } } } }; + const treeSha = branch.commit?.commit?.tree?.sha; + if (!treeSha) throw new Error('Could not resolve tree SHA from branch response.'); + + const treeUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/${treeSha}?recursive=1`; + const treeRes = await fetchWithTimeout(treeUrl); + if (!treeRes.ok) { + throw new Error(`GitHub tree fetch failed: ${treeRes.status} ${treeRes.statusText}`); + } + const tree = (await treeRes.json()) as { tree: GithubTreeNode[]; truncated: boolean }; + + const skillPaths = tree.tree + .filter((n) => n.type === 'blob' && n.path.startsWith(DOMAIN_SKILLS_PREFIX) && n.path.endsWith('.md')) + .map((n) => n.path); + + return { treeSha, skillPaths }; +} + +async function fetchRawFile(repoPath: string): Promise<string> { + const url = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}/${repoPath}`; + const res = await fetchWithTimeout(url, { headers: { Accept: 'text/plain' } }); + if (!res.ok) { + throw new Error(`Raw file fetch failed for ${repoPath}: ${res.status} ${res.statusText}`); + } + return res.text(); +} + +function parseRepoPath(repoPath: string): { id: string; site: string; fileName: string } | null { + const rel = repoPath.slice(DOMAIN_SKILLS_PREFIX.length); + const parts = rel.split('/'); + if (parts.length < 2) return null; + const site = parts[0]; + const fileName = parts.slice(1).join('/'); + const id = rel.replace(/\.md$/, ''); + return { id, site, fileName }; +} + +export async function refreshFromRemote(): Promise<SkillsIndex> { + await ensureCacheDir(); + const { treeSha, skillPaths } = await fetchRepoTree(); + + const entries: SkillEntry[] = []; + await Promise.all(skillPaths.map(async (repoPath) => { + const parsed = parseRepoPath(repoPath); + if (!parsed) return; + try { + const content = await fetchRawFile(repoPath); + const localRel = path.join(parsed.site, parsed.fileName); + const localPath = path.join(skillsDir(), localRel); + await fs.mkdir(path.dirname(localPath), { recursive: true }); + await fs.writeFile(localPath, content, 'utf8'); + entries.push({ + id: parsed.id, + site: parsed.site, + fileName: parsed.fileName, + title: extractTitle(content, parsed.id), + path: repoPath, + localPath, + }); + } catch (err) { + console.warn(`[browser-skills] Failed to fetch ${repoPath}:`, err); + } + })); + + entries.sort((a, b) => a.id.localeCompare(b.id)); + + const index: SkillsIndex = { + fetchedAt: Date.now(), + treeSha, + entries, + }; + await writeManifest(index); + return index; +} + +let inFlightRefresh: Promise<SkillsIndex> | null = null; + +export async function ensureLoaded(options?: { forceRefresh?: boolean }): Promise<LoaderStatus> { + try { + const existing = await readManifest(); + const fresh = existing && Date.now() - existing.fetchedAt < MANIFEST_TTL_MS; + + if (existing && fresh && !options?.forceRefresh) { + return { status: 'ready', index: existing }; + } + + if (existing && !options?.forceRefresh) { + if (!inFlightRefresh) { + inFlightRefresh = refreshFromRemote() + .catch((err) => { + console.warn('[browser-skills] Background refresh failed:', err); + return existing; + }) + .finally(() => { inFlightRefresh = null; }); + } + return { status: 'stale', index: existing, refreshing: true }; + } + + if (!inFlightRefresh) { + inFlightRefresh = refreshFromRemote().finally(() => { inFlightRefresh = null; }); + } + try { + const index = await inFlightRefresh; + return { status: 'ready', index }; + } catch (err) { + return { status: 'error', error: err instanceof Error ? err.message : 'Failed to load skills.' }; + } + } catch (err) { + return { status: 'error', error: err instanceof Error ? err.message : 'Skill loader failed.' }; + } +} + +export async function readSkillContent(id: string): Promise<{ ok: true; content: string; entry: SkillEntry } | { ok: false; error: string }> { + const status = await ensureLoaded(); + if (status.status === 'error' || status.status === 'empty') { + return { ok: false, error: status.status === 'error' ? status.error : 'No skills cached yet.' }; + } + const entry = status.index.entries.find((e) => e.id === id); + if (!entry) return { ok: false, error: `Skill '${id}' not found.` }; + try { + const content = await fs.readFile(entry.localPath, 'utf8'); + return { ok: true, content, entry }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Failed to read skill file.' }; + } +} diff --git a/apps/x/packages/core/src/application/browser-skills/matcher.ts b/apps/x/packages/core/src/application/browser-skills/matcher.ts new file mode 100644 index 00000000..a4aabde8 --- /dev/null +++ b/apps/x/packages/core/src/application/browser-skills/matcher.ts @@ -0,0 +1,56 @@ +import type { SkillEntry, SkillsIndex } from './loader.js'; + +/** + * Map browser-harness `domain-skills/<site>/` folder names to hostname tokens we + * match against the current tab's URL. + * + * Heuristic: for each site folder we generate candidate hostnames like + * "booking-com" -> ["booking-com", "bookingcom", "booking.com"] + * "github" -> ["github", "github.com"] + * "dev-to" -> ["dev-to", "devto", "dev.to"] + * Then we check whether any candidate is a substring of the tab hostname. + */ +function siteCandidates(site: string): string[] { + const candidates = new Set<string>(); + candidates.add(site); + candidates.add(site.replace(/-/g, '')); + candidates.add(site.replace(/-/g, '.')); + if (site.endsWith('-com')) { + candidates.add(`${site.slice(0, -4)}.com`); + } + if (site.endsWith('-org')) { + candidates.add(`${site.slice(0, -4)}.org`); + } + if (site.endsWith('-io')) { + candidates.add(`${site.slice(0, -3)}.io`); + } + return Array.from(candidates); +} + +function extractHostname(url: string): string | null { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} + +export function matchSkillsForUrl(index: SkillsIndex, url: string, limit = 5): SkillEntry[] { + const hostname = extractHostname(url); + if (!hostname) return []; + + const bySite = new Map<string, SkillEntry[]>(); + for (const entry of index.entries) { + if (!bySite.has(entry.site)) bySite.set(entry.site, []); + bySite.get(entry.site)!.push(entry); + } + + const matched: SkillEntry[] = []; + for (const [site, entries] of bySite) { + const candidates = siteCandidates(site); + const hit = candidates.some((c) => hostname === c || hostname.endsWith(`.${c}`) || hostname.includes(c)); + if (hit) matched.push(...entries); + } + + return matched.slice(0, limit); +} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 65b398a1..7dd06dd2 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -18,6 +18,7 @@ import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js"; import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js"; +import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js"; import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; @@ -1007,6 +1008,71 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { }, }, + // ============================================================================ + // Browser Skills (browser-use/browser-harness domain-skills cache) + // ============================================================================ + + 'load-browser-skill': { + description: 'Load a site-specific browser skill (from the browser-use/browser-harness domain-skills library) by id. Returns the full markdown content with selectors, gotchas, and recipes for the target site. Call this after browser-control responses surface a matching skill in suggestedSkills. Pass action="list" to see all available skills. Skills are fetched on first use and cached locally; pass action="refresh" to force an update from upstream.', + inputSchema: z.object({ + action: z.enum(['load', 'list', 'refresh']).optional().describe('load: fetch a skill by id (default). list: list all cached skills. refresh: re-fetch the library from upstream.'), + id: z.string().optional().describe('Skill id (e.g., "github/repo-actions") — required for load.'), + site: z.string().optional().describe('Filter list results to a single site (e.g., "github").'), + }), + execute: async (input: { action?: 'load' | 'list' | 'refresh'; id?: string; site?: string }) => { + const action = input.action ?? 'load'; + try { + if (action === 'refresh') { + const index = await refreshBrowserSkills(); + return { + success: true, + message: `Refreshed ${index.entries.length} skill${index.entries.length === 1 ? '' : 's'} from upstream.`, + count: index.entries.length, + treeSha: index.treeSha, + }; + } + + if (action === 'list') { + const status = await ensureBrowserSkillsLoaded(); + if (status.status === 'error') { + return { success: false, error: status.error }; + } + if (status.status === 'empty') { + return { success: false, error: 'No browser skills cached yet.' }; + } + const entries = status.index.entries + .filter((e) => !input.site || e.site === input.site) + .map((e) => ({ id: e.id, title: e.title, site: e.site })); + return { + success: true, + count: entries.length, + skills: entries, + cacheAgeMs: Date.now() - status.index.fetchedAt, + refreshing: status.status === 'stale' ? status.refreshing : false, + }; + } + + if (!input.id) { + return { success: false, error: 'id is required for load.' }; + } + const result = await readBrowserSkillContent(input.id); + if (!result.ok) { + return { success: false, error: result.error }; + } + return { + success: true, + id: result.entry.id, + title: result.entry.title, + site: result.entry.site, + path: result.entry.path, + content: result.content, + }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to load browser skill.' }; + } + }, + }, + // ============================================================================ // Browser Control // ============================================================================ diff --git a/apps/x/packages/shared/src/browser-control.ts b/apps/x/packages/shared/src/browser-control.ts index e1418a5e..e4eb112d 100644 --- a/apps/x/packages/shared/src/browser-control.ts +++ b/apps/x/packages/shared/src/browser-control.ts @@ -116,6 +116,12 @@ export const BrowserControlInputSchema = z.object({ } }); +export const SuggestedBrowserSkillSchema = z.object({ + id: z.string(), + title: z.string(), + path: z.string(), +}); + export const BrowserControlResultSchema = z.object({ success: z.boolean(), action: BrowserControlActionSchema, @@ -123,6 +129,7 @@ export const BrowserControlResultSchema = z.object({ error: z.string().optional(), browser: BrowserStateSchema, page: BrowserPageSnapshotSchema.optional(), + suggestedSkills: z.array(SuggestedBrowserSkillSchema).optional(), }); export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>; @@ -132,3 +139,4 @@ export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>; export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>; export type BrowserControlInput = z.infer<typeof BrowserControlInputSchema>; export type BrowserControlResult = z.infer<typeof BrowserControlResultSchema>; +export type SuggestedBrowserSkill = z.infer<typeof SuggestedBrowserSkillSchema>; From 5e47bd430942f736a564d26e8eb0b3f55d2ab0e4 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 13:02:01 +0530 Subject: [PATCH 038/143] fix shell path issue on mac --- apps/x/apps/main/src/main.ts | 4 +++- .../core/src/application/assistant/runtime-context.ts | 10 +++++++++- .../core/src/application/lib/command-executor.ts | 10 ++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index c3618000..b7d0a491 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -112,7 +112,9 @@ function initializeExecutionEnvironment(): void { ).trim(); const env = JSON.parse(stdout) as Record<string, string>; - process.env = { ...env, ...process.env }; + // Let the user's shell environment win for overlapping keys like PATH. + // Finder/launched GUI apps on macOS often start with a stripped PATH. + process.env = { ...process.env, ...env }; } catch (error) { console.error('Failed to load shell environment', error); } diff --git a/apps/x/packages/core/src/application/assistant/runtime-context.ts b/apps/x/packages/core/src/application/assistant/runtime-context.ts index f1011c2c..a9baffc2 100644 --- a/apps/x/packages/core/src/application/assistant/runtime-context.ts +++ b/apps/x/packages/core/src/application/assistant/runtime-context.ts @@ -9,7 +9,15 @@ export interface RuntimeContext { } export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { - return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; + if (platform === 'win32') { + return process.env.ComSpec || 'cmd.exe'; + } + + if (process.env.SHELL) { + return process.env.SHELL; + } + + return platform === 'darwin' ? '/bin/zsh' : '/bin/sh'; } export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 611bde45..11b15d90 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -8,7 +8,6 @@ const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); -const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"()]+|['"()]+$/g, ''); @@ -84,11 +83,12 @@ export async function executeCommand( } ): Promise<CommandResult> { try { + const shell = getExecutionShell(); const { stdout, stderr } = await execPromise(command, { cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: EXECUTION_SHELL, + shell, }); return { @@ -161,8 +161,9 @@ export function executeCommandAbortable( }; } + const shell = getExecutionShell(); const proc = spawn(command, [], { - shell: EXECUTION_SHELL, + shell, cwd: options?.cwd, detached: process.platform !== 'win32', // Create process group on Unix stdio: ['ignore', 'pipe', 'pipe'], @@ -272,11 +273,12 @@ export function executeCommandSync( } ): CommandResult { try { + const shell = getExecutionShell(); const stdout = execSync(command, { cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: EXECUTION_SHELL, + shell, }); return { From f26d57e8eb15af350f65c7bc916df82ceb4167b7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 13:10:20 +0530 Subject: [PATCH 039/143] fix sync resume modal copy --- .../composio-google-migration-modal.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx b/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx index 8afea839..97ef9321 100644 --- a/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx +++ b/apps/x/apps/renderer/src/components/composio-google-migration-modal.tsx @@ -37,21 +37,14 @@ export function ComposioGoogleMigrationModal({ <div className="p-6 pb-0"> <DialogHeader className="space-y-1.5"> <DialogTitle className="text-lg font-semibold"> - Reconnect Google to keep syncing + Reconnect Google to resume syncing </DialogTitle> <DialogDescription asChild> <div className="space-y-3 text-sm leading-relaxed"> <p> - Rowboat used to sync your Gmail and Calendar through{" "} - <span className="font-medium text-foreground">Composio</span>, a - third-party connector. We've now built a direct connection to - Google — it's faster, more private, and doesn't rely on a - middleman. - </p> - <p> - We've disconnected the Composio connection. Reconnect Google - directly to resume syncing — your existing emails and calendar - events stay exactly where they are. + Knowledge graph syncing for Gmail and Calendar now uses a + direct Google connection. Reconnect to resume. Your existing + emails and events stay where they are. </p> </div> </DialogDescription> From 0bb58e55ac6f8f91927298f96e0719579a89bed1 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Wed, 6 May 2026 14:34:53 +0530 Subject: [PATCH 040/143] feat: minimal Today.md UI polish - no emoji headings, better track chip (#528) * feat: remove emoji headings and polish track block chip styling - Strip emojis from Today.md section headings (new + existing files via migration) - Track chip: full-width card style matching email blocks, colored icons per track type - Larger, taller chip with muted gray background for light/dark mode * feat: increase track chip icon and text size * feat: make track block icons configurable via yaml --- .../renderer/src/extensions/track-block.tsx | 36 ++++++++++++++----- apps/x/apps/renderer/src/styles/editor.css | 34 ++++++++++-------- .../core/src/knowledge/ensure_daily_note.ts | 36 ++++++++++++++++--- apps/x/packages/shared/src/track-block.ts | 1 + 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/apps/x/apps/renderer/src/extensions/track-block.tsx b/apps/x/apps/renderer/src/extensions/track-block.tsx index a87decc8..4f2a1f0a 100644 --- a/apps/x/apps/renderer/src/extensions/track-block.tsx +++ b/apps/x/apps/renderer/src/extensions/track-block.tsx @@ -1,12 +1,31 @@ import { z } from 'zod' -import { useMemo } from 'react' +import { useMemo, type ComponentType } from 'react' import { mergeAttributes, Node } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' -import { Radio, Loader2 } from 'lucide-react' +import { Radio, Loader2, type LucideProps } from 'lucide-react' +import * as LucideIcons from 'lucide-react' import { parse as parseYaml } from 'yaml' import { TrackBlockSchema } from '@x/shared/dist/track-block.js' import { useTrackStatus } from '@/hooks/use-track-status' +function resolveIcon(iconName: string): ComponentType<LucideProps> | null { + const key = iconName + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join('') + const component = (LucideIcons as Record<string, unknown>)[key] + if (component != null) return component as ComponentType<LucideProps> + return null +} + +function TrackIcon({ icon, size }: { icon?: string; size: number }) { + if (icon) { + const Icon = resolveIcon(icon) + if (Icon) return <Icon size={size} /> + } + return <Radio size={size} /> +} + function truncate(text: string, maxLen: number): string { const clean = text.replace(/\s+/g, ' ').trim() if (clean.length <= maxLen) return clean @@ -87,6 +106,7 @@ function TrackBlockView({ node, deleteNode, extension }: { data-type="track-block" data-trigger={triggerType} data-active={active ? 'true' : 'false'} + data-trackid={trackId} > <button type="button" @@ -96,13 +116,13 @@ function TrackBlockView({ node, deleteNode, extension }: { onMouseDown={(e) => e.stopPropagation()} title={instruction ? `${trackId}: ${instruction}` : trackId} > - {isRunning - ? <Loader2 size={13} className="animate-spin track-block-chip-icon" /> - : <Radio size={13} className="track-block-chip-icon" />} + <span className="track-block-chip-icon"> + {isRunning + ? <Loader2 size={24} className="animate-spin" /> + : <TrackIcon icon={track?.icon} size={24} />} + </span> <span className="track-block-chip-id">{trackId || 'track'}</span> - {instruction && ( - <span className="track-block-chip-sep">·</span> - )} + {instruction && <span className="track-block-chip-sep">·</span>} {instruction && ( <span className="track-block-chip-instruction">{truncate(instruction, 80)}</span> )} diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index ca935a14..edbe000c 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -661,8 +661,8 @@ .tiptap-editor .ProseMirror .track-block-chip-wrapper { --track-accent: #64748b; /* default: manual/slate */ - margin: 4px 0; - display: inline-block; + margin: 8px 0; + display: block; } .tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; } @@ -674,24 +674,22 @@ display: inline-flex; align-items: center; gap: 6px; - max-width: 100%; - padding: 6px 12px; + width: 100%; + padding: 24px 16px; font-family: inherit; - font-size: 13px; + font-size: 16px; line-height: 1.3; color: var(--foreground); - background: color-mix(in srgb, var(--track-accent) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent); - border-left: 3px solid var(--track-accent); - border-radius: 999px; + background: color-mix(in srgb, var(--muted) 40%, transparent); + border: 1px solid var(--border); + border-radius: 8px; cursor: pointer; - transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease; + transition: background-color 0.15s ease; user-select: none; } .tiptap-editor .ProseMirror .track-block-chip:hover { - background: color-mix(in srgb, var(--track-accent) 14%, transparent); - box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent); + background: color-mix(in srgb, var(--muted) 70%, transparent); } .tiptap-editor .ProseMirror .track-block-chip:active { @@ -719,12 +717,18 @@ .tiptap-editor .ProseMirror .track-block-chip-icon { flex-shrink: 0; - color: var(--track-accent); + color: color-mix(in srgb, var(--foreground) 45%, transparent); } +.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="up-next"] .track-block-chip-icon { color: #3b82f6; } +.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="calendar"] .track-block-chip-icon { color: #22c55e; } +.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="emails"] .track-block-chip-icon { color: #f59e0b; } +.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="what-you-missed"] .track-block-chip-icon { color: #3b82f6; } +.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="priorities"] .track-block-chip-icon { color: #ef4444; } + .tiptap-editor .ProseMirror .track-block-chip-id { font-weight: 600; - color: var(--track-accent); + color: color-mix(in srgb, var(--foreground) 75%, transparent); white-space: nowrap; flex-shrink: 0; } @@ -735,7 +739,7 @@ } .tiptap-editor .ProseMirror .track-block-chip-instruction { - color: color-mix(in srgb, var(--foreground) 80%, transparent); + color: color-mix(in srgb, var(--foreground) 55%, transparent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 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 ac54d029..a4da6977 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -15,9 +15,10 @@ interface Section { const SECTIONS: Section[] = [ { - heading: '## ⏱ Up Next', + heading: '## Up Next', track: { trackId: 'up-next', + icon: 'clock', instruction: `Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today. @@ -41,9 +42,10 @@ Plain markdown prose only — no calendar block, no email block, no headings.`, }, }, { - heading: '## 📅 Calendar', + heading: '## Calendar', track: { trackId: 'calendar', + icon: 'calendar-days', instruction: `Emit today's meetings as a calendar block titled "Today's Meetings". @@ -60,9 +62,10 @@ After the block, you MAY add one short markdown line per event giving useful pre }, }, { - heading: '## 📧 Emails', + heading: '## Emails', track: { trackId: 'emails', + icon: 'mail', instruction: `Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread). @@ -81,9 +84,10 @@ Do NOT re-list threads the user has already seen unless their state changed (new }, }, { - heading: '## 📰 What You Missed', + heading: '## What You Missed', track: { trackId: 'what-you-missed', + icon: 'history', instruction: `Short markdown summary of what happened yesterday that matters this morning. @@ -106,9 +110,10 @@ Do NOT manufacture content to fill the section.`, }, }, { - heading: '## ✅ Today\'s Priorities', + heading: '## Today\'s Priorities', track: { trackId: 'priorities', + icon: 'list-todo', instruction: `Ranked markdown list of the real, actionable items the user should focus on today. @@ -154,7 +159,28 @@ function buildDailyNoteContent(): string { return parts.join('\n'); } +function migrateEmojiHeadings(): void { + if (!fs.existsSync(DAILY_NOTE_PATH)) return; + let content = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8'); + const original = content; + const replacements: [string, string][] = [ + ['## ⏱ Up Next', '## Up Next'], + ['## 📅 Calendar', '## Calendar'], + ['## 📧 Emails', '## Emails'], + ['## 📰 What You Missed', '## What You Missed'], + ["## ✅ Today's Priorities", "## Today's Priorities"], + ]; + for (const [from, to] of replacements) { + content = content.split(from).join(to); + } + if (content !== original) { + fs.writeFileSync(DAILY_NOTE_PATH, content, 'utf-8'); + console.log('[DailyNote] Migrated emoji headings'); + } +} + export function ensureDailyNote(): void { + migrateEmojiHeadings(); if (fs.existsSync(DAILY_NOTE_PATH)) return; fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8'); console.log('[DailyNote] Created today.md'); diff --git a/apps/x/packages/shared/src/track-block.ts b/apps/x/packages/shared/src/track-block.ts index 6d9ce3af..cbb370cc 100644 --- a/apps/x/packages/shared/src/track-block.ts +++ b/apps/x/packages/shared/src/track-block.ts @@ -27,6 +27,7 @@ export const TrackBlockSchema = z.object({ 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.'), + icon: z.string().optional().describe('Lucide icon name for the chip (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'), 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 37c1627d794500164232c425cbaa541d375a2df5 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 17:50:56 +0530 Subject: [PATCH 041/143] fix browser cleanup --- apps/x/apps/main/src/browser/view.ts | 24 ++++++++++++++++++------ apps/x/apps/main/src/main.ts | 1 + 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index b540809d..90b7d849 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -113,7 +113,9 @@ export class BrowserViewManager extends EventEmitter { attach(window: BrowserWindow): void { this.cleanupWindowListeners?.(); + this.cleanupWindowListeners = null; this.window = window; + const hostWebContents = window.webContents; const resetForHostWindowNavigation = () => { // Renderer refreshes do not run React unmount cleanup reliably, so the @@ -132,10 +134,16 @@ export class BrowserViewManager extends EventEmitter { }; const handleClosed = () => { - this.cleanupWindowListeners?.(); + if (this.window !== window) return; + + const tabs = [...this.tabs.values()]; this.cleanupWindowListeners = null; this.window = null; this.browserSession = null; + this.bounds = { x: 0, y: 0, width: 0, height: 0 }; + for (const tab of tabs) { + this.destroyTab(tab); + } this.tabs.clear(); this.tabOrder = []; this.activeTabId = null; @@ -144,14 +152,18 @@ export class BrowserViewManager extends EventEmitter { this.snapshotCache.clear(); }; - window.webContents.on('did-start-loading', handleDidStartLoading); - window.webContents.on('render-process-gone', handleRenderProcessGone); + hostWebContents.on('did-start-loading', handleDidStartLoading); + hostWebContents.on('render-process-gone', handleRenderProcessGone); window.on('closed', handleClosed); this.cleanupWindowListeners = () => { - window.webContents.removeListener('did-start-loading', handleDidStartLoading); - window.webContents.removeListener('render-process-gone', handleRenderProcessGone); - window.removeListener('closed', handleClosed); + if (!hostWebContents.isDestroyed()) { + hostWebContents.removeListener('did-start-loading', handleDidStartLoading); + hostWebContents.removeListener('render-process-gone', handleRenderProcessGone); + } + if (!window.isDestroyed()) { + window.removeListener('closed', handleClosed); + } }; } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index b7d0a491..a6d7b4e0 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -58,6 +58,7 @@ if (started) app.quit(); // Single-instance lock: route a second launch (e.g. clicking a rowboat:// link) // back into the existing process via the 'second-instance' event. if (!app.requestSingleInstanceLock()) { + console.error('[Main] Another Rowboat instance is already running; exiting this process.'); app.quit(); process.exit(0); } From 3630032d212dd38f745ea1e734f1013552398797 Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Wed, 6 May 2026 19:41:28 +0530 Subject: [PATCH 042/143] feat/today-minimal-polish (#532) * feat: remove emoji headings and polish track block chip styling - Strip emojis from Today.md section headings (new + existing files via migration) - Track chip: full-width card style matching email blocks, colored icons per track type - Larger, taller chip with muted gray background for light/dark mode * feat: increase track chip icon and text size * feat: make track block icons configurable via yaml * fix: migrate missing icon fields in existing Today.md on startup --- .../core/src/knowledge/ensure_daily_note.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 a4da6977..3172993a 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -1,6 +1,6 @@ import path from 'path'; import fs from 'fs'; -import { stringify as stringifyYaml } from 'yaml'; +import { stringify as stringifyYaml, parse as parseYaml } from 'yaml'; import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; import { WorkDir } from '../config/config.js'; import z from 'zod'; @@ -179,8 +179,37 @@ function migrateEmojiHeadings(): void { } } +function migrateTrackIcons(): void { + if (!fs.existsSync(DAILY_NOTE_PATH)) return; + let content = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8'); + const original = content; + + const iconMap = new Map<string, string>( + SECTIONS.flatMap(({ track }) => track.icon ? [[track.trackId, track.icon]] : []) + ); + + content = content.replace(/```track\n([\s\S]*?)\n```/g, (match, yaml) => { + try { + const parsed = parseYaml(yaml) as Record<string, unknown>; + if (!parsed.trackId || parsed.icon) return match; + const icon = iconMap.get(parsed.trackId as string); + if (!icon) return match; + const updated = yaml.replace(/^(trackId: .+)$/m, `$1\nicon: ${icon}`); + return '```track\n' + updated + '\n```'; + } catch { + return match; + } + }); + + if (content !== original) { + fs.writeFileSync(DAILY_NOTE_PATH, content, 'utf-8'); + console.log('[DailyNote] Migrated track icons'); + } +} + export function ensureDailyNote(): void { migrateEmojiHeadings(); + migrateTrackIcons(); if (fs.existsSync(DAILY_NOTE_PATH)) return; fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8'); console.log('[DailyNote] Created today.md'); From 0e3d058c2959034cd84de9cd7382e9bf2f5b678a Mon Sep 17 00:00:00 2001 From: gagan <gaganp000999@gmail.com> Date: Wed, 6 May 2026 21:41:26 +0530 Subject: [PATCH 043/143] feat: Gmail-style email block with inbox container layout (#531) * feat: restyle email block with Gmail-style layout and avatar * style: apply Google Sans/Roboto font to email block * feat: add Gmail inbox-style multi-email block with accordion rows * style: fix sender name casing, weight, and email display in expanded view * feat: emails inbox block with container layout, two-line rows, Gmail title style --- .../src/components/markdown-editor.tsx | 3 +- .../renderer/src/extensions/email-block.tsx | 550 ++++++++++++------ apps/x/apps/renderer/src/styles/editor.css | 545 +++++++++++++---- .../core/src/knowledge/ensure_daily_note.ts | 6 +- .../core/src/knowledge/inline_task_agent.ts | 10 +- apps/x/packages/shared/src/blocks.ts | 7 + apps/x/pnpm-lock.yaml | 141 +++-- 7 files changed, 899 insertions(+), 363 deletions(-) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index e97f7c6e..10e429d8 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -20,7 +20,7 @@ import { IframeBlockExtension } from '@/extensions/iframe-block' import { ChartBlockExtension } from '@/extensions/chart-block' import { TableBlockExtension } from '@/extensions/table-block' import { CalendarBlockExtension } from '@/extensions/calendar-block' -import { EmailBlockExtension } from '@/extensions/email-block' +import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block' import { TranscriptBlockExtension } from '@/extensions/transcript-block' import { MermaidBlockExtension } from '@/extensions/mermaid-block' import { Markdown } from 'tiptap-markdown' @@ -707,6 +707,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro ChartBlockExtension, TableBlockExtension, CalendarBlockExtension, + EmailsBlockExtension, EmailBlockExtension, TranscriptBlockExtension, MermaidBlockExtension, diff --git a/apps/x/apps/renderer/src/extensions/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx index 7356c94c..d7350e81 100644 --- a/apps/x/apps/renderer/src/extensions/email-block.tsx +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -1,6 +1,6 @@ import { mergeAttributes, Node } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' -import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react' +import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react' import { blocks } from '@x/shared' import { useState, useEffect, useRef, useCallback } from 'react' import { useTheme } from '@/contexts/theme-context' @@ -11,17 +11,47 @@ function formatEmailDate(dateStr: string): string { try { const d = new Date(dateStr) if (isNaN(d.getTime())) return dateStr - return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) + - ' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + const now = new Date() + const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() + if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) } catch { return dateStr } } -/** Extract just the name part from "Name <email>" format */ -function senderFirstName(from: string): string { - const name = from.replace(/<.*>/, '').trim() - return name.split(/\s+/)[0] || name +function formatFullDate(dateStr: string): string { + try { + const d = new Date(dateStr) + if (isNaN(d.getTime())) return dateStr + return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) + + ', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + } catch { + return dateStr + } +} + +function extractName(from: string): string { + const match = from.match(/^([^<]+)</) + if (match) return match[1].trim() + const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim() + return username.replace(/\b\w/g, c => c.toUpperCase()) +} + +function getInitial(from: string): string { + const name = extractName(from) + return (name[0] || '?').toUpperCase() +} + +const GMAIL_AVATAR_COLORS = [ + '#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900', + '#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32', +] + +function avatarColor(from: string): string { + let hash = 0 + for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0 + return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length] } declare global { @@ -30,7 +60,308 @@ declare global { } } -// --- Email Block --- +// --- Shared: expanded email body used by both block types --- + +function EmailExpandedBody({ + config, + resolvedTheme, +}: { + config: blocks.EmailBlock + resolvedTheme: string +}) { + const [draftBody, setDraftBody] = useState(config.draft_response || '') + const [copied, setCopied] = useState(false) + const bodyRef = useRef<HTMLTextAreaElement>(null) + + useEffect(() => { + setDraftBody(config.draft_response || '') + }, [config.draft_response]) + + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.style.height = 'auto' + bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px' + } + }, [draftBody]) + + const draftWithAssistant = useCallback(() => { + let prompt = draftBody + ? `Help me refine this draft response to an email` + : `Help me draft a response to this email` + if (config.threadId) { + prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context` + } + prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n` + if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n` + window.__pendingEmailDraft = { prompt } + window.dispatchEvent(new Event('email-block:draft-with-assistant')) + }, [config, draftBody]) + + const copyDraft = useCallback(() => { + navigator.clipboard.writeText(draftBody).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }).catch(() => { + const el = document.createElement('textarea') + el.value = draftBody + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + }, [draftBody]) + + const gmailUrl = config.threadId + ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` + : null + + const senderName = config.from ? extractName(config.from) : 'Unknown' + const initial = config.from ? getInitial(config.from) : '?' + const color = config.from ? avatarColor(config.from) : '#5f6368' + const hasDraft = !!config.draft_response + + return ( + <div className="email-gmail-expanded"> + {config.subject && ( + <div className="email-gmail-exp-subject">{config.subject}</div> + )} + + <div className="email-gmail-exp-meta"> + <div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div> + <div className="email-gmail-exp-meta-right"> + <div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div> + <div className="email-gmail-exp-to-date"> + {config.to && <span>to {config.to}</span>} + {config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>} + </div> + </div> + </div> + + <div className="email-gmail-exp-body">{config.latest_email}</div> + + {config.past_summary && ( + <div className="email-gmail-exp-history"> + <div className="email-gmail-exp-history-label">Earlier conversation</div> + <div className="email-gmail-exp-history-body">{config.past_summary}</div> + </div> + )} + + {!hasDraft && ( + <div className="email-gmail-reply-row"> + {gmailUrl && ( + <button + className="email-gmail-btn" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }} + > + <ExternalLink size={13} /> + Open in Gmail + </button> + )} + <button + className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); draftWithAssistant() }} + > + <MessageSquare size={13} /> + Draft with Rowboat + </button> + </div> + )} + + {hasDraft && ( + <div className="email-gmail-compose"> + <div className="email-gmail-compose-to"> + <span className="email-gmail-compose-to-label">Reply</span> + {config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>} + </div> + <textarea + key={resolvedTheme} + ref={bodyRef} + className="email-gmail-compose-body" + value={draftBody} + onChange={(e) => setDraftBody(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + placeholder="Write your reply..." + rows={3} + /> + <div className="email-gmail-compose-footer"> + <button + className="email-gmail-btn email-gmail-btn-primary" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); draftWithAssistant() }} + > + <MessageSquare size={13} /> + {hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'} + </button> + <button + className="email-gmail-btn" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); copyDraft() }} + > + {copied ? <Check size={13} /> : <Copy size={13} />} + {copied ? 'Copied!' : 'Copy draft'} + </button> + {gmailUrl && ( + <button + className="email-gmail-btn" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }} + > + <ExternalLink size={13} /> + Open in Gmail + </button> + )} + </div> + </div> + )} + + </div> + ) +} + +// --- Multi-email inbox block (language-emails) --- + +function EmailsBlockView({ node, deleteNode }: { + node: { attrs: Record<string, unknown> } + deleteNode: () => void +}) { + const raw = node.attrs.data as string + let config: blocks.EmailsBlock | null = null + + try { + config = blocks.EmailsBlockSchema.parse(JSON.parse(raw)) + } catch { /* fallback below */ } + + const { resolvedTheme } = useTheme() + const [expandedIndex, setExpandedIndex] = useState<number | null>(null) + + if (!config || config.emails.length === 0) { + return ( + <NodeViewWrapper className="email-block-wrapper" data-type="emails-block"> + <div className="email-block-card email-block-error"><span>Invalid emails block</span></div> + </NodeViewWrapper> + ) + } + + return ( + <NodeViewWrapper className="email-block-wrapper" data-type="emails-block"> + <div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}> + <button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button> + + {config.title && ( + <div className="email-inbox-title">{config.title}</div> + )} + + <div className="email-inbox-list"> + {config.emails.map((email, i) => { + const isExpanded = expandedIndex === i + const senderName = email.from ? extractName(email.from) : 'Unknown' + const initial = email.from ? getInitial(email.from) : '?' + const color = email.from ? avatarColor(email.from) : '#5f6368' + const snippet = email.summary + || (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '') + + return ( + <div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}> + {/* Collapsed row */} + <div + className="email-inbox-row-header" + onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }} + onMouseDown={(e) => e.stopPropagation()} + > + <div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div> + + <div className="email-inbox-content"> + <div className="email-inbox-top-row"> + <span className="email-inbox-sender">{senderName}</span> + {email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>} + </div> + <div className="email-inbox-bottom-row"> + {email.subject && <span className="email-inbox-subject">{email.subject}</span>} + {snippet && ( + <span className="email-inbox-snippet"> + {email.subject ? ` — ${snippet}` : snippet} + </span> + )} + </div> + </div> + + <ChevronDown + size={14} + className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`} + /> + </div> + + {/* Expanded content */} + {isExpanded && ( + <div className="email-inbox-expanded-wrap"> + <EmailExpandedBody + config={email} + resolvedTheme={resolvedTheme} + /> + </div> + )} + </div> + ) + })} + </div> + </div> + </NodeViewWrapper> + ) +} + +export const EmailsBlockExtension = Node.create({ + name: 'emailsBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { data: { default: '{}' } } + }, + + parseHTML() { + return [{ + tag: 'pre', + priority: 61, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + if ((code.className || '').includes('language-emails')) { + return { data: code.textContent || '{}' } + } + return false + }, + }] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(EmailsBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```emails\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: {}, + }, + } + }, +}) + +// --- Single email block (language-email, backward compat) --- function EmailBlockView({ node, deleteNode, updateAttributes }: { node: { attrs: Record<string, unknown> } @@ -42,194 +373,57 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: { try { config = blocks.EmailBlockSchema.parse(JSON.parse(raw)) - } catch { - // fallback below - } - - const hasDraft = !!config?.draft_response - const hasPastSummary = !!config?.past_summary + } catch { /* fallback below */ } const { resolvedTheme } = useTheme() + const [expanded, setExpanded] = useState(false) - // Local draft state for editing - const [draftBody, setDraftBody] = useState(config?.draft_response || '') - const [emailExpanded, setEmailExpanded] = useState(false) - const [copied, setCopied] = useState(false) - const bodyRef = useRef<HTMLTextAreaElement>(null) - - // Sync draft from external changes - useEffect(() => { - try { - const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw)) - setDraftBody(parsed.draft_response || '') - } catch { /* ignore */ } - }, [raw]) - - // Auto-resize textarea - useEffect(() => { - if (bodyRef.current) { - bodyRef.current.style.height = 'auto' - bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px' - } - }, [draftBody]) - - const commitDraft = useCallback((newBody: string) => { - try { - const current = JSON.parse(raw) as Record<string, unknown> - updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) }) - } catch { /* ignore */ } - }, [raw, updateAttributes]) - - const draftWithAssistant = useCallback(() => { - if (!config) return - let prompt = draftBody - ? `Help me refine this draft response to an email` - : `Help me draft a response to this email` - if (config.threadId) { - prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context` - } - prompt += `.\n\n` - prompt += `**From:** ${config.from || 'Unknown'}\n` - prompt += `**Subject:** ${config.subject || 'No subject'}\n` - if (draftBody) { - prompt += `\n**Current draft:**\n${draftBody}\n` - } - window.__pendingEmailDraft = { prompt } - window.dispatchEvent(new Event('email-block:draft-with-assistant')) - }, [config, draftBody]) + void updateAttributes // available for future per-email draft persistence if (!config) { return ( <NodeViewWrapper className="email-block-wrapper" data-type="email-block"> - <div className="email-block-card email-block-error"> - <Mail size={16} /> - <span>Invalid email block</span> - </div> + <div className="email-block-card email-block-error"><span>Invalid email block</span></div> </NodeViewWrapper> ) } - const gmailUrl = config.threadId - ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` - : null - - // Build summary: use explicit summary, or auto-generate from sender + subject - const summary = config.summary - || (config.from && config.subject - ? `${senderFirstName(config.from)} reached out about ${config.subject}` - : config.subject || 'New email') + const senderName = config.from ? extractName(config.from) : 'Unknown' + const initial = config.from ? getInitial(config.from) : '?' + const color = config.from ? avatarColor(config.from) : '#5f6368' + const snippet = config.summary + || (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '') return ( <NodeViewWrapper className="email-block-wrapper" data-type="email-block"> <div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}> - <button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"> - <X size={14} /> - </button> + <button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button> - {/* Header: Email badge */} - <div className="email-block-badge"> - <Mail size={13} /> - Email - </div> - - {/* Summary */} - <div className="email-block-summary">{summary}</div> - - {/* Expandable email details */} - <button - className="email-block-expand-btn" - onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }} + <div + className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`} + onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }} onMouseDown={(e) => e.stopPropagation()} > - <ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} /> - {emailExpanded ? 'Hide email' : 'Show email'} - {config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>} - {config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>} - </button> - - {emailExpanded && ( - <div className="email-block-email-details"> - <div className="email-block-message"> - <div className="email-block-message-header"> - <div className="email-block-sender-info"> - <div className="email-block-sender-row"> - <div className="email-block-sender-name">{config.from || 'Unknown'}</div> - {config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>} - </div> - {config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>} - </div> - </div> - <div className="email-block-message-body">{config.latest_email}</div> + <div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div> + <div className="email-gmail-content"> + <div className="email-gmail-top-row"> + <span className="email-gmail-sender">{senderName}</span> + {config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>} + </div> + <div className="email-gmail-bottom-row"> + {config.subject && <span className="email-gmail-subject">{config.subject}</span>} + {snippet && <span className="email-gmail-snippet">{config.subject ? ` — ${snippet}` : snippet}</span>} </div> - {hasPastSummary && ( - <div className="email-block-context-section"> - <div className="email-block-context-label">Earlier conversation</div> - <div className="email-block-context-summary">{config.past_summary}</div> - </div> - )} </div> - )} - - {/* Draft section */} - {hasDraft && ( - <div className="email-block-draft-section"> - <div className="email-block-draft-label">Draft reply</div> - <textarea - key={resolvedTheme} - ref={bodyRef} - className="email-draft-block-body-input" - value={draftBody} - onChange={(e) => setDraftBody(e.target.value)} - onBlur={() => commitDraft(draftBody)} - placeholder="Write your reply..." - rows={3} - /> - </div> - )} - - {/* Action buttons */} - <div className="email-block-actions"> - <button - className="email-block-gmail-btn email-block-gmail-btn-primary" - onClick={draftWithAssistant} - > - <MessageSquare size={13} /> - {hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'} - </button> - {hasDraft && ( - <button - className="email-block-gmail-btn email-block-gmail-btn-primary" - onClick={() => { - navigator.clipboard.writeText(draftBody).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }).catch(() => { - // Fallback for Electron contexts where clipboard API may fail - const textarea = document.createElement('textarea') - textarea.value = draftBody - document.body.appendChild(textarea) - textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) - }} - > - {copied ? <Check size={13} /> : <Copy size={13} />} - {copied ? 'Copied!' : 'Copy draft'} - </button> - )} - {gmailUrl && ( - <button - className="email-block-gmail-btn" - onClick={() => window.open(gmailUrl, '_blank')} - > - <ExternalLink size={13} /> - Open in Gmail - </button> - )} + <ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} /> </div> + + {expanded && ( + <EmailExpandedBody + config={config} + resolvedTheme={resolvedTheme} + /> + )} </div> </NodeViewWrapper> ) @@ -243,9 +437,7 @@ export const EmailBlockExtension = Node.create({ draggable: false, addAttributes() { - return { - data: { default: '{}' }, - } + return { data: { default: '{}' } } }, parseHTML() { @@ -256,7 +448,7 @@ export const EmailBlockExtension = Node.create({ const code = element.querySelector('code') if (!code) return false const cls = code.className || '' - if (cls.includes('language-email') && !cls.includes('language-emailDraft')) { + if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) { return { data: code.textContent || '{}' } } return false diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index edbe000c..b830a02f 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap'); + /* Tiptap Editor Styles */ .tiptap-editor { @@ -820,6 +822,49 @@ margin: 8px 0; } +/* Consecutive email blocks — zero gap, shared outer border */ + +/* Kill margins between adjacent email wrappers */ +.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) { + margin-bottom: 0; +} +.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper { + margin-top: 0; +} + +/* Strip card border/radius from every card inside a sequence */ +.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card, +.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper .email-block-card { + border-radius: 0; + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); +} + +/* First in group: restore top border + top radius */ +.tiptap-editor .ProseMirror .email-block-wrapper:not(.email-block-wrapper + .email-block-wrapper):has(+ .email-block-wrapper) .email-block-card { + border-top: 1px solid var(--border); + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +/* Last in group: restore bottom border + bottom radius, remove hairline */ +.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:not(:has(+ .email-block-wrapper)) .email-block-card { + border-bottom: 1px solid var(--border); + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} + +/* Middle cards: just left + right borders */ +.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card { + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); +} + + .tiptap-editor .ProseMirror .image-block-card, .tiptap-editor .ProseMirror .embed-block-card, .tiptap-editor .ProseMirror .iframe-block-card, @@ -1422,141 +1467,209 @@ /* Email block – Gmail style */ .tiptap-editor .ProseMirror .email-block-card-gmail { - background-color: var(--background); - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06); + font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; } -.tiptap-editor .ProseMirror .email-block-card-gmail:hover { - background-color: var(--background); -} - -/* Email badge */ -.tiptap-editor .ProseMirror .email-block-badge { - display: inline-flex; +/* Gmail-style two-column row */ +.tiptap-editor .ProseMirror .email-gmail-row { + display: flex; align-items: center; - gap: 5px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: color-mix(in srgb, var(--foreground) 45%, transparent); - margin-bottom: 8px; + gap: 12px; + cursor: pointer; + padding: 2px 0; + border-radius: 4px; + transition: background 0.1s ease; + user-select: none; } -/* Summary */ -.tiptap-editor .ProseMirror .email-block-summary { +.tiptap-editor .ProseMirror .email-gmail-row:hover { + background: color-mix(in srgb, var(--foreground) 4%, transparent); +} + +.tiptap-editor .ProseMirror .email-gmail-row.email-gmail-row-expanded { + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); + margin-bottom: 2px; +} + +/* Sender avatar circle */ +.tiptap-editor .ProseMirror .email-gmail-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; font-size: 15px; font-weight: 500; - color: var(--foreground); - line-height: 1.4; - margin-bottom: 10px; + color: #fff; + flex-shrink: 0; + letter-spacing: 0; } -/* Expand button */ -.tiptap-editor .ProseMirror .email-block-expand-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0; - font-size: 13px; - font-weight: 400; - color: color-mix(in srgb, var(--foreground) 50%, transparent); - background: none; - border: none; - cursor: pointer; - transition: color 0.12s ease; - margin-bottom: 4px; -} - -.tiptap-editor .ProseMirror .email-block-expand-btn:hover { - color: var(--foreground); -} - -.tiptap-editor .ProseMirror .email-block-expand-meta { - color: color-mix(in srgb, var(--foreground) 35%, transparent); -} - -.tiptap-editor .ProseMirror .email-block-toggle-chevron { - transition: transform 0.15s ease; -} - -.tiptap-editor .ProseMirror .email-block-toggle-chevron-open { - transform: rotate(180deg); -} - -/* Email details (expanded) */ -.tiptap-editor .ProseMirror .email-block-email-details { - margin-top: 10px; - padding: 12px; - background: color-mix(in srgb, var(--foreground) 4%, transparent); - border-radius: 6px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.tiptap-editor .ProseMirror .email-block-message { - padding: 0; -} - -.tiptap-editor .ProseMirror .email-block-message-header { - display: flex; - align-items: flex-start; - gap: 12px; - margin-bottom: 10px; -} - -.tiptap-editor .ProseMirror .email-block-sender-info { - display: flex; - flex-direction: column; - min-width: 0; +/* Content column */ +.tiptap-editor .ProseMirror .email-gmail-content { flex: 1; + min-width: 0; + display: flex; + flex-direction: column; gap: 2px; } -.tiptap-editor .ProseMirror .email-block-sender-row { +.tiptap-editor .ProseMirror .email-gmail-top-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } -.tiptap-editor .ProseMirror .email-block-sender-name { +.tiptap-editor .ProseMirror .email-gmail-sender { font-size: 14px; font-weight: 500; + font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tiptap-editor .ProseMirror .email-block-sender-date { +.tiptap-editor .ProseMirror .email-gmail-date { font-size: 12px; color: color-mix(in srgb, var(--foreground) 50%, transparent); white-space: nowrap; flex-shrink: 0; } -.tiptap-editor .ProseMirror .email-block-subject-line { - font-size: 12px; +.tiptap-editor .ProseMirror .email-gmail-bottom-row { + font-size: 13px; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.tiptap-editor .ProseMirror .email-gmail-subject { + color: color-mix(in srgb, var(--foreground) 80%, transparent); + font-weight: 500; +} + +.tiptap-editor .ProseMirror .email-gmail-snippet { color: color-mix(in srgb, var(--foreground) 45%, transparent); } -.tiptap-editor .ProseMirror .email-block-message-body { +/* Chevron */ +.tiptap-editor .ProseMirror .email-gmail-chevron { + flex-shrink: 0; + color: color-mix(in srgb, var(--foreground) 40%, transparent); + transition: transform 0.15s ease; +} + +.tiptap-editor .ProseMirror .email-gmail-chevron.email-gmail-chevron-open { + transform: rotate(180deg); +} + +/* Expanded area */ +.tiptap-editor .ProseMirror .email-gmail-expanded { + padding-top: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.tiptap-editor .ProseMirror .email-gmail-exp-subject { + font-size: 18px; + font-weight: 400; + color: var(--foreground); + line-height: 1.35; + letter-spacing: -0.01em; +} + +/* Metadata strip (avatar + from/to/date + open button) */ +.tiptap-editor .ProseMirror .email-gmail-exp-meta { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.tiptap-editor .ProseMirror .email-gmail-exp-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 500; + color: #fff; + flex-shrink: 0; +} + +.tiptap-editor .ProseMirror .email-gmail-exp-meta-right { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.tiptap-editor .ProseMirror .email-gmail-exp-sender { + font-size: 14px; + font-weight: 500; + color: var(--foreground); +} + +.tiptap-editor .ProseMirror .email-gmail-exp-to-date { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.tiptap-editor .ProseMirror .email-gmail-exp-fulldate { + color: color-mix(in srgb, var(--foreground) 40%, transparent); +} + +.tiptap-editor .ProseMirror .email-gmail-open-btn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: none; + color: color-mix(in srgb, var(--foreground) 50%, transparent); + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.tiptap-editor .ProseMirror .email-gmail-open-btn:hover { + background: color-mix(in srgb, var(--foreground) 8%, transparent); + color: var(--foreground); +} + +/* Email body */ +.tiptap-editor .ProseMirror .email-gmail-exp-body { font-size: 14px; color: color-mix(in srgb, var(--foreground) 80%, transparent); white-space: pre-wrap; - line-height: 1.58; + line-height: 1.6; + padding-left: 50px; } -.tiptap-editor .ProseMirror .email-block-context-section { +/* Earlier conversation */ +.tiptap-editor .ProseMirror .email-gmail-exp-history { display: flex; flex-direction: column; gap: 4px; - padding-top: 10px; - border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + padding-left: 50px; } -.tiptap-editor .ProseMirror .email-block-context-label { +.tiptap-editor .ProseMirror .email-gmail-exp-history-label { font-size: 11px; font-weight: 500; text-transform: uppercase; @@ -1564,68 +1677,88 @@ color: color-mix(in srgb, var(--foreground) 40%, transparent); } -.tiptap-editor .ProseMirror .email-block-context-summary { - font-size: 14px; - color: color-mix(in srgb, var(--foreground) 65%, transparent); - line-height: 1.58; +.tiptap-editor .ProseMirror .email-gmail-exp-history-body { + font-size: 13px; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + line-height: 1.55; white-space: pre-wrap; padding-left: 12px; border-left: 3px solid color-mix(in srgb, var(--foreground) 12%, transparent); } -/* Draft section */ -.tiptap-editor .ProseMirror .email-block-draft-section { - margin-top: 10px; - padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); - border-radius: 6px; +/* Compose / draft box */ +.tiptap-editor .ProseMirror .email-gmail-compose { + margin-top: 4px; + border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent); + border-radius: 8px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08); + overflow: hidden; } -.tiptap-editor .ProseMirror .email-block-draft-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: color-mix(in srgb, var(--foreground) 40%, transparent); - margin-bottom: 4px; +.tiptap-editor .ProseMirror .email-gmail-compose-to { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); } -.tiptap-editor .ProseMirror .email-draft-block-body-input { +.tiptap-editor .ProseMirror .email-gmail-compose-to-label { + font-size: 12px; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 45%, transparent); +} + +.tiptap-editor .ProseMirror .email-gmail-compose-to-addr { + font-size: 13px; + color: color-mix(in srgb, var(--foreground) 70%, transparent); +} + +.tiptap-editor .ProseMirror .email-gmail-compose-body { width: 100%; font-size: 14px; color: var(--foreground); background: none; border: none; outline: none; - padding: 4px 0; + padding: 10px 12px; font-family: inherit; line-height: 1.58; resize: none; overflow: hidden; + box-sizing: border-box; } -.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder { +.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder { color: color-mix(in srgb, var(--foreground) 35%, transparent); } -/* Action buttons */ -.tiptap-editor .ProseMirror .email-block-actions { +.tiptap-editor .ProseMirror .email-gmail-compose-footer { display: flex; align-items: center; gap: 8px; - margin-top: 12px; + padding: 8px 12px; + border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); } -.tiptap-editor .ProseMirror .email-block-gmail-btn { +/* Action buttons */ +.tiptap-editor .ProseMirror .email-gmail-actions { + display: flex; + align-items: center; + gap: 8px; + padding-left: 50px; +} + +.tiptap-editor .ProseMirror .email-gmail-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; - font-size: 14px; + font-size: 13px; font-weight: 500; color: color-mix(in srgb, var(--foreground) 60%, transparent); background: transparent; - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent); border-radius: 18px; cursor: pointer; transition: background-color 0.15s ease, box-shadow 0.15s ease; @@ -1633,24 +1766,19 @@ letter-spacing: 0.01em; } -.tiptap-editor .ProseMirror .email-block-gmail-btn:hover { - background: color-mix(in srgb, var(--foreground) 8%, transparent); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06); +.tiptap-editor .ProseMirror .email-gmail-btn:hover { + background: color-mix(in srgb, var(--foreground) 6%, transparent); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); color: var(--foreground); } -.tiptap-editor .ProseMirror .email-block-gmail-btn:disabled { - opacity: 0.6; - cursor: default; -} - -.tiptap-editor .ProseMirror .email-block-gmail-btn-primary { +.tiptap-editor .ProseMirror .email-gmail-btn-primary { color: #fff; background: #1a73e8; border-color: #1a73e8; } -.tiptap-editor .ProseMirror .email-block-gmail-btn-primary:hover:not(:disabled) { +.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover { background: #1765cc; box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3); color: #fff; @@ -1665,6 +1793,167 @@ font-size: 14px; } +/* Reply / Forward pill buttons (in expanded view) */ +.tiptap-editor .ProseMirror .email-gmail-reply-row { + display: flex; + gap: 8px; + padding-left: 50px; +} + +.tiptap-editor .ProseMirror .email-gmail-reply-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 20px; + font-size: 13px; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 65%, transparent); + background: transparent; + border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent); + border-radius: 18px; + cursor: pointer; + transition: background 0.12s ease, box-shadow 0.12s ease; + font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; +} + +.tiptap-editor .ProseMirror .email-gmail-reply-btn:hover { + background: color-mix(in srgb, var(--foreground) 6%, transparent); + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + color: var(--foreground); +} + +.tiptap-editor .ProseMirror .email-gmail-reply-row-end { + margin-left: auto; +} + +/* ---- Emails inbox block (language-emails) ---- */ + +.tiptap-editor .ProseMirror .email-inbox-card { + font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; +} + +.tiptap-editor .ProseMirror .email-inbox-title { + font-size: 14px; + font-weight: 500; + margin-bottom: 4px; + color: color-mix(in srgb, var(--foreground) 70%, transparent); + letter-spacing: 0.01em; +} + +.tiptap-editor .ProseMirror .email-inbox-list { + display: flex; + flex-direction: column; +} + +/* Each email row — hairline separator only, no card */ +.tiptap-editor .ProseMirror .email-inbox-row { + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); +} + +.tiptap-editor .ProseMirror .email-inbox-row:last-child { + border-bottom: none; +} + +.tiptap-editor .ProseMirror .email-inbox-row-header { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 4px 7px 0; + cursor: pointer; + transition: background 0.1s ease; + user-select: none; + border-radius: 4px; +} + +.tiptap-editor .ProseMirror .email-inbox-row-header:hover { + background: color-mix(in srgb, var(--foreground) 5%, transparent); +} + +.tiptap-editor .ProseMirror .email-inbox-row.email-inbox-row-expanded .email-inbox-row-header { + background: color-mix(in srgb, var(--foreground) 3%, transparent); +} + +/* Avatar */ +.tiptap-editor .ProseMirror .email-inbox-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 500; + color: #fff; + flex-shrink: 0; +} + +/* Content column — two-line layout */ +.tiptap-editor .ProseMirror .email-inbox-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.tiptap-editor .ProseMirror .email-inbox-top-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.tiptap-editor .ProseMirror .email-inbox-sender { + font-size: 14px; + font-weight: 500; + font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tiptap-editor .ProseMirror .email-inbox-date { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); + white-space: nowrap; + flex-shrink: 0; +} + +.tiptap-editor .ProseMirror .email-inbox-bottom-row { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tiptap-editor .ProseMirror .email-inbox-subject { + font-weight: 500; + color: var(--foreground); +} + +.tiptap-editor .ProseMirror .email-inbox-snippet { + color: color-mix(in srgb, var(--foreground) 45%, transparent); + font-weight: 400; +} + +/* Expand chevron */ +.tiptap-editor .ProseMirror .email-inbox-chevron { + flex-shrink: 0; + color: color-mix(in srgb, var(--foreground) 35%, transparent); + transition: transform 0.15s ease; +} + +.tiptap-editor .ProseMirror .email-inbox-chevron.email-inbox-chevron-open { + transform: rotate(180deg); +} + +/* Expanded content padding */ +.tiptap-editor .ProseMirror .email-inbox-expanded-wrap { + padding: 8px 0 12px 0; + border-top: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent); +} + /* Transcript block */ .tiptap-editor .ProseMirror .transcript-block-toggle { display: flex; 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 3172993a..fee2b124 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -67,14 +67,12 @@ After the block, you MAY add one short markdown line per event giving useful pre trackId: 'emails', icon: 'mail', instruction: -`Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread). +`Maintain a digest of email threads worth the user's attention today. Output everything as a single fenced code block with language "emails" (plural) — never individual "email" (singular) blocks. The content must be a JSON object: {"title":"Today's Emails","emails":[...]} where each entry has threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response. -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. +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 entry for a new threadId, or update the existing entry 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. -Each email block should include threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add a draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response. - If there is genuinely nothing to surface, output the single line: No new emails. Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`, 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 fd90875b..cd17a940 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -163,15 +163,15 @@ If there are events, include them: 1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`) 2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`) 3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response -4. For emails needing a response, output \\\`\\\`\\\`email blocks with a \`draft_response\`. Write the draft in the user's voice — direct, informal, no fluff. Example: +4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example: \`\`\` -\\\`\\\`\\\`email -{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."} +\\\`\\\`\\\`emails +{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"Security alert","subject":"New sign-in from Chrome","from":"Google <no-reply@accounts.google.com>","date":"2026-04-01T09:15:00+05:30","latest_email":"A new sign-in to your account was detected."}]} \\\`\\\`\\\` \`\`\` -5. For other important/recent emails, output \\\`\\\`\\\`email blocks without \`draft_response\` as FYI items +5. FYI emails go in the same \`emails\` array without a \`draft_response\` 6. **Recency matters.** Since this refreshes every 15 minutes, prioritize emails that arrived since the last refresh. On the first run of the day (morning), include notable emails from the last 24 hours. On subsequent runs, focus on what's new — don't re-list emails the user has already seen unless their status changed (e.g., a thread got a new reply). 7. Add a brief take on emails where it's helpful — flag what's worth reading vs. what's noise. Be direct: "This is a cold pitch, probably skip" or "Worth reading — they're asking about pricing for a team of 50." 8. If no new emails have come in since the last refresh, just say "No new emails" or omit the section entirely. Don't re-surface stale items. @@ -200,7 +200,7 @@ This is NOT a generic task list. These are the things the user should actually f ## Output format - Start with the date heading as described above - Use clean markdown with the section headers (## Up Next, ## Calendar, ## Emails, ## What You Missed, ## Today's Priorities) -- Use \\\`\\\`\\\`calendar and \\\`\\\`\\\`email code blocks where specified — these render as interactive UI blocks +- Use \\\`\\\`\\\`calendar and \\\`\\\`\\\`emails (plural) code blocks where specified — these render as interactive UI blocks. Never use \\\`\\\`\\\`email (singular) - Keep the overall brief **scannable and concise** — this should take under 30 seconds to read on a refresh, under 60 seconds for the morning brief - Write in a natural, conversational tone throughout — you're briefing a person, not generating a report - **Sections can be omitted** if they have nothing to show. Don't include empty sections with filler text. The brief should get shorter as the day goes on and things get resolved. diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index 2219c36a..8617e861 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -101,6 +101,13 @@ export const EmailBlockSchema = z.object({ export type EmailBlock = z.infer<typeof EmailBlockSchema>; +export const EmailsBlockSchema = z.object({ + title: z.string().optional(), + emails: z.array(EmailBlockSchema), +}); + +export type EmailsBlock = z.infer<typeof EmailsBlockSchema>; + export const TranscriptBlockSchema = z.object({ transcript: z.string(), }); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index efe77d10..bdf05f5e 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -177,28 +177,28 @@ importers: version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) '@tiptap/extension-image': specifier: ^3.16.0 - version: 3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) + version: 3.16.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3)) '@tiptap/extension-link': specifier: ^3.15.3 - version: 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + version: 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-placeholder': specifier: ^3.15.3 - version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) '@tiptap/extension-table': specifier: ^3.22.4 - version: 3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-task-item': specifier: ^3.15.3 - version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) '@tiptap/extension-task-list': specifier: ^3.15.3 - version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) '@tiptap/pm': specifier: ^3.15.3 version: 3.15.3 '@tiptap/react': specifier: ^3.15.3 - version: 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/starter-kit': specifier: ^3.15.3 version: 3.15.3 @@ -264,7 +264,7 @@ importers: version: 4.1.18 tiptap-markdown: specifier: ^0.9.0 - version: 0.9.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) + version: 0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3)) tokenlens: specifier: ^1.3.1 version: 1.3.1 @@ -1479,30 +1479,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} @@ -2628,56 +2633,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -2996,24 +3012,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3053,6 +3073,11 @@ packages: peerDependencies: '@tiptap/pm': ^3.15.3 + '@tiptap/core@3.22.4': + resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==} + peerDependencies: + '@tiptap/pm': 3.22.4 + '@tiptap/extension-blockquote@3.15.3': resolution: {integrity: sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==} peerDependencies: @@ -5602,24 +5627,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -11073,6 +11102,10 @@ snapshots: dependencies: '@tiptap/pm': 3.15.3 + '@tiptap/core@3.22.4(@tiptap/pm@3.15.3)': + dependencies: + '@tiptap/pm': 3.15.3 + '@tiptap/extension-blockquote@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) @@ -11081,16 +11114,16 @@ snapshots: dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/extension-bubble-menu@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-bubble-menu@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 optional: true - '@tiptap/extension-bullet-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-bullet-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-code-block@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: @@ -11105,20 +11138,20 @@ snapshots: dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/extension-dropcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-dropcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extensions': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-floating-menu@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-floating-menu@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 optional: true - '@tiptap/extension-gapcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-gapcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extensions': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-hard-break@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: @@ -11133,9 +11166,9 @@ snapshots: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 - '@tiptap/extension-image@3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-image@3.16.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) '@tiptap/extension-italic@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: @@ -11147,47 +11180,58 @@ snapshots: '@tiptap/pm': 3.15.3 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-link@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) + '@tiptap/pm': 3.15.3 + linkifyjs: 4.3.2 - '@tiptap/extension-list-keymap@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-list-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + + '@tiptap/extension-list-keymap@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + dependencies: + '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 - '@tiptap/extension-ordered-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) + '@tiptap/pm': 3.15.3 + + '@tiptap/extension-ordered-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + dependencies: + '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-paragraph@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/extension-placeholder@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-placeholder@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extensions': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-strike@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/extension-table@3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 - '@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-task-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-task-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-text@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': dependencies: @@ -11202,6 +11246,11 @@ snapshots: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 + '@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + dependencies: + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) + '@tiptap/pm': 3.15.3 + '@tiptap/pm@3.15.3': dependencies: prosemirror-changeset: 2.3.1 @@ -11223,9 +11272,9 @@ snapshots: prosemirror-transform: 1.10.5 prosemirror-view: 1.41.4 - '@tiptap/react@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tiptap/react@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) '@tiptap/pm': 3.15.3 '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) @@ -11235,8 +11284,8 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-floating-menu': 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-bubble-menu': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-floating-menu': 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) transitivePeerDependencies: - '@floating-ui/dom' @@ -11245,21 +11294,21 @@ snapshots: '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) '@tiptap/extension-blockquote': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-bold': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-bullet-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + '@tiptap/extension-bullet-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) '@tiptap/extension-code': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-code-block': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-document': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-dropcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-gapcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + '@tiptap/extension-dropcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + '@tiptap/extension-gapcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) '@tiptap/extension-hard-break': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-heading': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-horizontal-rule': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-italic': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-link': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-list-item': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-list-keymap': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-ordered-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + '@tiptap/extension-list-item': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + '@tiptap/extension-list-keymap': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + '@tiptap/extension-ordered-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) '@tiptap/extension-paragraph': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-strike': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) '@tiptap/extension-text': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) @@ -16221,9 +16270,9 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tiptap-markdown@0.9.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)): + tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3)): dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) '@types/markdown-it': 13.0.9 markdown-it: 14.1.0 markdown-it-task-lists: 2.1.1 From d6651c4bf8aaff9f2056e38ae1b2d82bcef1b373 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 22:22:27 +0530 Subject: [PATCH 044/143] fix build issues --- apps/x/apps/renderer/package.json | 19 +- .../src/components/markdown-editor.tsx | 2 +- .../renderer/src/extensions/email-block.tsx | 1 - .../apps/renderer/src/extensions/wiki-link.ts | 35 +- apps/x/pnpm-lock.yaml | 553 +++++++----------- 5 files changed, 250 insertions(+), 360 deletions(-) diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index d9216de1..359f1709 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -25,15 +25,16 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tailwindcss/vite": "^4.1.18", - "@tiptap/extension-image": "^3.16.0", - "@tiptap/extension-link": "^3.15.3", - "@tiptap/extension-placeholder": "^3.15.3", - "@tiptap/extension-table": "^3.22.4", - "@tiptap/extension-task-item": "^3.15.3", - "@tiptap/extension-task-list": "^3.15.3", - "@tiptap/pm": "^3.15.3", - "@tiptap/react": "^3.15.3", - "@tiptap/starter-kit": "^3.15.3", + "@tiptap/core": "3.22.4", + "@tiptap/extension-image": "3.22.4", + "@tiptap/extension-link": "3.22.4", + "@tiptap/extension-placeholder": "3.22.4", + "@tiptap/extension-table": "3.22.4", + "@tiptap/extension-task-item": "3.22.4", + "@tiptap/extension-task-list": "3.22.4", + "@tiptap/pm": "3.22.4", + "@tiptap/react": "3.22.4", + "@tiptap/starter-kit": "3.22.4", "@x/preload": "workspace:*", "@x/shared": "workspace:*", "ai": "^5.0.117", diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 10e429d8..9c1634a3 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -713,7 +713,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro MermaidBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate - ? (path) => { + ? (path: string) => { void wikiLinks.onCreate(path) } : undefined, diff --git a/apps/x/apps/renderer/src/extensions/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx index d7350e81..d615c212 100644 --- a/apps/x/apps/renderer/src/extensions/email-block.tsx +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -117,7 +117,6 @@ function EmailExpandedBody({ ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` : null - const senderName = config.from ? extractName(config.from) : 'Unknown' const initial = config.from ? getInitial(config.from) : '?' const color = config.from ? avatarColor(config.from) : '#5f6368' const hasDraft = !!config.draft_response diff --git a/apps/x/apps/renderer/src/extensions/wiki-link.ts b/apps/x/apps/renderer/src/extensions/wiki-link.ts index 6fa1446d..09b6d866 100644 --- a/apps/x/apps/renderer/src/extensions/wiki-link.ts +++ b/apps/x/apps/renderer/src/extensions/wiki-link.ts @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/react' -import { InputRule, inputRules } from '@tiptap/pm/inputrules' +import { InputRule, Node, mergeAttributes } from '@tiptap/core' import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/ @@ -88,13 +87,13 @@ export const WikiLink = Node.create<WikiLinkOptions>({ return [ { tag: 'wiki-link[data-path]', - getAttrs: (element) => ({ + getAttrs: (element: Element) => ({ path: (element as HTMLElement).getAttribute('data-path') ?? '', }), }, { tag: 'a[data-type="wiki-link"]', - getAttrs: (element) => ({ + getAttrs: (element: Element) => ({ path: (element as HTMLElement).getAttribute('data-path') ?? '', }), }, @@ -132,23 +131,23 @@ export const WikiLink = Node.create<WikiLinkOptions>({ } }, - addProseMirrorPlugins() { + addInputRules() { const onCreate = this.options.onCreate - const rules = [ - new InputRule(wikiLinkInputRegex, (state, match, start, end) => { - const rawPath = match[1]?.trim() - const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : '' - if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null - if (state.selection.$from.parent.type.spec.code) return null - if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null + return [ + new InputRule({ + find: wikiLinkInputRegex, + handler: ({ state, range, match }) => { + const rawPath = match[1]?.trim() + const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : '' + if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null + if (state.selection.$from.parent.type.spec.code) return null + if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null - const finalPath = ensureMarkdownExtension(normalizedPath) - const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath })) - onCreate?.(finalPath) - return tr + const finalPath = ensureMarkdownExtension(normalizedPath) + state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath })) + onCreate?.(finalPath) + }, }), ] - - return [inputRules({ rules })] }, }) diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index bdf05f5e..eb329c0d 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -175,33 +175,36 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@tiptap/core': + specifier: 3.22.4 + version: 3.22.4(@tiptap/pm@3.22.4) '@tiptap/extension-image': - specifier: ^3.16.0 - version: 3.16.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3)) + specifier: 3.22.4 + version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) '@tiptap/extension-link': - specifier: ^3.15.3 - version: 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + specifier: 3.22.4 + version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-placeholder': - specifier: ^3.15.3 - version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + specifier: 3.22.4 + version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/extension-table': - specifier: ^3.22.4 - version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + specifier: 3.22.4 + version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) '@tiptap/extension-task-item': - specifier: ^3.15.3 - version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + specifier: 3.22.4 + version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/extension-task-list': - specifier: ^3.15.3 - version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) + specifier: 3.22.4 + version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) '@tiptap/pm': - specifier: ^3.15.3 - version: 3.15.3 + specifier: 3.22.4 + version: 3.22.4 '@tiptap/react': - specifier: ^3.15.3 - version: 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 3.22.4 + version: 3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/starter-kit': - specifier: ^3.15.3 - version: 3.15.3 + specifier: 3.22.4 + version: 3.22.4 '@x/preload': specifier: workspace:* version: link:../preload @@ -264,7 +267,7 @@ importers: version: 4.1.18 tiptap-markdown: specifier: ^0.9.0 - version: 0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3)) + version: 0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) tokenlens: specifier: ^1.3.1 version: 1.3.1 @@ -1479,35 +1482,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} @@ -2593,9 +2591,6 @@ packages: react-redux: optional: true - '@remirror/core-constants@3.0.0': - resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -2633,67 +2628,56 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -3012,28 +2996,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3068,137 +3048,132 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@tiptap/core@3.15.3': - resolution: {integrity: sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==} - peerDependencies: - '@tiptap/pm': ^3.15.3 - '@tiptap/core@3.22.4': resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==} peerDependencies: '@tiptap/pm': 3.22.4 - '@tiptap/extension-blockquote@3.15.3': - resolution: {integrity: sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==} + '@tiptap/extension-blockquote@3.22.5': + resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-bold@3.15.3': - resolution: {integrity: sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==} + '@tiptap/extension-bold@3.22.5': + resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-bubble-menu@3.15.3': - resolution: {integrity: sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==} + '@tiptap/extension-bubble-menu@3.22.5': + resolution: {integrity: sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-bullet-list@3.15.3': - resolution: {integrity: sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==} + '@tiptap/extension-bullet-list@3.22.5': + resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-code-block@3.15.3': - resolution: {integrity: sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==} + '@tiptap/extension-code-block@3.22.5': + resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-code@3.15.3': - resolution: {integrity: sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==} + '@tiptap/extension-code@3.22.5': + resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-document@3.15.3': - resolution: {integrity: sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==} + '@tiptap/extension-document@3.22.5': + resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-dropcursor@3.15.3': - resolution: {integrity: sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==} + '@tiptap/extension-dropcursor@3.22.5': + resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} peerDependencies: - '@tiptap/extensions': ^3.15.3 + '@tiptap/extensions': 3.22.5 - '@tiptap/extension-floating-menu@3.15.3': - resolution: {integrity: sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==} + '@tiptap/extension-floating-menu@3.22.5': + resolution: {integrity: sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-gapcursor@3.15.3': - resolution: {integrity: sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==} + '@tiptap/extension-gapcursor@3.22.5': + resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} peerDependencies: - '@tiptap/extensions': ^3.15.3 + '@tiptap/extensions': 3.22.5 - '@tiptap/extension-hard-break@3.15.3': - resolution: {integrity: sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==} + '@tiptap/extension-hard-break@3.22.5': + resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-heading@3.15.3': - resolution: {integrity: sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==} + '@tiptap/extension-heading@3.22.5': + resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-horizontal-rule@3.15.3': - resolution: {integrity: sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg==} + '@tiptap/extension-horizontal-rule@3.22.5': + resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-image@3.16.0': - resolution: {integrity: sha512-mTjt4kdyVtY/2dJcfxAgBae/dkH+r6GwARl7NlPtnI3EzpELFR65FNuOQyTxFXP3yfV9uMtPpq6Wevk8aLTsxQ==} + '@tiptap/extension-image@3.22.4': + resolution: {integrity: sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA==} peerDependencies: - '@tiptap/core': ^3.16.0 + '@tiptap/core': 3.22.4 - '@tiptap/extension-italic@3.15.3': - resolution: {integrity: sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==} + '@tiptap/extension-italic@3.22.5': + resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-link@3.15.3': - resolution: {integrity: sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==} + '@tiptap/extension-link@3.22.4': + resolution: {integrity: sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 - '@tiptap/extension-list-item@3.15.3': - resolution: {integrity: sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==} + '@tiptap/extension-list-item@3.22.5': + resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-list-keymap@3.15.3': - resolution: {integrity: sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==} + '@tiptap/extension-list-keymap@3.22.5': + resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-list@3.15.3': - resolution: {integrity: sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==} + '@tiptap/extension-list@3.22.5': + resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/extension-ordered-list@3.15.3': - resolution: {integrity: sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==} + '@tiptap/extension-ordered-list@3.22.5': + resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': 3.22.5 - '@tiptap/extension-paragraph@3.15.3': - resolution: {integrity: sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==} + '@tiptap/extension-paragraph@3.22.5': + resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-placeholder@3.15.3': - resolution: {integrity: sha512-XcHHnojT186hKIoOgcPBesXk89+caNGVUdMtc171Vcr/5s0dpnr4q5LfE+YRC+S85CpCxCRRnh84Ou+XRtOqrw==} + '@tiptap/extension-placeholder@3.22.4': + resolution: {integrity: sha512-Z3wtWL+KufwkC7CkJge5enAxx4q8C3oOYixme02snY9zfjX3V/1pjAmEfP4wxScgM5GIuTEJ83B9Yz3wRzPA6Q==} peerDependencies: - '@tiptap/extensions': ^3.15.3 + '@tiptap/extensions': 3.22.4 - '@tiptap/extension-strike@3.15.3': - resolution: {integrity: sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==} + '@tiptap/extension-strike@3.22.5': + resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 '@tiptap/extension-table@3.22.4': resolution: {integrity: sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==} @@ -3206,47 +3181,47 @@ packages: '@tiptap/core': 3.22.4 '@tiptap/pm': 3.22.4 - '@tiptap/extension-task-item@3.15.3': - resolution: {integrity: sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==} + '@tiptap/extension-task-item@3.22.4': + resolution: {integrity: sha512-PhoiOMatdRXJU1HJz0fMP5N7wv0eYAz/Id/gphby/gdxjYQaMhJ7vQiLTR28EkVBkdntTUb1bwZ4XQn9thFtpw==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': 3.22.4 - '@tiptap/extension-task-list@3.15.3': - resolution: {integrity: sha512-nh8iBk1LHVIoqxphLoqZlLAN9fF2i9ZeK+2TjGSS35lfh7sYzRoSjNW0E81Uy48YuCzM1NQYghYR5Qfc7vm4jA==} + '@tiptap/extension-task-list@3.22.4': + resolution: {integrity: sha512-5M3XiZMZJ2mwWSUKPG4mb90g86rpgYw7yf8lBEkaCgke9XxsLg8mXmYRpCc6n/v1TQXryB+WDKuenCzJTx/4/A==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': 3.22.4 - '@tiptap/extension-text@3.15.3': - resolution: {integrity: sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==} + '@tiptap/extension-text@3.22.5': + resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extension-underline@3.15.3': - resolution: {integrity: sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==} + '@tiptap/extension-underline@3.22.5': + resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': 3.22.5 - '@tiptap/extensions@3.15.3': - resolution: {integrity: sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==} + '@tiptap/extensions@3.22.5': + resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 - '@tiptap/pm@3.15.3': - resolution: {integrity: sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==} + '@tiptap/pm@3.22.4': + resolution: {integrity: sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==} - '@tiptap/react@3.15.3': - resolution: {integrity: sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==} + '@tiptap/react@3.22.4': + resolution: {integrity: sha512-XIQZPwLakR1t8+Q1UeCpr+kUHDWxpJzGy9r2xUi3mpPd6Wh8dtNltScBkUlCcr0sqc6J1GF6Is02JJVQGmCZMA==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': 3.22.4 + '@tiptap/pm': 3.22.4 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.15.3': - resolution: {integrity: sha512-ia+eQr9Mt1ln2UO+kK4kFTJOrZK4GhvZXFjpCCYuHtco3rhr2fZAIxEEY4cl/vo5VO5WWyPqxhkFeLcoWmNjSw==} + '@tiptap/starter-kit@3.22.4': + resolution: {integrity: sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==} '@tokenlens/core@1.3.0': resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==} @@ -4165,9 +4140,6 @@ packages: engines: {node: '>=0.8'} hasBin: true - crelt@1.0.6: - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - cron-parser@5.5.0: resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} engines: {node: '>=18'} @@ -5627,28 +5599,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6560,9 +6528,6 @@ packages: prosemirror-changeset@2.3.1: resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} - prosemirror-collab@1.3.1: - resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} - prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -6575,24 +6540,15 @@ packages: prosemirror-history@1.5.0: resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} - prosemirror-inputrules@1.5.1: - resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} - prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} prosemirror-markdown@1.13.2: resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} - prosemirror-menu@1.2.5: - resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} - prosemirror-model@1.25.4: resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} - prosemirror-schema-basic@1.2.4: - resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} - prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -6602,13 +6558,6 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-trailing-node@3.0.0: - resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} - peerDependencies: - prosemirror-model: ^1.22.1 - prosemirror-state: ^1.4.2 - prosemirror-view: ^1.33.8 - prosemirror-transform@1.10.5: resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} @@ -10575,8 +10524,6 @@ snapshots: react: 19.2.3 react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) - '@remirror/core-constants@3.0.0': {} - '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/rollup-android-arm-eabi@4.54.0': @@ -11098,184 +11045,158 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) - '@tiptap/core@3.15.3(@tiptap/pm@3.15.3)': + '@tiptap/core@3.22.4(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/pm': 3.15.3 + '@tiptap/pm': 3.22.4 - '@tiptap/core@3.22.4(@tiptap/pm@3.15.3)': + '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-blockquote@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-bold@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': - dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - - '@tiptap/extension-bubble-menu@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-bubble-menu@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 optional: true - '@tiptap/extension-bullet-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-code-block@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 - '@tiptap/extension-code@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-document@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-dropcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-floating-menu@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-floating-menu@3.22.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 optional: true - '@tiptap/extension-gapcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-hard-break@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-heading@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-horizontal-rule@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 - '@tiptap/extension-image@3.16.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))': + '@tiptap/extension-image@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-italic@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-link@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-link@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 linkifyjs: 4.3.2 - '@tiptap/extension-link@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 - linkifyjs: 4.3.2 + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-list-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-list-keymap@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 - '@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-ordered-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-paragraph@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-placeholder@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 - '@tiptap/extension-strike@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': + '@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) - '@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-task-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))': + '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) - '@tiptap/extension-text@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 - '@tiptap/extension-underline@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))': - dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - - '@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': - dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 - - '@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)': - dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 - - '@tiptap/pm@3.15.3': + '@tiptap/pm@3.22.4': dependencies: prosemirror-changeset: 2.3.1 - prosemirror-collab: 1.3.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 prosemirror-gapcursor: 1.4.0 prosemirror-history: 1.5.0 - prosemirror-inputrules: 1.5.1 prosemirror-keymap: 1.2.3 - prosemirror-markdown: 1.13.2 - prosemirror-menu: 1.2.5 prosemirror-model: 1.25.4 - prosemirror-schema-basic: 1.2.4 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4) prosemirror-transform: 1.10.5 prosemirror-view: 1.41.4 - '@tiptap/react@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tiptap/react@3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) '@types/use-sync-external-store': 0.0.6 @@ -11284,37 +11205,37 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-floating-menu': 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) + '@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.15.3': + '@tiptap/starter-kit@3.22.4': dependencies: - '@tiptap/core': 3.15.3(@tiptap/pm@3.15.3) - '@tiptap/extension-blockquote': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-bold': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-bullet-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-code': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-code-block': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-document': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-dropcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-gapcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-hard-break': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-heading': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-horizontal-rule': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-italic': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-link': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/extension-list-item': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-list-keymap': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-ordered-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.22.4(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)) - '@tiptap/extension-paragraph': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-strike': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-text': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extension-underline': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)) - '@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3) - '@tiptap/pm': 3.15.3 + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) + '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-link': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)) + '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4) + '@tiptap/pm': 3.22.4 '@tokenlens/core@1.3.0': {} @@ -12360,8 +12281,6 @@ snapshots: crc-32@1.2.2: {} - crelt@1.0.6: {} - cron-parser@5.5.0: dependencies: luxon: 3.7.2 @@ -15302,10 +15221,6 @@ snapshots: dependencies: prosemirror-transform: 1.10.5 - prosemirror-collab@1.3.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-commands@1.7.1: dependencies: prosemirror-model: 1.25.4 @@ -15332,11 +15247,6 @@ snapshots: prosemirror-view: 1.41.4 rope-sequence: 1.3.4 - prosemirror-inputrules@1.5.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 @@ -15348,21 +15258,10 @@ snapshots: markdown-it: 14.1.0 prosemirror-model: 1.25.4 - prosemirror-menu@1.2.5: - dependencies: - crelt: 1.0.6 - prosemirror-commands: 1.7.1 - prosemirror-history: 1.5.0 - prosemirror-state: 1.4.4 - prosemirror-model@1.25.4: dependencies: orderedmap: 2.1.1 - prosemirror-schema-basic@1.2.4: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.4 @@ -15383,14 +15282,6 @@ snapshots: prosemirror-transform: 1.10.5 prosemirror-view: 1.41.4 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4): - dependencies: - '@remirror/core-constants': 3.0.0 - escape-string-regexp: 4.0.0 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 - prosemirror-transform@1.10.5: dependencies: prosemirror-model: 1.25.4 @@ -16270,9 +16161,9 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.15.3)): + tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)): dependencies: - '@tiptap/core': 3.22.4(@tiptap/pm@3.15.3) + '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@types/markdown-it': 13.0.9 markdown-it: 14.1.0 markdown-it-task-lists: 2.1.1 From a18f5dc3dde996a0e567164e22f50198860153b3 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 19:03:27 +0530 Subject: [PATCH 045/143] coding with acpx --- apps/x/apps/renderer/src/App.tsx | 57 +++- .../src/components/terminal-output.tsx | 24 ++ .../renderer/src/lib/chat-conversation.ts | 1 + .../apps/renderer/src/lib/terminal-output.ts | 319 ++++++++++++++++++ apps/x/packages/core/src/agents/runtime.ts | 26 +- .../src/application/assistant/instructions.ts | 2 + .../skills/code-with-agents/skill.ts | 90 +++++ .../src/application/assistant/skills/index.ts | 7 + .../core/src/application/lib/builtin-tools.ts | 10 + .../src/application/lib/command-executor.ts | 9 +- .../core/src/application/lib/exec-tool.ts | 5 +- apps/x/packages/shared/src/runs.ts | 8 + 12 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/terminal-output.tsx create mode 100644 apps/x/apps/renderer/src/lib/terminal-output.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index c2e35cb2..b6e6add7 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; @@ -41,6 +41,7 @@ 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'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; @@ -121,6 +122,31 @@ function SmoothStreamingMessage({ text, components }: { text: string; components return <MessageResponse components={components}>{smoothText}</MessageResponse> } +function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { + const ref = useRef<HTMLPreElement>(null) + const stickToBottom = useRef(true) + + useLayoutEffect(() => { + const el = ref.current + if (el && stickToBottom.current) { + el.scrollTop = el.scrollHeight + } + }, [children]) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickToBottom.current = atBottom + }, []) + + return ( + <pre ref={ref} onScroll={handleScroll} className={className}> + {children} + </pre> + ) +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -2085,6 +2111,10 @@ function App() { return next }) + if (event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) + } + // Handle app-navigation tool results — trigger UI side effects if (event.toolName === 'app-navigation') { const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined @@ -2096,6 +2126,23 @@ function App() { break } + case 'tool-output-stream': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if ( + isToolCall(item) + && item.id === event.toolCallId + ) { + if (!item.streamingOutput) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, streamingOutput: (item.streamingOutput ?? '') + event.output } + } + return item + })) + break + } + case 'tool-permission-request': { if (!isActiveRun) return const key = event.toolCall.toolCallId @@ -4314,7 +4361,13 @@ function App() { state={toToolState(item.status)} /> <ToolContent> - <ToolTabbedContent input={input} output={output} errorText={errorText} /> + {item.streamingOutput && item.status === 'running' ? ( + <AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90"> + <TerminalOutput raw={item.streamingOutput} /> + </AutoScrollPre> + ) : ( + <ToolTabbedContent input={input} output={output} errorText={errorText} /> + )} </ToolContent> </Tool> ) diff --git a/apps/x/apps/renderer/src/components/terminal-output.tsx b/apps/x/apps/renderer/src/components/terminal-output.tsx new file mode 100644 index 00000000..587616c8 --- /dev/null +++ b/apps/x/apps/renderer/src/components/terminal-output.tsx @@ -0,0 +1,24 @@ +import React, { useMemo } from 'react' +import { processTerminalOutput, spanStyleToCSS } from '../lib/terminal-output' + +export function TerminalOutput({ raw }: { raw: string }) { + const lines = useMemo(() => processTerminalOutput(raw), [raw]) + + return ( + <> + {lines.map((line, lineIdx) => ( + <React.Fragment key={lineIdx}> + {lineIdx > 0 && '\n'} + {line.spans.map((span, spanIdx) => { + const css = spanStyleToCSS(span.style) + return css ? ( + <span key={spanIdx} style={css}>{span.text}</span> + ) : ( + <React.Fragment key={spanIdx}>{span.text}</React.Fragment> + ) + })} + </React.Fragment> + ))} + </> + ) +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 150edacb..6ae88d93 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -24,6 +24,7 @@ export interface ToolCall { name: string input: ToolUIPart['input'] result?: ToolUIPart['output'] + streamingOutput?: string status: 'pending' | 'running' | 'completed' | 'error' timestamp: number } diff --git a/apps/x/apps/renderer/src/lib/terminal-output.ts b/apps/x/apps/renderer/src/lib/terminal-output.ts new file mode 100644 index 00000000..fadb0eb7 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/terminal-output.ts @@ -0,0 +1,319 @@ +/** + * Terminal output processor that handles ANSI escape sequences, carriage returns, + * and other terminal control characters to produce styled, terminal-like output. + */ + +export interface StyledSpan { + text: string + style: SpanStyle +} + +export interface SpanStyle { + bold?: boolean + dim?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + fg?: string + bg?: string +} + +export interface TerminalLine { + spans: StyledSpan[] +} + +const ANSI_COLORS_16: Record<number, string> = { + 30: '#4e4e4e', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b', + 34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#dcdfe4', + 90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#e5c07b', + 94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff', +} + +const ANSI_BG_COLORS_16: Record<number, string> = { + 40: '#4e4e4e', 41: '#e06c75', 42: '#98c379', 43: '#e5c07b', + 44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#dcdfe4', + 100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#e5c07b', + 104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff', +} + +function color256(n: number): string { + if (n < 8) return ANSI_COLORS_16[30 + n] ?? '#dcdfe4' + if (n < 16) return ANSI_COLORS_16[90 + (n - 8)] ?? '#dcdfe4' + if (n < 232) { + const idx = n - 16 + const r = Math.floor(idx / 36) + const g = Math.floor((idx % 36) / 6) + const b = idx % 6 + const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` + } + const level = 8 + (n - 232) * 10 + const hex = level.toString(16).padStart(2, '0') + return `#${hex}${hex}${hex}` +} + +function parseSGR(params: number[], style: SpanStyle): SpanStyle { + const s = { ...style } + let i = 0 + while (i < params.length) { + const p = params[i] + if (p === 0) { + delete s.bold + delete s.dim + delete s.italic + delete s.underline + delete s.strikethrough + delete s.fg + delete s.bg + } else if (p === 1) s.bold = true + else if (p === 2) s.dim = true + else if (p === 3) s.italic = true + else if (p === 4) s.underline = true + else if (p === 9) s.strikethrough = true + else if (p === 22) { + delete s.bold + delete s.dim + } else if (p === 23) delete s.italic + else if (p === 24) delete s.underline + else if (p === 29) delete s.strikethrough + else if (p >= 30 && p <= 37) s.fg = ANSI_COLORS_16[p] + else if (p === 38) { + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + s.fg = color256(params[i + 2]) + i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + const r = params[i + 2] + const g = params[i + 3] + const b = params[i + 4] + s.fg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + i += 4 + } + } else if (p === 39) delete s.fg + else if (p >= 40 && p <= 47) s.bg = ANSI_BG_COLORS_16[p] + else if (p === 48) { + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + s.bg = color256(params[i + 2]) + i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + const r = params[i + 2] + const g = params[i + 3] + const b = params[i + 4] + s.bg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + i += 4 + } + } else if (p === 49) delete s.bg + else if (p >= 90 && p <= 97) s.fg = ANSI_COLORS_16[p] + else if (p >= 100 && p <= 107) s.bg = ANSI_BG_COLORS_16[p] + i++ + } + return s +} + +export function processTerminalOutput(raw: string): TerminalLine[] { + type Cell = { char: string; style: SpanStyle } + const lines: Cell[][] = [[]] + let cursorRow = 0 + let cursorCol = 0 + let currentStyle: SpanStyle = {} + + function ensureRow(row: number) { + while (lines.length <= row) lines.push([]) + } + + function ensureCol(row: number, col: number) { + ensureRow(row) + const line = lines[row] + while (line.length <= col) line.push({ char: ' ', style: {} }) + } + + let i = 0 + while (i < raw.length) { + const ch = raw[i] + + if (ch === '\x1b' && i + 1 < raw.length) { + const next = raw[i + 1] + + if (next === '[') { + i += 2 + let paramStr = '' + while (i < raw.length && raw[i] >= '\x20' && raw[i] <= '\x3f') { + paramStr += raw[i] + i++ + } + const finalByte = i < raw.length ? raw[i] : '' + i++ + + const params = paramStr.length > 0 + ? paramStr.split(';').map(s => parseInt(s, 10) || 0) + : [0] + + switch (finalByte) { + case 'm': + currentStyle = parseSGR(params, currentStyle) + break + case 'A': + cursorRow = Math.max(0, cursorRow - (params[0] || 1)) + break + case 'B': + cursorRow += (params[0] || 1) + ensureRow(cursorRow) + break + case 'C': + cursorCol += (params[0] || 1) + break + case 'D': + cursorCol = Math.max(0, cursorCol - (params[0] || 1)) + break + case 'G': + cursorCol = Math.max(0, (params[0] || 1) - 1) + break + case 'H': + case 'f': + cursorRow = Math.max(0, (params[0] || 1) - 1) + cursorCol = Math.max(0, (params[1] || 1) - 1) + ensureRow(cursorRow) + break + case 'J': { + const mode = params[0] || 0 + if (mode === 2 || mode === 3) { + lines.length = 0 + lines.push([]) + cursorRow = 0 + cursorCol = 0 + } else if (mode === 0) { + ensureRow(cursorRow) + lines[cursorRow].length = cursorCol + for (let r = cursorRow + 1; r < lines.length; r++) lines[r] = [] + } else if (mode === 1) { + for (let r = 0; r < cursorRow; r++) lines[r] = [] + ensureCol(cursorRow, cursorCol) + for (let c = 0; c <= cursorCol; c++) lines[cursorRow][c] = { char: ' ', style: {} } + } + break + } + case 'K': { + const mode = params[0] || 0 + ensureRow(cursorRow) + const line = lines[cursorRow] + if (mode === 0) { + line.length = cursorCol + } else if (mode === 1) { + ensureCol(cursorRow, cursorCol) + for (let c = 0; c <= cursorCol; c++) line[c] = { char: ' ', style: {} } + } else if (mode === 2) { + lines[cursorRow] = [] + } + break + } + default: + break + } + continue + } + + if (next === ']') { + i += 2 + while (i < raw.length && raw[i] !== '\x07' && !(raw[i] === '\x1b' && raw[i + 1] === '\\')) { + i++ + } + if (i < raw.length && raw[i] === '\x07') i++ + else if (i < raw.length) i += 2 + continue + } + + i += 2 + continue + } + + if (ch === '\r') { + cursorCol = 0 + i++ + continue + } + + if (ch === '\n') { + cursorRow++ + cursorCol = 0 + ensureRow(cursorRow) + i++ + continue + } + + if (ch === '\b') { + cursorCol = Math.max(0, cursorCol - 1) + i++ + continue + } + + if (ch === '\t') { + const nextTabStop = (Math.floor(cursorCol / 8) + 1) * 8 + while (cursorCol < nextTabStop) { + ensureCol(cursorRow, cursorCol) + lines[cursorRow][cursorCol] = { char: ' ', style: { ...currentStyle } } + cursorCol++ + } + i++ + continue + } + + if (ch.charCodeAt(0) < 32) { + i++ + continue + } + + ensureCol(cursorRow, cursorCol) + lines[cursorRow][cursorCol] = { char: ch, style: { ...currentStyle } } + cursorCol++ + i++ + } + + return lines.map(cells => { + const spans: StyledSpan[] = [] + if (cells.length === 0) return { spans: [{ text: '', style: {} }] } + + let end = cells.length + while (end > 0 && cells[end - 1].char === ' ' && Object.keys(cells[end - 1].style).length === 0) { + end-- + } + + let currentSpan: StyledSpan | null = null + for (let c = 0; c < end; c++) { + const cell = cells[c] + const sameStyle = currentSpan && styleEquals(currentSpan.style, cell.style) + if (sameStyle && currentSpan) { + currentSpan.text += cell.char + } else { + if (currentSpan) spans.push(currentSpan) + currentSpan = { text: cell.char, style: { ...cell.style } } + } + } + if (currentSpan) spans.push(currentSpan) + if (spans.length === 0) spans.push({ text: '', style: {} }) + return { spans } + }) +} + +function styleEquals(a: SpanStyle, b: SpanStyle): boolean { + return a.bold === b.bold + && a.dim === b.dim + && a.italic === b.italic + && a.underline === b.underline + && a.strikethrough === b.strikethrough + && a.fg === b.fg + && a.bg === b.bg +} + +export function spanStyleToCSS(style: SpanStyle): React.CSSProperties | undefined { + if (Object.keys(style).length === 0) return undefined + const css: React.CSSProperties = {} + if (style.fg) css.color = style.fg + if (style.bg) css.backgroundColor = style.bg + if (style.bold) css.fontWeight = 'bold' + if (style.dim) css.opacity = 0.6 + if (style.italic) css.fontStyle = 'italic' + if (style.underline) css.textDecoration = 'underline' + if (style.strikethrough) { + css.textDecoration = css.textDecoration ? `${css.textDecoration} line-through` : 'line-through' + } + return Object.keys(css).length > 0 ? css : undefined +} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index a635b4e9..07703e30 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -163,6 +163,7 @@ export class AgentRuntime implements IAgentRuntime { modelConfigRepo: this.modelConfigRepo, signal, abortRegistry: this.abortRegistry, + bus: this.bus, })) { eventCount++; if (event.type !== "llm-stream-event") { @@ -855,6 +856,7 @@ export async function* streamAgent({ modelConfigRepo, signal, abortRegistry, + bus, }: { state: AgentState, idGenerator: IMonotonicallyIncreasingIdGenerator; @@ -863,6 +865,7 @@ export async function* streamAgent({ modelConfigRepo: IModelConfigRepo; signal: AbortSignal; abortRegistry: IAbortRegistry; + bus: IBus; }): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> { const logger = new PrefixLogger(`run-${runId}-${state.agentName}`); @@ -972,11 +975,12 @@ export async function* streamAgent({ state: subflowState, idGenerator, runId, - messageQueue, - modelConfigRepo, - signal, - abortRegistry, - })) { + messageQueue, + modelConfigRepo, + signal, + abortRegistry, + bus, + })) { yield* processEvent({ ...event, subflow: [toolCallId, ...event.subflow], @@ -985,9 +989,15 @@ export async function* streamAgent({ if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { result = subflowState.finalResponse(); } - } else { - result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); - } + } else { + result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { + runId, + toolCallId, + signal, + abortRegistry, + publish: (event) => bus.publish(event), + }); + } } catch (error) { if ((error instanceof Error && error.name === "AbortError") || signal.aborted) { throw error; diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index a455d845..86fe3f9e 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -80,6 +80,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base. +**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx. + **App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view. **Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards. diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts new file mode 100644 index 00000000..c2879228 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -0,0 +1,90 @@ +export const skill = String.raw` +# Code with Agents Skill + +Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex). + +## Important: delegate ALL coding work + +Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes: +- Writing, editing, or refactoring code +- Reading, summarizing, or explaining code +- Debugging and fixing bugs +- Running tests or build commands +- Exploring project structure +- Any other task that involves interacting with a codebase + +Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent. + +## Prerequisites + +The user must have one of the following installed on their machine: +- **Claude Code** — https://claude.ai/code +- **Codex** — https://codex.openai.com + +These are external tools that you cannot install for the user. + +## Workflow + +### Step 1: Gather requirements + +Before running anything, confirm the following with the user: + +1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?" +2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine. + +### Step 2: Confirm execution plan + +Once you know the folder and agent, tell the user: + +> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything. + +### Step 3: Execute with acpx + +Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is: + +**For Claude Code:** +` + "`" + ` +npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>" +` + "`" + ` + +**For Codex:** +` + "`" + ` +npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>" +` + "`" + ` + +### Critical: flag order + +The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order: + +` + "`" + ` +npx acpx@latest [global flags] <agent> exec "<prompt>" +` + "`" + ` + +**Correct:** +` + "`" + ` +npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug" +` + "`" + ` + +**Wrong (will fail):** +` + "`" + ` +npx acpx@latest claude --approve-all exec "fix the bug" +` + "`" + ` + +### Writing good prompts + +When constructing the prompt for the coding agent: +- Be specific and detailed about what to build or fix +- Include file names, function signatures, and expected behavior +- Mention any constraints (language, framework, style) +- If the user gave you a short request, expand it into a clear, actionable prompt for the agent + +### Step 4: Report results + +After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero. + +Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text. + +- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index f4ba9b1d..fb7ec4e9 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -11,6 +11,7 @@ import createPresentationsSkill from "./create-presentations/skill.js"; import appNavigationSkill from "./app-navigation/skill.js"; import browserControlSkill from "./browser-control/skill.js"; +import codeWithAgentsSkill from "./code-with-agents/skill.js"; import composioIntegrationSkill from "./composio-integration/skill.js"; import tracksSkill from "./tracks/skill.js"; import notifyUserSkill from "./notify-user/skill.js"; @@ -94,6 +95,12 @@ const definitions: SkillDefinition[] = [ summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.", content: appNavigationSkill, }, + { + id: "code-with-agents", + title: "Code with Agents", + summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.", + content: codeWithAgentsSkill, + }, { id: "tracks", title: "Tracks", 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 7dd06dd2..c57d4dfc 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -969,6 +969,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { const { promise, process: proc } = executeCommandAbortable(command, { cwd: workingDir, signal: ctx.signal, + onData: (chunk: string) => { + ctx.publish({ + runId: ctx.runId, + type: "tool-output-stream", + toolCallId: ctx.toolCallId, + toolName: "executeCommand", + output: chunk, + subflow: [], + }); + }, }); // Register process with abort registry for force-kill diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 11b15d90..005bb7e8 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -143,6 +143,7 @@ export function executeCommandAbortable( timeout?: number; maxBuffer?: number; signal?: AbortSignal; + onData?: (chunk: string) => void; } ): { promise: Promise<AbortableCommandResult>; process: ChildProcess } { // Check if already aborted before spawning @@ -177,16 +178,20 @@ export function executeCommandAbortable( // Collect output proc.stdout?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); const maxBuffer = options?.maxBuffer || 1024 * 1024; if (stdout.length < maxBuffer) { - stdout += chunk.toString(); + stdout += text; } + options?.onData?.(text); }); proc.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); const maxBuffer = options?.maxBuffer || 1024 * 1024; if (stderr.length < maxBuffer) { - stderr += chunk.toString(); + stderr += text; } + options?.onData?.(text); }); // Abort handler diff --git a/apps/x/packages/core/src/application/lib/exec-tool.ts b/apps/x/packages/core/src/application/lib/exec-tool.ts index 09983402..92e87fa6 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -1,4 +1,5 @@ import { ToolAttachment } from "@x/shared/dist/agent.js"; +import { RunEvent } from "@x/shared/dist/runs.js"; import { z } from "zod"; import { BuiltinTools } from "./builtin-tools.js"; import { executeTool } from "../../mcp/mcp.js"; @@ -9,8 +10,10 @@ import { IAbortRegistry } from "../../runs/abort-registry.js"; */ export interface ToolContext { runId: string; + toolCallId: string; signal: AbortSignal; abortRegistry: IAbortRegistry; + publish: (event: z.infer<typeof RunEvent>) => Promise<void>; } async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> { @@ -34,4 +37,4 @@ export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: return builtinTool.execute(input, ctx); } } -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index ea93c8a3..7bf7a13d 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -63,6 +63,13 @@ export const ToolResultEvent = BaseRunEvent.extend({ result: z.any(), }); +export const ToolOutputStreamEvent = BaseRunEvent.extend({ + type: z.literal("tool-output-stream"), + toolCallId: z.string(), + toolName: z.string(), + output: z.string(), +}); + export const AskHumanRequestEvent = BaseRunEvent.extend({ type: z.literal("ask-human-request"), toolCallId: z.string(), @@ -106,6 +113,7 @@ export const RunEvent = z.union([ MessageEvent, ToolInvocationEvent, ToolResultEvent, + ToolOutputStreamEvent, AskHumanRequestEvent, AskHumanResponseEvent, ToolPermissionRequestEvent, From d515c423eef3d0759509b1f73b7a136809c8ef1f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 19:11:00 +0530 Subject: [PATCH 046/143] show the terminal view --- apps/x/apps/renderer/src/App.tsx | 4 +-- .../renderer/src/components/chat-sidebar.tsx | 34 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b6e6add7..1f5cb153 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2111,7 +2111,7 @@ function App() { return next }) - if (event.toolCallId) { + if (event.toolCallId && event.toolName !== 'executeCommand') { setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) } @@ -4361,7 +4361,7 @@ function App() { state={toToolState(item.status)} /> <ToolContent> - {item.streamingOutput && item.status === 'running' ? ( + {item.streamingOutput ? ( <AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90"> <TerminalOutput raw={item.streamingOutput} /> </AutoScrollPre> diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 6fa295b1..a7551757 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -20,6 +20,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } 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' +import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' @@ -59,6 +60,31 @@ const streamdownComponents = { pre: MarkdownPreOverride } // into <br> so typed line breaks are preserved without requiring blank lines. const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] +function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { + const ref = useRef<HTMLPreElement>(null) + const stickToBottom = useRef(true) + + useEffect(() => { + const el = ref.current + if (el && stickToBottom.current) { + el.scrollTop = el.scrollHeight + } + }, [children]) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickToBottom.current = atBottom + }, []) + + return ( + <pre ref={ref} onScroll={handleScroll} className={className}> + {children} + </pre> + ) +} + /* ─── Billing error helpers ─── */ const BILLING_ERROR_PATTERNS = [ @@ -452,7 +478,13 @@ export function ChatSidebar({ > <ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} /> <ToolContent> - <ToolTabbedContent input={input} output={output} errorText={errorText} /> + {item.streamingOutput ? ( + <AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90"> + <TerminalOutput raw={item.streamingOutput} /> + </AutoScrollPre> + ) : ( + <ToolTabbedContent input={input} output={output} errorText={errorText} /> + )} </ToolContent> </Tool> ) From a48887da61fb0b67b4584c2013880ba8bde42e8f Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 6 May 2026 23:14:00 +0530 Subject: [PATCH 047/143] can set a work directory in assistant chats (#534) --- apps/x/apps/main/src/ipc.ts | 13 +++ .../components/chat-input-with-mentions.tsx | 109 ++++++++++++++++-- apps/x/packages/core/src/agents/runtime.ts | 35 ++++++ apps/x/packages/shared/src/ipc.ts | 10 ++ 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 682d46e6..64ff7f2d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -685,6 +685,19 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, + 'dialog:openDirectory': async (event, args) => { + const win = BrowserWindow.fromWebContents(event.sender); + const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir(); + const result = await dialog.showOpenDialog(win!, { + title: args.title ?? 'Choose work directory', + defaultPath, + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) { + return { path: null }; + } + return { path: result.filePaths[0] ?? null }; + }, // Knowledge version history handlers 'knowledge:history': async (_event, args) => { const commits = await versionHistory.getFileHistory(args.path); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index e1fb950f..9d552905 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -10,8 +10,10 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCog, Globe, Headphones, + ImagePlus, LoaderIcon, Mic, Plus, @@ -23,8 +25,10 @@ import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -169,6 +173,7 @@ function ChatInputInner({ const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [workDir, setWorkDir] = useState<string | null>(null) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -251,6 +256,55 @@ function ChatInputInner({ return () => window.removeEventListener('models-config-changed', handler) }, [loadModelConfig]) + // Load currently configured work directory + const loadWorkDir = useCallback(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' }) + const parsed = JSON.parse(result.data) + const value = typeof parsed?.path === 'string' ? parsed.path.trim() : '' + setWorkDir(value || null) + } catch { + setWorkDir(null) + } + }, []) + + useEffect(() => { + loadWorkDir() + }, [isActive, loadWorkDir]) + + const handleSetWorkDir = useCallback(async () => { + try { + const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', { + title: 'Choose work directory', + defaultPath: workDir ?? undefined, + }) + if (!chosen) return + await window.ipc.invoke('workspace:writeFile', { + path: 'config/workdir.json', + data: JSON.stringify({ path: chosen }, null, 2), + }) + setWorkDir(chosen) + toast.success(`Work directory set: ${chosen}`) + } catch (err) { + console.error('Failed to set work directory', err) + toast.error('Failed to set work directory') + } + }, [workDir]) + + const handleClearWorkDir = useCallback(async () => { + try { + await window.ipc.invoke('workspace:writeFile', { + path: 'config/workdir.json', + data: JSON.stringify({}, null, 2), + }) + setWorkDir(null) + toast.success('Work directory cleared') + } catch (err) { + console.error('Failed to clear work directory', err) + toast.error('Failed to clear work directory') + } + }, []) + // Check search tool availability (exa or signed-in via gateway) useEffect(() => { const checkSearch = async () => { @@ -484,14 +538,53 @@ function ChatInputInner({ /> </div> <div className="flex items-center gap-2 px-4 pb-3"> - <button - type="button" - onClick={() => fileInputRef.current?.click()} - className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - aria-label="Attach files" - > - <Plus className="h-4 w-4" /> - </button> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + aria-label="Add" + > + <Plus className="h-4 w-4" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="min-w-56"> + <DropdownMenuItem onSelect={() => fileInputRef.current?.click()}> + <ImagePlus className="size-4" /> + <span>Add files or photos</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}> + <FolderCog className="size-4" /> + <span>{workDir ? 'Change work directory' : 'Set work directory'}</span> + </DropdownMenuItem> + {workDir && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}> + <X className="size-4" /> + <span>Clear work directory</span> + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + {workDir && ( + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleSetWorkDir} + className="flex h-7 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + <FolderCog className="h-3.5 w-3.5" /> + <span className="truncate">{workDir.split('/').pop() || workDir}</span> + </button> + </TooltipTrigger> + <TooltipContent side="top"> + Work directory: {workDir} + </TooltipContent> + </Tooltip> + )} {searchAvailable && ( searchEnabled ? ( <button diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index a635b4e9..888474c1 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -35,6 +35,19 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent. import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); +const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json'); + +function loadUserWorkDir(): string | null { + try { + if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null; + const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as { path?: unknown }; + const value = typeof parsed.path === 'string' ? parsed.path.trim() : ''; + return value || null; + } catch { + return null; + } +} function loadAgentNotesContext(): string | null { const sections: string[] = []; @@ -1094,6 +1107,28 @@ export async function* streamAgent({ if (agentNotesContext) { instructionsWithDateTime += `\n\n${agentNotesContext}`; } + const userWorkDir = loadUserWorkDir(); + if (userWorkDir) { + loopLogger.log('injecting user work directory', userWorkDir); + instructionsWithDateTime += `\n\n# User Work Directory +The user has chosen the following directory as their current **work directory**: + +\`${userWorkDir}\` + +Treat this as the **default location** for file operations whenever the user refers to files generically: +- "list the files", "show me what's in here", "what's the latest report" — list or look in the work directory. +- "save this", "export it", "write that to a file" — write the output into the work directory unless the user names another location. +- "open the file I was just working on", "the doc from earlier" — assume the work directory first. + +Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first. + +**Exceptions — these ALWAYS take precedence over the work directory default:** +1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory. +2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request. +3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory. + +Do not announce the work directory unless it's relevant. Just use it.`; + } // Always inject a Middle Pane section so the LLM has a clear, up-to-date signal // that supersedes any earlier middle-pane mention in the conversation history. const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 9e62f3d9..d827c32e 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -483,6 +483,16 @@ const ipcSchemas = { req: z.object({ path: z.string() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), }, + // Native dialog channels + 'dialog:openDirectory': { + req: z.object({ + defaultPath: z.string().optional(), + title: z.string().optional(), + }), + res: z.object({ + path: z.string().nullable(), + }), + }, // Knowledge version history channels 'knowledge:history': { req: z.object({ path: RelPath }), From db6757514c15f9fe8510f7bb873abdef6edbcc6d Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 7 May 2026 18:00:20 +0530 Subject: [PATCH 048/143] =?UTF-8?q?feat:=20tracks=20=E2=80=94=20frontmatte?= =?UTF-8?q?r=20directives,=20sidebar=20UI,=20multi-trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recasts the old "track blocks" as "tracks" — directives stored in a note's frontmatter rather than inline YAML fences and HTML-comment target regions. The motivation is UX: the inline anatomy made notes feel like config, leaked into the editing surface, and competed with the writing flow. Frontmatter is invisible to the body editor, so moving directives there reclaims the body as just markdown the user wrote. The runtime agent now edits the note body freely via standard workspace tools rather than rewriting a constrained target region. Each track's instruction names an H2 section to own; the agent finds or creates that section, updates only its content, and self-heals position on subsequent runs. Triggers are now a unified array per track. cron / window / once / event in any combination, including multi-trigger setups (the flagship example: a priorities track that rebuilds at three day-windows and reacts to incoming gmail / calendar events). window is forgiving — fires once per day anywhere inside its band — so users opening the app late in the morning still get the morning run. The chip-in-editor is gone. Tracks are managed from a right-side sidebar opened by a Radio-icon button at the top-right of the editor toolbar. Cmd+K is no longer a Copilot entry point — search- only — pending a more intuitive invocation surface later. Today.md ships as the flagship demo of what tracks can do, with a versioned migration system so future template updates roll out cleanly to existing users (existing body preserved, old version backed up). Copilot is tuned to listen for any signal that the user wants something dynamic — not just the literal word "track". Strong phrasings get acted on directly; one-off questions about decaying information are answered first and then offered as a track. New or edited tracks run once by default so the user immediately sees content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- apps/x/TRACKS.md | 415 ++++++------ apps/x/apps/main/src/ipc.ts | 22 +- apps/x/apps/renderer/src/App.tsx | 25 +- .../src/components/background-agents-view.tsx | 2 +- .../src/components/editor-toolbar.tsx | 16 + .../src/components/frontmatter-properties.tsx | 10 +- .../src/components/markdown-editor.tsx | 50 +- .../renderer/src/components/search-dialog.tsx | 304 +++------ .../renderer/src/components/track-modal.tsx | 530 --------------- .../renderer/src/components/track-sidebar.tsx | 627 ++++++++++++++++++ .../renderer/src/extensions/track-block.tsx | 199 ------ .../renderer/src/extensions/track-target.tsx | 90 --- .../renderer/src/hooks/use-track-status.ts | 4 +- apps/x/apps/renderer/src/lib/frontmatter.ts | 115 +++- apps/x/apps/renderer/src/styles/editor.css | 154 +---- .../apps/renderer/src/styles/track-modal.css | 168 ++++- .../src/application/assistant/instructions.ts | 8 +- .../src/application/assistant/skills/index.ts | 2 +- .../assistant/skills/notify-user/skill.ts | 4 +- .../assistant/skills/tracks/skill.ts | 509 +++++++------- .../core/src/application/lib/builtin-tools.ts | 32 +- .../src/application/lib/parse-frontmatter.ts | 62 +- .../core/src/knowledge/ensure_daily_note.ts | 292 ++++---- .../packages/core/src/knowledge/track/bus.ts | 2 +- .../core/src/knowledge/track/events.ts | 17 +- .../core/src/knowledge/track/fileops.ts | 354 +++++----- .../core/src/knowledge/track/routing.ts | 16 +- .../core/src/knowledge/track/run-agent.ts | 87 +-- .../core/src/knowledge/track/runner.ts | 53 +- .../src/knowledge/track/schedule-utils.ts | 43 +- .../core/src/knowledge/track/scheduler.ts | 24 +- .../core/src/knowledge/track/types.ts | 9 +- apps/x/packages/shared/src/index.ts | 2 +- apps/x/packages/shared/src/ipc.ts | 18 +- .../shared/src/{track-block.ts => track.ts} | 51 +- 36 files changed, 2043 insertions(+), 2275 deletions(-) delete mode 100644 apps/x/apps/renderer/src/components/track-modal.tsx create mode 100644 apps/x/apps/renderer/src/components/track-sidebar.tsx delete mode 100644 apps/x/apps/renderer/src/extensions/track-block.tsx delete mode 100644 apps/x/apps/renderer/src/extensions/track-target.tsx rename apps/x/packages/shared/src/{track-block.ts => track.ts} (61%) diff --git a/CLAUDE.md b/CLAUDE.md index 6bbcf22b..b10d5234 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,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` | +| Tracks — frontmatter directives that keep a note's body auto-updated (cron / window / once / event / multi-trigger), section-placement model, sidebar UI, 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/TRACKS.md b/apps/x/TRACKS.md index 3caf9e41..3d9662f2 100644 --- a/apps/x/TRACKS.md +++ b/apps/x/TRACKS.md @@ -1,24 +1,29 @@ -# Track Blocks +# Tracks -> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand. +> Frontmatter directives that keep a markdown note's body auto-updated — on a schedule, when a relevant event arrives, or on demand. -A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary. +A track is a single entry in a note's YAML frontmatter under the `track:` array. Each entry defines an instruction, optional triggers (cron / window / once / event — any mix), and (after the first run) some runtime state. When a trigger fires, a background agent edits the **note body** to satisfy the instruction. A note with no `track:` key is just a static note. -**Example** (a Chicago-time track refreshed hourly): +**Example** (a note that shows the current Chicago time, refreshed hourly): ~~~markdown -```track -trackId: chicago-time -instruction: Show the current time in Chicago, IL in 12-hour format. -active: true -schedule: - type: cron - expression: "0 * * * *" -``` +--- +track: + - id: chicago-time + instruction: | + Show the current time in Chicago, IL in 12-hour format. + active: true + triggers: + - type: cron + expression: "0 * * * *" + lastRunAt: "2026-05-07T15:00:01.234Z" + lastRunId: "..." + lastRunSummary: "Updated — 3:00 PM, Central Time." +--- -<!--track-target:chicago-time--> -2:30 PM, Central Time -<!--/track-target:chicago-time--> +# Chicago time + +3:00 PM, Central Time ~~~ ## Table of Contents @@ -27,279 +32,304 @@ schedule: 2. [Architecture at a Glance](#architecture-at-a-glance) 3. [Technical Flows](#technical-flows) 4. [Schema Reference](#schema-reference) -5. [Prompts Catalog](#prompts-catalog) -6. [File Map](#file-map) -7. [Known Follow-ups](#known-follow-ups) +5. [Section Placement](#section-placement) +6. [Daily-Note Template & Migrations](#daily-note-template--migrations) +7. [Renderer UI](#renderer-ui) +8. [Prompts Catalog](#prompts-catalog) +9. [File Map](#file-map) --- ## Product Overview -### Trigger types +### Triggers -A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track. +A track has zero or more triggers under a single `triggers:` array. Each trigger is one of four types and can be mixed freely: -| Trigger | When it fires | How to express it | +| Type | When it fires | Shape | |---|---|---| -| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset | -| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` | -| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` | -| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` | -| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` | +| **`cron`** | At exact cron times | `{ type: cron, expression: "0 * * * *" }` | +| **`window`** | Once per day, anywhere inside a time-of-day band | `{ type: window, startTime: "09:00", endTime: "12:00" }` | +| **`once`** | Once at a future time, then never | `{ type: once, runAt: "2026-04-14T09:00:00" }` | +| **`event`** | When a matching event arrives (e.g. new Gmail thread) | `{ type: event, matchCriteria: "Emails about Q3 planning" }` | -Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals. +A track with no `triggers` (or an empty array) is **manual-only** — fires only when the user clicks Run in the sidebar. + +`cron` and `once` enforce a 2-minute grace window — if the scheduled time passed more than 2 minutes ago (e.g. the app was offline), the run is skipped, not replayed. `window` is forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, it fires the moment the app is open. The day's cycle is anchored at `startTime`. + +A single track can carry multiple triggers. The flagship example is in Today.md's `priorities` track: three `window` entries (morning / midday / post-lunch) plus two `event` entries (gmail / calendar) — five triggers total, giving a baseline rebuild three times per day plus reactive updates on incoming signals. ### Creating a track -Three paths, all produce identical on-disk YAML: +Two paths, both producing identical on-disk YAML: -1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension. -2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`. -3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name. +1. **Hand-written** — type the entry directly into a note's frontmatter under `track:`. The scheduler picks it up on its next 15-second tick. +2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond the literal word "track" (see "Prompts Catalog → Copilot trigger paragraph" for the signal taxonomy); it loads the `tracks` skill, edits the note's frontmatter via `workspace-edit`, then **runs the track once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. -### Viewing and managing a track +There is no inline-block creation flow anymore. The Cmd+K palette is search-only and does not invoke Copilot. -The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running. +### Viewing and managing tracks -Clicking the chip opens the **track modal**, where everything happens: +The editor has a Radio-icon button in the top toolbar (right side) that opens the **Track Sidebar** for the current note. The sidebar: -- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`). -- **Tabs** — *What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata). -- **Advanced** — expandable raw-YAML editor for power users. -- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region. -- **Footer** — *Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately). +- **List view** — one row per track in the note's frontmatter. Title is the track's `id`; subtitle is the trigger summary plus a `Paused ·` prefix when applicable, plus the instruction's first line as a tertiary line. A Play button on the right runs that track. +- **Detail view** (click a row) — back arrow + tabs (*What* / *Schedule* / *Events* / *Details*), an advanced raw-YAML editor, danger-zone delete, and a footer with "Edit with Copilot" + "Run now". +- **Status hook** — `useTrackStatus` subscribes to `tracks:events` IPC; rows show a spinner whenever a track is running, regardless of hover state. -Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`. +Every mutation in the sidebar goes through IPC to the backend — the renderer never writes the file itself. This avoids races with the scheduler/runner writing runtime fields like `lastRunAt`. -### What Copilot can do +### What the runtime agent does -- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`). -- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event. -- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`. -- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill. +When a trigger fires, a background agent ("track-run") receives a short message: +- The track's `id`, the workspace-relative path to the note, and a localized timestamp. +- The instruction. +- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). -### After a run +The agent's system prompt tells it to: +1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). +2. Find or create the H2 section the instruction names (placement model below). +3. Update only that section's content. Never modify YAML frontmatter — that's owned by the user and the runtime. +4. After writing, re-check its section's position; cut-and-paste only its own block if it's misplaced (handles the cold-start firing-order problem). +5. End with a one-line summary stored as `lastRunSummary`. -- The **target region** (between `<!--track-target:ID-->` markers) is rewritten by the track-run agent using the `update-track-content` tool. -- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML. -- The chip pulses while running, then displays the latest `lastRunAt`. -- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook. +The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP) — there's no special "track-content" tool anymore; tracks just ship general edits. --- ## Architecture at a Glance ``` -Editor chip (display-only) ──click──► TrackModal (React) - │ - ├──► IPC: track:get / update / - │ replaceYaml / delete / run - │ +Editor toolbar Radio button ─click──► TrackSidebar (React) + │ + ├──► IPC: track:get / update / + │ replaceYaml / delete / run + │ Backend (main process) ├─ Scheduler loop (15 s) ──┐ - ├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent - └─ Copilot tool run-track-block ──┘ │ - ▼ - update-track-content tool - │ - ▼ - target region rewritten on disk + ├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent + └─ Builtin tool run-track ─┘ │ + ▼ + workspace-readFile / -edit + │ + ▼ + body region rewritten on disk + frontmatter lastRun* patched ``` -**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields. +**Single-writer invariant** — the renderer is never a file writer for the `track:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrack`, `replaceTrackYaml`, `deleteTrack`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `track:` key (and `buildFrontmatter` splices it back from the original raw on save), so the FrontmatterProperties UI can't accidentally mangle it. -**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context. +**Event contract** — `window.dispatchEvent(CustomEvent('rowboat:open-track-sidebar', { detail: { filePath } }))` is the sole entry point from editor toolbar → sidebar. `rowboat:open-copilot-edit-track` opens the Copilot sidebar with the note attached. --- ## Technical Flows -### 4.1 Scheduling (cron / window / once) +### Scheduling (cron / window / once) -- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`). -- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`. -- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed. -- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates). -- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`. +- **Module**: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`). +- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all tracks via `fetchAll(relPath)`. +- For each track with `active === true` and at least one timed trigger (`cron` / `window` / `once`), `find` the first due trigger via `isTriggerDue(t, lastRunAt)` (`schedule-utils.ts`). +- When due, fire `triggerTrackUpdate(track.id, relPath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates). +- **Grace window** — `cron` and `once` enforce a 2-minute grace; missed schedules are skipped, not replayed. `window` has no grace — anywhere inside the band counts. +- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a fire lands strictly after today's `startTime`, that window is done for the day. The strict comparison handles the boundary case (e.g. an 08:00–12:00 + a 12:00–15:00 window each get to fire even when the morning fire happens exactly at 12:00:00). +- **Startup** — `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`. -### 4.2 Event pipeline +### Event pipeline **Producers** — any data source that should feed tracks emits events: - -- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`. -- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`. +- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`. +- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`. **Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO. **Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event: - 1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive). -2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`. -3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below). -4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. -5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/<id>.json`, unlink from `pending/`. +2. `listAllTracks()` scans every `.md` under `knowledge/`. Only tracks with at least one `event`-type trigger appear in the routing list; their `eventMatchCriteria` is the joined `matchCriteria` from all event triggers (`'; '`-separated). +3. `findCandidates(event, allTracks)` runs Pass 1 LLM routing (below). +4. For each candidate, `triggerTrackUpdate(id, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. +5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then move to `events/done/<id>.json`. -**Pass 1 routing** (`routing.ts:73+ findCandidates`): - -- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly. +**Pass 1 routing** (`routing.ts`): +- **Short-circuit** — if `event.targetTrackId` + `event.targetFilePath` are set (manual re-run events), skip the LLM and return that track directly. - Filter to `active && instruction && eventMatchCriteria` tracks. - Batches of `BATCH_SIZE = 20`. -- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file. -- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config. +- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `id` is only unique per file. -**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region. +**Pass 2 decision** happens inside the track-run agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body. -### 4.3 Run flow (`triggerTrackUpdate`) +### Run flow (`triggerTrackUpdate`) Module: `packages/core/src/knowledge/track/runner.ts`. -1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. -2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`. -3. **Create agent run** — `createRun({ agentId: 'track-run' })`. -4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set. -5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). -6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive. -7. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. -8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`. -9. **Store `lastRunSummary`** via `updateTrackBlock`. -10. **Emit `track_run_complete`** with `summary` or `error`. -11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. +1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${id}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. +2. **Fetch track** via `fetchAll(filePath)`, locate by `id`. +3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff. +4. **Create agent run** — `createRun({ agentId: 'track-run' })`. +5. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger; for `once` tracks the "done" marker is already set. +6. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). +7. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly. +8. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. +9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`. +10. **Patch `lastRunSummary`** via `updateTrack(filePath, id, { lastRunSummary })`. +11. **Emit `track_run_complete`** with `summary` or `error`. +12. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`. -### 4.4 IPC surface +### IPC surface | Channel | Caller → handler | Purpose | |---|---|---| -| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` | -| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` | -| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML | -| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML | -| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region | -| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook | +| `track:run` | Renderer (sidebar Run button) | Fires `triggerTrackUpdate(..., 'manual')` | +| `track:get` | Sidebar on detail open | Returns fresh per-track YAML from disk via `fetchYaml(filePath, id)` | +| `track:update` | Sidebar toggle / partial edits | `updateTrack` merges a partial into the on-disk entry | +| `track:replaceYaml` | Sidebar advanced raw-YAML save | `replaceTrackYaml` validates + writes the full entry | +| `track:delete` | Sidebar danger-zone confirm | `deleteTrack` removes the entry from the `track:` array | +| `track:setNoteActive` | Background-agents view toggle | Flips `active` on every track in a note | +| `track:listNotes` | Background-agents view load | Lists all notes that contain at least one track, with summary fields | +| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to `useTrackStatus` | Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`. -### 4.5 Renderer integration +### Concurrency & FIFO guarantees -- **Chip** — `apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save. -- **Modal** — `apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called. -- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state. -- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file. - -### 4.6 Copilot skill integration - -- **Skill content** — `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called. -- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync. -- **Skill registration** — `packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array). -- **Loading trigger** — `packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests. -- **Builtin tools** — `packages/core/src/application/lib/builtin-tools.ts`: - - `update-track-content` — low-level: rewrite the target region between `<!--track-target:ID-->` markers. Used mainly by the track-run agent. - - `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`). - -### 4.7 Concurrency & FIFO guarantees - -- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC. -- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file. -- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too. +- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`. +- **Backend is single writer for `track:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `track:` byte-for-byte across saves. +- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file. +- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially. - **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point. --- ## Schema Reference -All canonical schemas live in `packages/shared/src/track-block.ts`: +All canonical schemas live in `packages/shared/src/track.ts`: -- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`. -- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`. +- `TrackSchema` — a single entry in the frontmatter `track:` array. Fields: `id` (kebab-case, unique within the note), `instruction`, `active` (default true), `triggers?`, `model?`, `provider?`, `icon?`. **Runtime-managed (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`. +- `TriggerSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' | 'event' }`. Window has just `startTime` + `endTime` (no `cron` field — the cycle is anchored at `startTime`). - `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`. - `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`. -Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth. +The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(TrackSchema))` — so editing `TrackSchema` propagates to the skill on the next build. + +--- + +## Section Placement + +Tracks no longer have formal target regions. Each instruction names a section by H2 heading (e.g. *"in a section titled 'Overview' at the top"*) and the agent finds or creates that section. + +The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/track/run-agent.ts`): + +- Sections are **H2 headings** (`## Section Name`). Match by exact heading text. +- **Existing**: replace its content (everything between that heading and the next H2 — or end of file). Heading itself stays. +- **Missing**: create it. The placement hint determines location: + - "at the top" → just below the H1 title. + - "after X" → immediately after section X. + - no hint → append. +- **Self-heal**: after writing, the agent re-checks its section's position. If misplaced (the cold-start case where empty notes get sections in firing order rather than reading order), the agent moves only its **own** H2 block — never reorders other tracks' sections. +- **Boundaries**: never modify another track's section content; never duplicate; never touch frontmatter; if the user renamed the heading, recreate per the placement hint. + +This keeps tracks loosely coupled: each one stakes out a section by name, and the rest of the body is entirely the user's. + +--- + +## Daily-Note Template & Migrations + +`Today.md` is the canonical demo of what tracks can do. It ships with six tracks (overview/photo combined into one, calendar, emails, what-you-missed, priorities) showing pure-cron, pure-event, multi-window, and multi-trigger configurations. + +**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: + +- File missing → fresh write at canonical version. +- File at-or-above canonical → no-op. +- File below canonical → rename existing to `Today.md.bkp.<ISO-stamp>` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template with the body byte-preserved via `splitFrontmatter` from `application/lib/parse-frontmatter.ts`. + +Any change to the canonical TRACKS list, instructions, default body, or trigger config should bump the constant. Existing users will get the new template on next launch with their body sections preserved; their `lastRunAt` and any custom additions to the tracks list are dropped (the .bkp file is the recovery path). + +--- + +## Renderer UI + +The chip-in-editor model is gone. Replacements: + +- **Toolbar button** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon ghost button at the top-right of the editor toolbar. `markdown-editor.tsx` passes `onOpenTracks` (only when a `notePath` is available) which dispatches `rowboat:open-track-sidebar` with `{ filePath }`. +- **Sidebar** — `apps/renderer/src/components/track-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-track-sidebar`; on open, calls `workspace:readFile` and parses tracks from the frontmatter on the renderer side (uses the same `TrackSchema` from `@x/shared`). All mutations go through IPC. + - Constant top header: Radio icon, "Tracks" title, note name subtitle, X close. Uses the `bg-sidebar` design tokens to match the app's left sidebar. + - List view: one row per track. Title is `id`; subtitle is the trigger summary (with `Paused ·` prefix); third line is the instruction's first line, truncated. Run button always visible while running, otherwise fades in on hover. + - Detail view: back arrow + track id; status row (trigger summary + Active/Paused toggle); tabs (`What` / `Schedule` / `Events` / `Details`); advanced raw-YAML editor; danger-zone delete; footer (Edit with Copilot + Run now). +- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` IPC and maintains a `Map<"${id}:${filePath}", RunState>` keyed by composite key. +- **Edit-with-Copilot flow** — sidebar dispatches `rowboat:open-copilot-edit-track` (App.tsx listener handles it via `submitFromPalette`). +- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` adds `STRUCTURED_KEYS = new Set(['track'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `track:` block back from `preserveRaw` on save. This means the property panel can edit `tags` / `status` / etc. without ever clobbering the tracks frontmatter. --- ## Prompts Catalog -Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`). +Every LLM-facing prompt in the feature, with file pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app. ### 1. Routing system prompt (Pass 1 classifier) -- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them. -- **File**: `packages/core/src/knowledge/track/routing.ts:22–37` (`ROUTING_SYSTEM_PROMPT`). -- **Inputs**: none interpolated — constant system prompt. +- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; the run-agent does Pass 2. +- **File**: `packages/core/src/knowledge/track/routing.ts` (`ROUTING_SYSTEM_PROMPT`). - **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`. -- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`. +- **Invoked by**: `findCandidates()` per batch of 20 tracks via `generateObject({ model, system, prompt, schema })`. ### 2. Routing user prompt template -- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt. -- **File**: `packages/core/src/knowledge/track/routing.ts:51–66` (`buildRoutingPrompt`). -- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`). -- **Output**: plain text, two sections — `## Event` and `## Track Blocks`. -- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below). +- **Purpose**: formats the event and the current batch of tracks into the user message for Pass 1. +- **File**: `packages/core/src/knowledge/track/routing.ts` (`buildRoutingPrompt`). +- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `matchCriteria` — joined from all event triggers, `'; '`-separated). +- **Output**: plain text, two sections — `## Event` and `## Tracks`. ### 3. Track-run agent instructions -- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path. -- **File**: `packages/core/src/knowledge/track/run-agent.ts:6–50` (`TRACK_RUN_INSTRUCTIONS`). -- **Inputs**: `${WorkDir}` template literal (substituted at module load). +- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the section-placement contract (find/create/self-heal), points at the knowledge graph, and prescribes general `workspace-readFile` / `workspace-edit` as the write path. +- **File**: `packages/core/src/knowledge/track/run-agent.ts` (`TRACK_RUN_INSTRUCTIONS`). +- **Inputs**: `${WorkDir}` template literal substituted at module load. - **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`. -- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`. +- **Invoked by**: `buildTrackRunAgent()`, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`. ### 4. Track-run agent message (`buildMessage`) -- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`. -- **File**: `packages/core/src/knowledge/track/runner.ts:23–62`. -- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`. -- **Output**: free-form — the agent decides whether to call `update-track-content`. +- **Purpose**: the user message seeded into each track-run. +- **File**: `packages/core/src/knowledge/track/runner.ts` (`buildMessage`). +- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `track.id`, `track.instruction`, all event triggers' `matchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. +- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run). -Three branches: - -- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills. +Three branches by `trigger`: +- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-track` tool uses this path for both plain refreshes and context-biased backfills. - **`timed`** — same as `manual`. Called by the scheduler with no `context`. -- **`event`** — adds a **Pass 2 decision block** (lines 45–56). Quoted verbatim: - - > **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below) - > - > **Event match criteria for this track:** … - > - > **Event payload:** … - > - > **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track. +- **`event`** — adds a Pass 2 decision block listing all event triggers' `matchCriteria` (numbered if multiple) and the event payload, with the directive to skip the edit if the event isn't truly relevant. ### 5. Tracks skill (Copilot-facing) -- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context. -- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant. -- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically. +- **Purpose**: teaches Copilot the frontmatter `track:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, user-facing language (call them "tracks"; surface the **Track sidebar** by name), the auto-run-once-on-create/edit default, schema, triggers, multi-trigger combos, YAML-safety rules, insertion workflow, and the `run-track` tool with `context` backfills. +- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts`. Exported `skill` constant. +- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(TrackSchema))` is interpolated into the "Canonical Schema" section. Edits to `TrackSchema` propagate automatically. - **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires. -- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`. -- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template. +- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`. ### 6. Copilot trigger paragraph -- **Purpose**: tells Copilot *when* to load the `tracks` skill. -- **File**: `packages/core/src/application/assistant/instructions.ts:73`. -- **Inputs**: none; static prose. -- **Output**: part of the baseline Copilot system prompt. -- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh. +- **Purpose**: tells Copilot *when* to load the `tracks` skill, and frames how aggressively to act once loaded. +- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Tracks (Auto-Updating Notes)" paragraph). +- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…"). +- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up. +- **Anti-signals (do NOT track)**: definitional questions, one-off lookups, manual document editing. -### 7. `run-track-block` tool — `context` parameter description +### 7. `run-track` tool — `context` parameter description -- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema. -- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt). -- **Inputs**: free-form string from Copilot. +- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. +- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-track` tool definition). +- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), `id`, optional `context`. - **Output**: flows into `triggerTrackUpdate(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message. -- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. +- **Key use case**: backfill a newly-created event-driven track so its section isn't empty on day 1. ### 8. Calendar sync digest (event payload template) - **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`. -- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126. -- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync. -- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars. -- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look. +- **File**: `packages/core/src/knowledge/sync_calendar.ts` (`summarizeCalendarSync`, wrapped by `publishCalendarSyncEvent()`). +- **Output**: markdown with a counts header, `## Changed events` (per-event block: title, ID, time, organizer, location, attendees, truncated description), `## Deleted event IDs`. Capped at ~50 events; descriptions truncated to 500 chars. +- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. --- @@ -307,37 +337,30 @@ Three branches: | Purpose | File | |---|---| -| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` | +| Zod schemas (track, triggers, events, Pass1) | `packages/shared/src/track.ts` | | IPC channel schemas | `packages/shared/src/ipc.ts` | | IPC handlers (main process) | `apps/main/src/ipc.ts` | -| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` | +| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` | +| File operations (fetchAll / fetch / updateTrack / replaceTrackYaml / deleteTrack / readNoteBody / list / setActive) | `packages/core/src/knowledge/track/fileops.ts` | | Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` | -| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` | +| Trigger due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` | | Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` | | Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` | | Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` | | Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` | | Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` | | Track state type | `packages/core/src/knowledge/track/types.ts` | +| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | | Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` | | Skill registration | `packages/core/src/application/assistant/skills/index.ts` | | Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | -| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` | -| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` | -| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` | +| `run-track` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` | +| Editor toolbar (Radio button → sidebar) | `apps/renderer/src/components/editor-toolbar.tsx` | +| Track sidebar (list + detail view) | `apps/renderer/src/components/track-sidebar.tsx` | | Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` | -| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` | -| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` | +| Renderer frontmatter helper (preserves `track:`) | `apps/renderer/src/lib/frontmatter.ts` | +| App-level listeners (sidebar open + Copilot edit) | `apps/renderer/src/App.tsx` | +| CSS (sidebar styles, legacy filename) | `apps/renderer/src/styles/track-modal.css`, `apps/renderer/src/styles/editor.css` | | Main process startup (schedulers & processors) | `apps/main/src/main.ts` | - ---- - -## Known Follow-ups - -- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields. - - **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save. - - **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor). - -- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow. diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 64ff7f2d..3d888ee9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -54,9 +54,9 @@ import { fetchYaml, listNotesWithTracks, setNoteTracksActive, - updateTrackBlock, - replaceTrackBlockYaml, - deleteTrackBlock, + updateTrack, + replaceTrackYaml, + deleteTrack, } from '@x/core/dist/knowledge/track/fileops.js'; import { browserIpcHandlers } from './browser/ipc.js'; @@ -815,12 +815,12 @@ export function setupIpcHandlers() { }, // Track handlers 'track:run': async (_event, args) => { - const result = await triggerTrackUpdate(args.trackId, args.filePath); + const result = await triggerTrackUpdate(args.id, args.filePath); return { success: !result.error, summary: result.summary ?? undefined, error: result.error }; }, 'track:get': async (_event, args) => { try { - const yaml = await fetchYaml(args.filePath, args.trackId); + const yaml = await fetchYaml(args.filePath, args.id); if (yaml === null) return { success: false, error: 'Track not found' }; return { success: true, yaml }; } catch (err) { @@ -829,8 +829,8 @@ export function setupIpcHandlers() { }, 'track:update': async (_event, args) => { try { - await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>); - const yaml = await fetchYaml(args.filePath, args.trackId); + await updateTrack(args.filePath, args.id, args.updates as Record<string, unknown>); + const yaml = await fetchYaml(args.filePath, args.id); if (yaml === null) return { success: false, error: 'Track vanished after update' }; return { success: true, yaml }; } catch (err) { @@ -839,8 +839,8 @@ export function setupIpcHandlers() { }, 'track:replaceYaml': async (_event, args) => { try { - await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml); - const yaml = await fetchYaml(args.filePath, args.trackId); + await replaceTrackYaml(args.filePath, args.id, args.yaml); + const yaml = await fetchYaml(args.filePath, args.id); if (yaml === null) return { success: false, error: 'Track vanished after replace' }; return { success: true, yaml }; } catch (err) { @@ -849,7 +849,7 @@ export function setupIpcHandlers() { }, 'track:delete': async (_event, args) => { try { - await deleteTrackBlock(args.filePath, args.trackId); + await deleteTrack(args.filePath, args.id); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; @@ -858,7 +858,7 @@ export function setupIpcHandlers() { 'track:setNoteActive': async (_event, args) => { try { const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active); - if (!note) return { success: false, error: 'No track blocks found in note' }; + if (!note) return { success: false, error: 'No tracks found in note' }; return { success: true, note }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1f5cb153..ec54e95b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -59,8 +59,8 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' -import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' -import { TrackModal } from '@/components/track-modal' +import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' +import { TrackSidebar } from '@/components/track-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' @@ -351,20 +351,20 @@ const buildSuggestedTopicExplorePrompt = ({ 'Treat a clear confirmation from me as explicit approval to proceed.', `If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`, `If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`, - 'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.', + 'Add a track to the note (a `track:` entry in its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.', 'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.', ].join('\n') } const buildBackgroundAgentSetupPrompt = () => [ 'Help me set up a background agent.', - 'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.', + 'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.', 'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.', 'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.', 'Start with a short, plain-English explanation of what a background agent is.', 'Do not make the explanation too terse.', 'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.', - 'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.', + 'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.', 'In the first reply, tell me that you will create this in my Tasks folder by default.', 'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.', 'Then ask only what I want it to monitor or update and how often I want it to run.', @@ -874,7 +874,6 @@ function App() { // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload // queued across the new-chat-tab state flush before submit fires. const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map()) - const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null) const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null) const handleSubmitRecording = useCallback(() => { @@ -2933,8 +2932,7 @@ function App() { setPendingPaletteSubmit(null) }, [pendingPaletteSubmit]) - // Listener for track-block "Edit with Copilot" events - // (dispatched by apps/renderer/src/extensions/track-block.tsx) + // Listener for "Edit with Copilot" events from the track sidebar. useEffect(() => { const handler = (e: Event) => { const ev = e as CustomEvent<{ @@ -3539,16 +3537,11 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) - // Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode). - // If an editor tab is currently active, capture cursor context so Chat mode shows the - // note + line as a removable chip. + // Keyboard shortcut: Cmd+K / Ctrl+K opens the search palette (search-only). useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault() - const activeId = activeFileTabIdRef.current - const handle = activeId ? editorRefsByTabId.current.get(activeId) : null - setPaletteContext(handle?.getCursorContext() ?? null) setIsSearchOpen(true) } } @@ -5090,12 +5083,10 @@ function App() { onOpenChange={setIsSearchOpen} onSelectFile={navigateToFile} onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }} - initialContext={paletteContext} - onChatSubmit={submitFromPalette} /> </SidebarSectionProvider> <Toaster /> - <TrackModal /> + <TrackSidebar /> <OnboardingModal open={showOnboarding} onComplete={handleOnboardingComplete} diff --git a/apps/x/apps/renderer/src/components/background-agents-view.tsx b/apps/x/apps/renderer/src/components/background-agents-view.tsx index d09e4380..20ff0c8b 100644 --- a/apps/x/apps/renderer/src/components/background-agents-view.tsx +++ b/apps/x/apps/renderer/src/components/background-agents-view.tsx @@ -154,7 +154,7 @@ export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: Ba </Button> </div> <p className="mt-1 text-xs text-muted-foreground"> - Notes that contain track blocks. Toggle a note inactive to pause every background agent in it. + Notes that contain tracks. Toggle a note inactive to pause every background agent in it. </p> </div> <div className="flex-1 overflow-auto p-6"> diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index 72b1cb35..c87ff068 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -29,6 +29,7 @@ import { FileTextIcon, FileIcon, FileTypeIcon, + Radio, } from 'lucide-react' import { DropdownMenu, @@ -42,6 +43,7 @@ interface EditorToolbarProps { onSelectionHighlight?: (range: { from: number; to: number } | null) => void onImageUpload?: (file: File) => Promise<void> | void onExport?: (format: 'md' | 'pdf' | 'docx') => void + onOpenTracks?: () => void } export function EditorToolbar({ @@ -49,6 +51,7 @@ export function EditorToolbar({ onSelectionHighlight, onImageUpload, onExport, + onOpenTracks, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -385,6 +388,19 @@ export function EditorToolbar({ </DropdownMenu> </> )} + + {/* Tracks — pushed to far right */} + {onOpenTracks && ( + <Button + variant="ghost" + size="icon-sm" + onClick={onOpenTracks} + title="Tracks" + className="ml-auto" + > + <Radio className="size-4" /> + </Button> + )} </div> ) } diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx index 0ceb2c76..cc7aec0b 100644 --- a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] { return Object.entries(record).map(([key, value]) => ({ key, value })) } -function fieldsToRaw(fields: FieldEntry[]): string | null { +function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null { const record: Record<string, string | string[]> = {} for (const { key, value } of fields) { if (key.trim()) record[key.trim()] = value } - return buildFrontmatter(record) + return buildFrontmatter(record, preserveRaw) } export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { @@ -45,10 +45,12 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro }, [editingNewKey]) const commit = useCallback((updated: FieldEntry[]) => { - const newRaw = fieldsToRaw(updated) + // Use the latest raw seen as the preserve-source so structured keys + // (like `track:`) survive a round-trip through this UI. + const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current) lastCommittedRaw.current = newRaw onRawChange(newRaw) - }, [onRawChange]) + }, [onRawChange, raw]) // For scalar fields: update local state immediately, commit on blur const updateLocalValue = useCallback((index: number, newValue: string) => { diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 9c1634a3..7e34353d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -11,9 +11,7 @@ import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table' import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { TaskBlockExtension } from '@/extensions/task-block' -import { TrackBlockExtension } from '@/extensions/track-block' import { PromptBlockExtension } from '@/extensions/prompt-block' -import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target' import { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' import { IframeBlockExtension } from '@/extensions/iframe-block' @@ -48,36 +46,6 @@ function preprocessMarkdown(markdown: string): string { }) } -// Convert track-target open/close HTML comment markers into placeholder divs -// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom -// nodes. Content *between* the markers is left untouched — tiptap-markdown -// parses it naturally as whatever it is (paragraphs, lists, custom-block -// fences, etc.), all rendered live by the existing extension set. -// -// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag -// line until a blank line terminates it, and markdown inline rules (bold, -// italics, links) don't apply inside the block. Without surrounding blank -// lines, the line right after our placeholder div gets absorbed as HTML and -// its markdown is not parsed. -// -// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n` -// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks -// on save; a `\n?` regex on reload would only consume one of those two -// newlines, so every cycle would add a net newline on each side of every -// marker — causing tracks running on an open note to steadily inflate the -// file with blank lines around target regions. -function preprocessTrackTargets(md: string): string { - return md - .replace( - /\n*<!--track-target:([^\s>]+)-->\n*/g, - (_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`, - ) - .replace( - /\n*<!--\/track-target:([^\s>]+)-->\n*/g, - (_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`, - ) -} - // Post-process to clean up any zero-width spaces in the output function postprocessMarkdown(markdown: string): string { // Remove lines that contain only the zero-width space marker @@ -189,12 +157,6 @@ function blockToMarkdown(node: JsonNode): string { return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' case 'promptBlock': return '```prompt\n' + (node.attrs?.data as string || '') + '\n```' - case 'trackBlock': - return '```track\n' + (node.attrs?.data as string || '') + '\n```' - case 'trackTargetOpen': - return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->` - case 'trackTargetClose': - return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->` case 'imageBlock': return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' case 'embedBlock': @@ -697,10 +659,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro }), ImageUploadPlaceholderExtension, TaskBlockExtension, - TrackBlockExtension.configure({ notePath }), PromptBlockExtension.configure({ notePath }), - TrackTargetOpenExtension, - TrackTargetCloseExtension, ImageBlockExtension, EmbedBlockExtension, IframeBlockExtension, @@ -1100,9 +1059,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { isInternalUpdate.current = true - // Pre-process to preserve blank lines, then wrap track-target comment - // regions into placeholder divs so TrackTargetExtension can pick them up. - const preprocessed = preprocessMarkdown(preprocessTrackTargets(content)) + const preprocessed = preprocessMarkdown(content) // Treat tab-open content as baseline: do not add hydration to undo history. editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false @@ -1472,6 +1429,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} onExport={onExport} + onOpenTracks={notePath ? () => { + window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', { + detail: { filePath: notePath }, + })) + } : undefined} /> {(frontmatter !== undefined) && onFrontmatterChange && ( <FrontmatterProperties diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 66a37802..56f0875a 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import posthog from 'posthog-js' import * as analytics from '@/lib/analytics' -import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react' +import { FileTextIcon, MessageSquareIcon } from 'lucide-react' import { CommandDialog, CommandInput, @@ -22,13 +22,14 @@ interface SearchResult { } type SearchType = 'knowledge' | 'chat' -type Mode = 'chat' | 'search' function activeTabToTypes(section: ActiveSection): SearchType[] { if (section === 'knowledge') return ['knowledge'] - return ['chat'] // "tasks" tab maps to chat + return ['chat'] } +// Retained for any remaining programmatic Copilot entry points (background-agent +// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot. export type CommandPaletteContext = { path: string lineNumber: number @@ -43,12 +44,8 @@ export type CommandPaletteMention = { interface CommandPaletteProps { open: boolean onOpenChange: (open: boolean) => void - // Search mode onSelectFile: (path: string) => void onSelectRun: (runId: string) => void - // Chat mode - initialContext?: CommandPaletteContext | null - onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void } export function CommandPalette({ @@ -56,14 +53,8 @@ export function CommandPalette({ onOpenChange, onSelectFile, onSelectRun, - initialContext, - onChatSubmit, }: CommandPaletteProps) { const { activeSection } = useSidebarSection() - const [mode, setMode] = useState<Mode>('chat') - const [chatInput, setChatInput] = useState('') - const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null) - const chatInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null) const [query, setQuery] = useState('') @@ -74,45 +65,23 @@ export function CommandPalette({ ) const debouncedQuery = useDebounce(query, 250) - // On open: always reset to Chat mode (per spec — no mode persistence), sync context chip - // and reset search filters. + // Sync filters and clear query when the dialog opens. useEffect(() => { if (open) { - setMode('chat') - setChatInput('') - setContextChip(initialContext ?? null) + setQuery('') setActiveTypes(new Set(activeTabToTypes(activeSection))) } - }, [open, activeSection, initialContext]) + }, [open, activeSection]) - // Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't - // swallow it. Only fires while the dialog is open. useEffect(() => { if (!open) return - const handler = (e: KeyboardEvent) => { - if (e.key !== 'Tab') return - if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return - e.preventDefault() - e.stopPropagation() - setMode(prev => (prev === 'chat' ? 'search' : 'chat')) - } - document.addEventListener('keydown', handler, true) - return () => document.removeEventListener('keydown', handler, true) + searchInputRef.current?.focus() }, [open]) - // Refocus the appropriate input on mode change so the user can start typing immediately. - useEffect(() => { - if (!open) return - const target = mode === 'chat' ? chatInputRef : searchInputRef - target.current?.focus() - }, [open, mode]) - const toggleType = useCallback((type: SearchType) => { setActiveTypes(new Set([type])) }, []) - // Search query effect (only meaningful while in search mode, but the debounce keeps running - // harmlessly otherwise — empty query skips the IPC call below). useEffect(() => { if (!debouncedQuery.trim()) { setResults([]) @@ -133,25 +102,19 @@ export function CommandPalette({ }) .catch((err) => { console.error('Search failed:', err) - if (!cancelled) { - setResults([]) - } + if (!cancelled) setResults([]) }) .finally(() => { - if (!cancelled) { - setIsSearching(false) - } + if (!cancelled) setIsSearching(false) }) return () => { cancelled = true } }, [debouncedQuery, activeTypes]) - // Reset transient state on close. useEffect(() => { if (!open) { setQuery('') setResults([]) - setChatInput('') } }, [open]) @@ -164,20 +127,6 @@ export function CommandPalette({ } }, [onOpenChange, onSelectFile, onSelectRun]) - const submitChat = useCallback(() => { - const text = chatInput.trim() - if (!text && !contextChip) return - const mention: CommandPaletteMention | null = contextChip - ? { - path: contextChip.path, - displayName: deriveDisplayName(contextChip.path), - lineNumber: contextChip.lineNumber, - } - : null - onChatSubmit(text, mention) - onOpenChange(false) - }, [chatInput, contextChip, onChatSubmit, onOpenChange]) - const knowledgeResults = results.filter(r => r.type === 'knowledge') const chatResults = results.filter(r => r.type === 'chat') @@ -185,178 +134,77 @@ export function CommandPalette({ <CommandDialog open={open} onOpenChange={onOpenChange} - title={mode === 'chat' ? 'Chat with copilot' : 'Search'} - description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'} + title="Search" + description="Search across knowledge and chats" showCloseButton={false} className="top-[20%] translate-y-0" > - {/* Mode strip */} + <CommandInput + ref={searchInputRef} + placeholder="Search..." + value={query} + onValueChange={setQuery} + /> <div className="flex items-center gap-1.5 px-3 py-2 border-b"> - <ModeButton - active={mode === 'chat'} - onClick={() => setMode('chat')} - icon={<MessageSquareIcon className="size-3" />} - label="Chat" - /> - <ModeButton - active={mode === 'search'} - onClick={() => setMode('search')} + <FilterToggle + active={activeTypes.has('knowledge')} + onClick={() => toggleType('knowledge')} icon={<FileTextIcon className="size-3" />} - label="Search" + label="Knowledge" + /> + <FilterToggle + active={activeTypes.has('chat')} + onClick={() => toggleType('chat')} + icon={<MessageSquareIcon className="size-3" />} + label="Chats" /> - <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span> </div> - - {mode === 'chat' ? ( - <div className="flex flex-col"> - <input - ref={chatInputRef} - type="text" - value={chatInput} - onChange={(e) => setChatInput(e.target.value)} - onKeyDown={(e) => { - // cmdk's Command component intercepts Enter for item selection — stop it - // before bubbling so we control the chat submit ourselves. - if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { - e.preventDefault() - e.stopPropagation() - submitChat() - } - }} - placeholder="Ask copilot anything…" - autoFocus - className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground" - /> - {contextChip && ( - <div className="flex items-center gap-2 px-3 pb-3"> - <span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs"> - <FileTextIcon className="size-3 shrink-0 text-muted-foreground" /> - <span className="font-medium">{deriveDisplayName(contextChip.path)}</span> - <span className="text-muted-foreground">· Line {contextChip.lineNumber}</span> - <button - type="button" - onClick={() => setContextChip(null)} - aria-label="Remove context" - className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground" - > - <XIcon className="size-3" /> - </button> - </span> - <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span> - </div> - )} - {!contextChip && ( - <div className="flex items-center px-3 pb-3"> - <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span> - </div> - )} - </div> - ) : ( - <> - <CommandInput - ref={searchInputRef} - placeholder="Search..." - value={query} - onValueChange={setQuery} - /> - <div className="flex items-center gap-1.5 px-3 py-2 border-b"> - <FilterToggle - active={activeTypes.has('knowledge')} - onClick={() => toggleType('knowledge')} - icon={<FileTextIcon className="size-3" />} - label="Knowledge" - /> - <FilterToggle - active={activeTypes.has('chat')} - onClick={() => toggleType('chat')} - icon={<MessageSquareIcon className="size-3" />} - label="Chats" - /> - </div> - <CommandList> - {!query.trim() && ( - <CommandEmpty>Type to search...</CommandEmpty> - )} - {query.trim() && !isSearching && results.length === 0 && ( - <CommandEmpty>No results found.</CommandEmpty> - )} - {knowledgeResults.length > 0 && ( - <CommandGroup heading="Knowledge"> - {knowledgeResults.map((result) => ( - <CommandItem - key={`knowledge-${result.path}`} - value={`knowledge-${result.title}-${result.path}`} - onSelect={() => handleSelect(result)} - > - <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> - <div className="flex flex-col gap-0.5 min-w-0"> - <span className="truncate font-medium">{result.title}</span> - <span className="truncate text-xs text-muted-foreground">{result.preview}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - )} - {chatResults.length > 0 && ( - <CommandGroup heading="Chats"> - {chatResults.map((result) => ( - <CommandItem - key={`chat-${result.path}`} - value={`chat-${result.title}-${result.path}`} - onSelect={() => handleSelect(result)} - > - <MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" /> - <div className="flex flex-col gap-0.5 min-w-0"> - <span className="truncate font-medium">{result.title}</span> - <span className="truncate text-xs text-muted-foreground">{result.preview}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - )} - </CommandList> - </> - )} + <CommandList> + {!query.trim() && ( + <CommandEmpty>Type to search...</CommandEmpty> + )} + {query.trim() && !isSearching && results.length === 0 && ( + <CommandEmpty>No results found.</CommandEmpty> + )} + {knowledgeResults.length > 0 && ( + <CommandGroup heading="Knowledge"> + {knowledgeResults.map((result) => ( + <CommandItem + key={`knowledge-${result.path}`} + value={`knowledge-${result.title}-${result.path}`} + onSelect={() => handleSelect(result)} + > + <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> + <div className="flex flex-col gap-0.5 min-w-0"> + <span className="truncate font-medium">{result.title}</span> + <span className="truncate text-xs text-muted-foreground">{result.preview}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + )} + {chatResults.length > 0 && ( + <CommandGroup heading="Chats"> + {chatResults.map((result) => ( + <CommandItem + key={`chat-${result.path}`} + value={`chat-${result.title}-${result.path}`} + onSelect={() => handleSelect(result)} + > + <MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" /> + <div className="flex flex-col gap-0.5 min-w-0"> + <span className="truncate font-medium">{result.title}</span> + <span className="truncate text-xs text-muted-foreground">{result.preview}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> </CommandDialog> ) } -// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette. -export const SearchDialog = CommandPalette - -function deriveDisplayName(path: string): string { - const base = path.split('/').pop() ?? path - return base.replace(/\.md$/, '') -} - -function ModeButton({ - active, - onClick, - icon, - label, -}: { - active: boolean - onClick: () => void - icon: React.ReactNode - label: string -}) { - return ( - <button - type="button" - onClick={onClick} - className={cn( - "flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors", - active - ? "bg-accent text-accent-foreground" - : "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground" - )} - > - {icon} - {label} - </button> - ) -} - function FilterToggle({ active, onClick, @@ -370,17 +218,19 @@ function FilterToggle({ }) { return ( <button - type="button" onClick={onClick} className={cn( - "flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors", + 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors', active - ? "bg-accent text-accent-foreground" - : "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground" + ? 'bg-accent text-accent-foreground' + : 'text-muted-foreground hover:text-foreground hover:bg-accent/50', )} > {icon} - {label} + <span>{label}</span> </button> ) } + +// Back-compat export: thin alias to CommandPalette. +export const SearchDialog = CommandPalette diff --git a/apps/x/apps/renderer/src/components/track-modal.tsx b/apps/x/apps/renderer/src/components/track-modal.tsx deleted file mode 100644 index a4c0b512..00000000 --- a/apps/x/apps/renderer/src/components/track-modal.tsx +++ /dev/null @@ -1,530 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { z } from 'zod' -import '@/styles/track-modal.css' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Switch } from '@/components/ui/switch' -import { Textarea } from '@/components/ui/textarea' -import { - Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap, - Trash2, ChevronDown, ChevronUp, -} from 'lucide-react' -import { parse as parseYaml } from 'yaml' -import { Streamdown } from 'streamdown' -import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js' -import { useTrackStatus } from '@/hooks/use-track-status' -import type { OpenTrackModalDetail } from '@/extensions/track-block' - -function formatDateTime(iso: string): string { - const d = new Date(iso) - return d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }) -} - -// --------------------------------------------------------------------------- -// Schedule helpers -// --------------------------------------------------------------------------- - -const CRON_PHRASES: Record<string, string> = { - '* * * * *': 'Every minute', - '*/5 * * * *': 'Every 5 minutes', - '*/15 * * * *': 'Every 15 minutes', - '*/30 * * * *': 'Every 30 minutes', - '0 * * * *': 'Hourly', - '0 */2 * * *': 'Every 2 hours', - '0 */6 * * *': 'Every 6 hours', - '0 */12 * * *': 'Every 12 hours', - '0 0 * * *': 'Daily at midnight', - '0 8 * * *': 'Daily at 8 AM', - '0 9 * * *': 'Daily at 9 AM', - '0 12 * * *': 'Daily at noon', - '0 18 * * *': 'Daily at 6 PM', - '0 9 * * 1-5': 'Weekdays at 9 AM', - '0 17 * * 1-5': 'Weekdays at 5 PM', - '0 0 * * 0': 'Sundays at midnight', - '0 0 * * 1': 'Mondays at midnight', - '0 0 1 * *': 'First of each month', -} - -function describeCron(expr: string): string { - return CRON_PHRASES[expr.trim()] ?? expr -} - -type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt' -type ScheduleSummary = { icon: ScheduleIconKind; text: string } - -function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary { - if (!schedule) return { icon: 'bolt', text: 'Manual only' } - if (schedule.type === 'once') { - return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` } - } - if (schedule.type === 'cron') { - return { icon: 'timer', text: describeCron(schedule.expression) } - } - if (schedule.type === 'window') { - return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` } - } - return { icon: 'calendar', text: 'Scheduled' } -} - -function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) { - if (icon === 'timer') return <Clock size={size} /> - if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} /> - return <Zap size={size} /> -} - -// --------------------------------------------------------------------------- -// Modal -// --------------------------------------------------------------------------- - -type Tab = 'what' | 'when' | 'event' | 'details' - -export function TrackModal() { - const [open, setOpen] = useState(false) - const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null) - const [yaml, setYaml] = useState<string>('') - const [loading, setLoading] = useState(false) - const [activeTab, setActiveTab] = useState<Tab>('what') - const [editingRaw, setEditingRaw] = useState(false) - const [rawDraft, setRawDraft] = useState('') - const [showAdvanced, setShowAdvanced] = useState(false) - const [confirmingDelete, setConfirmingDelete] = useState(false) - const [saving, setSaving] = useState(false) - const [error, setError] = useState<string | null>(null) - const textareaRef = useRef<HTMLTextAreaElement>(null) - - // Listen for the open event and seed modal state. - useEffect(() => { - const handler = (e: Event) => { - const ev = e as CustomEvent<OpenTrackModalDetail> - const d = ev.detail - if (!d?.trackId || !d?.filePath) return - setDetail(d) - setYaml(d.initialYaml ?? '') - setActiveTab('what') - setEditingRaw(false) - setRawDraft('') - setShowAdvanced(false) - setConfirmingDelete(false) - setError(null) - setOpen(true) - void fetchFresh(d) - } - window.addEventListener('rowboat:open-track-modal', handler as EventListener) - return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => { - try { - setLoading(true) - const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) }) - if (res?.success && res.yaml) { - setYaml(res.yaml) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setLoading(false) - } - }, []) - - const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => { - if (!yaml) return null - try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null } - }, [yaml]) - - const trackId = track?.trackId ?? detail?.trackId ?? '' - const instruction = track?.instruction ?? '' - const active = track?.active ?? true - const schedule = track?.schedule - const eventMatchCriteria = track?.eventMatchCriteria ?? '' - 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' - - const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : '' - - const allTrackStatus = useTrackStatus() - const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const } - const isRunning = runState.status === 'running' - - useEffect(() => { - if (editingRaw && textareaRef.current) { - textareaRef.current.focus() - textareaRef.current.setSelectionRange( - textareaRef.current.value.length, - textareaRef.current.value.length, - ) - } - }, [editingRaw]) - - const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [ - { key: 'what', label: 'What to track', visible: true }, - { key: 'when', label: 'When to run', visible: !!schedule }, - { key: 'event', label: 'Event matching', visible: !!eventMatchCriteria }, - { key: 'details', label: 'Details', visible: true }, - ] - const shown = visibleTabs.filter(t => t.visible) - - useEffect(() => { - if (!shown.some(t => t.key === activeTab)) setActiveTab('what') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schedule, eventMatchCriteria]) - - // ------------------------------------------------------------------------- - // IPC-backed mutations - // ------------------------------------------------------------------------- - - const runUpdate = useCallback(async (updates: Record<string, unknown>) => { - if (!detail) return - setSaving(true) - setError(null) - try { - const res = await window.ipc.invoke('track:update', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - updates, - }) - if (res?.success && res.yaml) { - setYaml(res.yaml) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [detail]) - - const handleToggleActive = useCallback(() => { - void runUpdate({ active: !active }) - }, [active, runUpdate]) - - const handleRun = useCallback(async () => { - if (!detail || isRunning) return - try { - await window.ipc.invoke('track:run', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - }) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - }, [detail, isRunning]) - - const handleSaveRaw = useCallback(async () => { - if (!detail) return - setSaving(true) - setError(null) - try { - const res = await window.ipc.invoke('track:replaceYaml', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - yaml: rawDraft, - }) - if (res?.success && res.yaml) { - setYaml(res.yaml) - setEditingRaw(false) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [detail, rawDraft]) - - const handleDelete = useCallback(async () => { - if (!detail) return - setSaving(true) - setError(null) - try { - const res = await window.ipc.invoke('track:delete', { - trackId: detail.trackId, - filePath: stripKnowledgePrefix(detail.filePath), - }) - if (res?.success) { - // Tell the editor to remove the node so Tiptap's next save doesn't - // re-create the track block on disk. - try { detail.onDeleted() } catch { /* editor may have unmounted */ } - setOpen(false) - } else if (res?.error) { - setError(res.error) - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [detail]) - - const handleEditWithCopilot = useCallback(() => { - if (!detail) return - window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', { - detail: { - trackId: detail.trackId, - filePath: detail.filePath, - }, - })) - setOpen(false) - }, [detail]) - - if (!detail) return null - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogContent - className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl" - data-trigger={triggerType} - data-active={active ? 'true' : 'false'} - > - <div className="track-modal-header"> - <div className="track-modal-header-left"> - <div className="track-modal-icon-wrap"> - <Radio size={16} /> - </div> - <div className="track-modal-title-col"> - <DialogHeader className="space-y-0"> - <DialogTitle className="track-modal-title"> - {trackId || 'Track'} - </DialogTitle> - <DialogDescription className="track-modal-subtitle"> - <ScheduleIcon icon={scheduleSummary.icon} size={11} /> - {scheduleSummary.text} - {eventMatchCriteria && triggerType === 'scheduled' && ( - <span className="track-modal-subtitle-sep">· also event-driven</span> - )} - </DialogDescription> - </DialogHeader> - </div> - </div> - <div className="track-modal-header-actions"> - <label className="track-modal-toggle"> - <Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} /> - <span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span> - </label> - </div> - </div> - - {/* Tabs */} - <div className="track-modal-tabs"> - {shown.map(tab => ( - <button - key={tab.key} - className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`} - onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }} - > - {tab.label} - </button> - ))} - </div> - - {/* Body */} - <div className="track-modal-body"> - {loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>} - - {activeTab === 'what' && ( - <div className="track-modal-prose"> - {instruction - ? <Streamdown className="track-modal-markdown">{instruction}</Streamdown> - : <span className="track-modal-empty">No instruction set.</span>} - </div> - )} - - {activeTab === 'when' && schedule && ( - <div className="track-modal-when"> - <div className="track-modal-when-headline"> - <ScheduleIcon icon={scheduleSummary.icon} size={18} /> - <span>{scheduleSummary.text}</span> - </div> - <dl className="track-modal-dl"> - <dt>Type</dt><dd><code>{schedule.type}</code></dd> - {schedule.type === 'cron' && ( - <> - <dt>Expression</dt><dd><code>{schedule.expression}</code></dd> - </> - )} - {schedule.type === 'window' && ( - <> - <dt>Expression</dt><dd><code>{schedule.cron}</code></dd> - <dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd> - </> - )} - {schedule.type === 'once' && ( - <> - <dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd> - </> - )} - </dl> - </div> - )} - - {activeTab === 'event' && ( - <div className="track-modal-prose"> - {eventMatchCriteria - ? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown> - : <span className="track-modal-empty">No event matching set.</span>} - </div> - )} - - {activeTab === 'details' && ( - <div className="track-modal-details"> - <dl className="track-modal-dl"> - <dt>Track ID</dt><dd><code>{trackId}</code></dd> - <dt>File</dt><dd><code>{detail.filePath}</code></dd> - <dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd> - {model && (<> - <dt>Model</dt><dd><code>{model}</code></dd> - </>)} - {provider && (<> - <dt>Provider</dt><dd><code>{provider}</code></dd> - </>)} - {lastRunAt && (<> - <dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd> - </>)} - {lastRunId && (<> - <dt>Run ID</dt><dd><code>{lastRunId}</code></dd> - </>)} - {lastRunSummary && (<> - <dt>Summary</dt><dd>{lastRunSummary}</dd> - </>)} - </dl> - </div> - )} - - {/* Advanced (raw YAML) — all tabs */} - <div className="track-modal-advanced"> - <button - className="track-modal-advanced-toggle" - onClick={() => { - const next = !showAdvanced - setShowAdvanced(next) - if (next) { - setRawDraft(yaml) - setEditingRaw(true) - } else { - setEditingRaw(false) - } - }} - > - {showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />} - <Code2 size={12} /> - Advanced (raw YAML) - </button> - {showAdvanced && ( - <div className="track-modal-raw-editor"> - <Textarea - ref={textareaRef} - value={rawDraft} - onChange={(e) => setRawDraft(e.target.value)} - rows={12} - spellCheck={false} - className="track-modal-textarea" - /> - <div className="track-modal-raw-actions"> - <Button - variant="outline" - size="sm" - onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }} - disabled={saving} - > - Cancel - </Button> - <Button - size="sm" - onClick={handleSaveRaw} - disabled={saving || rawDraft.trim() === yaml.trim()} - > - {saving ? <Loader2 size={12} className="animate-spin" /> : null} - Save - </Button> - </div> - </div> - )} - </div> - - {/* Danger zone — on Details tab only */} - {activeTab === 'details' && ( - <div className="track-modal-danger-zone"> - {confirmingDelete ? ( - <div className="track-modal-confirm"> - <span>Delete this track and its generated content?</span> - <div className="track-modal-confirm-actions"> - <Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}> - Cancel - </Button> - <Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}> - {saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />} - Yes, delete - </Button> - </div> - </div> - ) : ( - <Button - variant="outline" - size="sm" - className="track-modal-delete-btn" - onClick={() => setConfirmingDelete(true)} - > - <Trash2 size={12} /> - Delete track block - </Button> - )} - </div> - )} - </div> - - {error && ( - <div className="track-modal-error">{error}</div> - )} - - <DialogFooter className="track-modal-footer"> - <Button - variant="outline" - size="sm" - onClick={handleEditWithCopilot} - disabled={saving} - > - <Sparkles size={12} /> - Edit with Copilot - </Button> - <Button - size="sm" - onClick={handleRun} - disabled={isRunning || saving} - className="track-modal-run-btn" - > - {isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />} - {isRunning ? 'Running…' : 'Run now'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} - -function stripKnowledgePrefix(p: string): string { - return p.replace(/^knowledge\//, '') -} diff --git a/apps/x/apps/renderer/src/components/track-sidebar.tsx b/apps/x/apps/renderer/src/components/track-sidebar.tsx new file mode 100644 index 00000000..24af4c3d --- /dev/null +++ b/apps/x/apps/renderer/src/components/track-sidebar.tsx @@ -0,0 +1,627 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { z } from 'zod' +import '@/styles/track-modal.css' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap, + Trash2, ChevronDown, ChevronUp, ChevronLeft, X, +} from 'lucide-react' +import { parse as parseYaml } from 'yaml' +import { Streamdown } from 'streamdown' +import { TrackSchema, type Trigger } from '@x/shared/dist/track.js' +import { useTrackStatus } from '@/hooks/use-track-status' + +export type OpenTrackSidebarDetail = { + filePath: string + selectId?: string +} + +function formatDateTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +const CRON_PHRASES: Record<string, string> = { + '* * * * *': 'Every minute', + '*/5 * * * *': 'Every 5 minutes', + '*/15 * * * *': 'Every 15 minutes', + '*/30 * * * *': 'Every 30 minutes', + '0 * * * *': 'Hourly', + '0 */2 * * *': 'Every 2 hours', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 0 * * *': 'Daily at midnight', + '0 8 * * *': 'Daily at 8 AM', + '0 9 * * *': 'Daily at 9 AM', + '0 12 * * *': 'Daily at noon', + '0 18 * * *': 'Daily at 6 PM', + '0 9 * * 1-5': 'Weekdays at 9 AM', + '0 17 * * 1-5': 'Weekdays at 5 PM', + '0 0 * * 0': 'Sundays at midnight', + '0 0 * * 1': 'Mondays at midnight', + '0 0 1 * *': 'First of each month', +} + +function describeCron(expr: string): string { + return CRON_PHRASES[expr.trim()] ?? expr +} + +type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt' +type ScheduleSummary = { icon: ScheduleIconKind; text: string } + +function describeTrigger(t: Trigger): ScheduleSummary { + if (t.type === 'once') return { icon: 'target', text: `Once at ${formatDateTime(t.runAt)}` } + if (t.type === 'cron') return { icon: 'timer', text: describeCron(t.expression) } + if (t.type === 'window') return { icon: 'calendar', text: `${t.startTime}–${t.endTime}` } + return { icon: 'bolt', text: 'Event-driven' } +} + +function summarizeTriggers(triggers: Trigger[] | undefined): ScheduleSummary { + if (!triggers || triggers.length === 0) return { icon: 'bolt', text: 'Manual only' } + const timed = triggers.filter(t => t.type !== 'event') + const events = triggers.filter(t => t.type === 'event') + if (timed.length === 0) { + return { icon: 'bolt', text: events.length > 1 ? `${events.length} event triggers` : 'Event-driven' } + } + const first = describeTrigger(timed[0]) + let text = first.text + if (timed.length > 1) text += ` (+${timed.length - 1})` + if (events.length > 0) text += ' · also event-driven' + return { icon: first.icon, text } +} + + +function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) { + if (icon === 'timer') return <Clock size={size} /> + if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} /> + return <Zap size={size} /> +} + +function stripKnowledgePrefix(p: string): string { + return p.replace(/^knowledge\//, '') +} + +type Track = z.infer<typeof TrackSchema> + +function parseTracksFromFile(content: string): Track[] { + if (!content.startsWith('---')) return [] + const close = /\r?\n---\r?\n/.exec(content) + if (!close) return [] + const yamlText = content.slice(3, close.index).trim() + if (!yamlText) return [] + let fm: unknown + try { fm = parseYaml(yamlText) } catch { return [] } + if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return [] + const raw = (fm as Record<string, unknown>).track + if (!Array.isArray(raw)) return [] + const tracks: Track[] = [] + for (const entry of raw) { + const result = TrackSchema.safeParse(entry) + if (result.success) tracks.push(result.data) + } + return tracks +} + +type Tab = 'what' | 'when' | 'event' | 'details' + +export function TrackSidebar() { + const [open, setOpen] = useState(false) + const [filePath, setFilePath] = useState<string>('') + const [tracks, setTracks] = useState<Track[]>([]) + const [selectedId, setSelectedId] = useState<string | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + + // Detail-view state (per-track local UI) + const [activeTab, setActiveTab] = useState<Tab>('what') + const [editingRaw, setEditingRaw] = useState(false) + const [rawDraft, setRawDraft] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [saving, setSaving] = useState(false) + const textareaRef = useRef<HTMLTextAreaElement>(null) + + const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath), [filePath]) + const allTrackStatus = useTrackStatus() + + const refresh = useCallback(async (relPath: string) => { + if (!relPath) { setTracks([]); return } + setLoading(true) + setError(null) + try { + const res = await window.ipc.invoke('workspace:readFile', { path: `knowledge/${relPath}` }) + if (res?.data) { + setTracks(parseTracksFromFile(res.data)) + } else { + setTracks([]) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setTracks([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent<OpenTrackSidebarDetail> + const d = ev.detail + if (!d?.filePath) return + setFilePath(d.filePath) + setSelectedId(d.selectId ?? null) + setActiveTab('what') + setEditingRaw(false) + setRawDraft('') + setShowAdvanced(false) + setConfirmingDelete(false) + setError(null) + setOpen(true) + void refresh(stripKnowledgePrefix(d.filePath)) + } + window.addEventListener('rowboat:open-track-sidebar', handler as EventListener) + return () => window.removeEventListener('rowboat:open-track-sidebar', handler as EventListener) + }, [refresh]) + + // Re-fetch when a run completes for a track in this file. + useEffect(() => { + if (!open || !knowledgeRelPath) return + let stale = false + for (const [, state] of allTrackStatus) { + if (state.status === 'done' || state.status === 'error') { + stale = true + break + } + } + if (stale) void refresh(knowledgeRelPath) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allTrackStatus, open, knowledgeRelPath]) + + const selected = useMemo( + () => (selectedId ? tracks.find(t => t.id === selectedId) ?? null : null), + [selectedId, tracks], + ) + + // Seed raw editor draft when entering advanced mode. + useEffect(() => { + if (showAdvanced && selected) { + try { + // Lazy import yaml stringify only when needed; avoid top-level dep cycle. + import('yaml').then(({ stringify }) => { + setRawDraft(stringify(selected).trimEnd()) + }) + } catch { + setRawDraft('') + } + } + }, [showAdvanced, selected]) + + useEffect(() => { + if (editingRaw && textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length, + ) + } + }, [editingRaw]) + + const runUpdate = useCallback(async (id: string, updates: Record<string, unknown>) => { + if (!knowledgeRelPath) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:update', { id, filePath: knowledgeRelPath, updates }) + if (!res?.success && res?.error) setError(res.error) + await refresh(knowledgeRelPath) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [knowledgeRelPath, refresh]) + + const handleToggleActive = useCallback((id: string, currentlyActive: boolean) => { + void runUpdate(id, { active: !currentlyActive }) + }, [runUpdate]) + + const handleRun = useCallback(async (id: string) => { + if (!knowledgeRelPath) return + try { + await window.ipc.invoke('track:run', { id, filePath: knowledgeRelPath }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [knowledgeRelPath]) + + const handleSaveRaw = useCallback(async () => { + if (!knowledgeRelPath || !selectedId) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:replaceYaml', { id: selectedId, filePath: knowledgeRelPath, yaml: rawDraft }) + if (res?.success) { + setEditingRaw(false) + await refresh(knowledgeRelPath) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [knowledgeRelPath, selectedId, rawDraft, refresh]) + + const handleDelete = useCallback(async () => { + if (!knowledgeRelPath || !selectedId) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('track:delete', { id: selectedId, filePath: knowledgeRelPath }) + if (res?.success) { + setSelectedId(null) + setConfirmingDelete(false) + await refresh(knowledgeRelPath) + } else if (res?.error) { + setError(res.error) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [knowledgeRelPath, selectedId, refresh]) + + const handleEditWithCopilot = useCallback(() => { + if (!filePath || !selectedId) return + window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', { + detail: { trackId: selectedId, filePath }, + })) + setOpen(false) + }, [filePath, selectedId]) + + if (!open) return null + + const noteTitle = filePath + ? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '') + : 'Tracks' + + return ( + <aside className="fixed inset-y-0 right-0 z-60 flex w-[min(420px,calc(100vw-2rem))] flex-col overflow-hidden border-l border-border bg-background shadow-2xl"> + <div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground"> + <Radio className="size-4 shrink-0 text-sidebar-foreground/70" /> + <div className="flex min-w-0 flex-1 flex-col"> + <span className="truncate text-sm font-medium">Tracks</span> + <span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span> + </div> + <button + type="button" + className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground" + onClick={() => setOpen(false)} + aria-label="Close" + > + <X className="size-4" /> + </button> + </div> + + {error && ( + <div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"> + {error} + </div> + )} + + {!selected && ( + <div className="flex-1 overflow-auto"> + {loading && ( + <div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"> + <Loader2 className="size-3 animate-spin" /> Loading… + </div> + )} + {!loading && tracks.length === 0 && ( + <div className="flex flex-col items-center gap-1.5 px-6 py-12 text-center"> + <Radio className="size-6 text-muted-foreground/50" /> + <div className="text-sm text-muted-foreground">No tracks in this note yet.</div> + <div className="text-xs text-muted-foreground/70"> + Ask Copilot “track Chicago time hourly” to add one. + </div> + </div> + )} + <ul className="divide-y divide-border"> + {tracks.map(t => { + const sched = summarizeTriggers(t.triggers) + const runState = allTrackStatus.get(`${t.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const } + const isRunning = runState.status === 'running' + const paused = t.active === false + const instructionPreview = t.instruction.split('\n')[0].trim() + return ( + <li key={t.id}> + <button + type="button" + className={`group flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-accent ${paused ? 'opacity-60' : ''}`} + onClick={() => { setSelectedId(t.id); setActiveTab('what') }} + > + <div className="flex min-w-0 flex-1 flex-col gap-0.5"> + <span className="truncate text-sm font-medium">{t.id}</span> + <span className="truncate text-xs text-muted-foreground"> + {paused ? 'Paused · ' : ''}{sched.text} + </span> + {instructionPreview && ( + <span className="truncate text-xs text-muted-foreground/70"> + {instructionPreview} + </span> + )} + </div> + <button + type="button" + className={`inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-background hover:text-foreground ${isRunning ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} + onClick={(ev) => { ev.stopPropagation(); void handleRun(t.id) }} + disabled={isRunning} + aria-label={isRunning ? `Running ${t.id}` : `Run ${t.id}`} + title={isRunning ? `Running…` : `Run ${t.id}`} + > + {isRunning ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />} + </button> + </button> + </li> + ) + })} + </ul> + </div> + )} + + {selected && (() => { + const triggers: Trigger[] = selected.triggers ?? [] + const timedTriggers = triggers.filter((t): t is Exclude<Trigger, { type: 'event' }> => t.type !== 'event') + const eventTriggers = triggers.filter((t): t is Extract<Trigger, { type: 'event' }> => t.type === 'event') + const sched = summarizeTriggers(triggers) + const runState = allTrackStatus.get(`${selected.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const } + const isRunning = runState.status === 'running' + const paused = selected.active === false + const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [ + { key: 'what', label: 'What', visible: true }, + { key: 'when', label: 'Schedule', visible: timedTriggers.length > 0 }, + { key: 'event', label: 'Events', visible: eventTriggers.length > 0 }, + { key: 'details', label: 'Details', visible: true }, + ] + const shown = visibleTabs.filter(t => t.visible) + + return ( + <div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}> + {/* Subheader: back arrow + track id */} + <div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2"> + <button + type="button" + className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" + onClick={() => { + setSelectedId(null) + setShowAdvanced(false) + setEditingRaw(false) + setConfirmingDelete(false) + }} + aria-label="Back to tracks" + > + <ChevronLeft className="size-4" /> + </button> + <span className="truncate text-sm font-medium">{selected.id}</span> + </div> + + {/* Status row: schedule summary + active toggle */} + <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2"> + <span className="truncate text-xs text-muted-foreground">{sched.text}</span> + <label className="flex shrink-0 items-center gap-2"> + <Switch + checked={!paused} + onCheckedChange={() => handleToggleActive(selected.id, !paused)} + disabled={saving} + /> + <span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span> + </label> + </div> + + {/* Tabs */} + <div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5"> + {shown.map(tab => ( + <button + key={tab.key} + type="button" + className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ + activeTab === tab.key + ? 'bg-accent text-foreground' + : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground' + }`} + onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }} + > + {tab.label} + </button> + ))} + </div> + + {/* Body */} + <div className="flex-1 overflow-auto px-3 py-3"> + {activeTab === 'what' && ( + <div className="text-sm leading-relaxed"> + {selected.instruction ? ( + <Streamdown className="prose prose-sm max-w-none dark:prose-invert"> + {selected.instruction} + </Streamdown> + ) : ( + <span className="text-muted-foreground">No instruction set.</span> + )} + </div> + )} + + {activeTab === 'when' && timedTriggers.length > 0 && ( + <div className="flex flex-col gap-2"> + {timedTriggers.map((trig, idx) => { + const tSched = describeTrigger(trig) + return ( + <div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5"> + <div className="mb-2 flex items-center gap-2 text-sm font-medium"> + <ScheduleIcon icon={tSched.icon} size={14} /> + <span>{tSched.text}</span> + </div> + <DetailGrid> + <DetailRow label="Type" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.type}</code>} /> + {trig.type === 'cron' && ( + <DetailRow label="Expression" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.expression}</code>} /> + )} + {trig.type === 'window' && ( + <DetailRow label="Window" value={`${trig.startTime} – ${trig.endTime}`} /> + )} + {trig.type === 'once' && ( + <DetailRow label="Runs at" value={formatDateTime(trig.runAt)} /> + )} + </DetailGrid> + </div> + ) + })} + </div> + )} + + {activeTab === 'event' && ( + <div className="flex flex-col gap-2"> + {eventTriggers.length === 0 ? ( + <span className="text-sm text-muted-foreground">No event matching set.</span> + ) : eventTriggers.map((trig, idx) => ( + <div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5"> + <Streamdown className="prose prose-sm max-w-none dark:prose-invert"> + {trig.matchCriteria} + </Streamdown> + </div> + ))} + </div> + )} + + {activeTab === 'details' && ( + <DetailGrid> + <DetailRow label="ID" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.id}</code>} /> + <DetailRow label="File" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px] break-all">{filePath}</code>} /> + <DetailRow label="Status" value={paused ? 'Paused' : 'Active'} /> + {selected.model && <DetailRow label="Model" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.model}</code>} />} + {selected.provider && <DetailRow label="Provider" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.provider}</code>} />} + {selected.lastRunAt && <DetailRow label="Last run" value={formatDateTime(selected.lastRunAt)} />} + {selected.lastRunSummary && <DetailRow label="Summary" value={selected.lastRunSummary} />} + </DetailGrid> + )} + + {/* Advanced — raw YAML */} + <div className="mt-6 border-t border-border pt-3"> + <button + type="button" + className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground" + onClick={() => { + const next = !showAdvanced + setShowAdvanced(next) + setEditingRaw(next) + }} + > + {showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />} + <Code2 className="size-3" /> + Advanced (raw YAML) + </button> + {showAdvanced && ( + <div className="mt-2 flex flex-col gap-2"> + <Textarea + ref={textareaRef} + value={rawDraft} + onChange={(e) => setRawDraft(e.target.value)} + rows={12} + spellCheck={false} + className="font-mono text-xs" + /> + <div className="flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => { setShowAdvanced(false); setEditingRaw(false) }} + disabled={saving} + > + Cancel + </Button> + <Button size="sm" onClick={handleSaveRaw} disabled={saving}> + {saving ? <Loader2 className="size-3 animate-spin" /> : null} + Save + </Button> + </div> + </div> + )} + </div> + + {/* Danger zone — Details tab only */} + {activeTab === 'details' && ( + <div className="mt-4 border-t border-border pt-3"> + {confirmingDelete ? ( + <div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm"> + <span className="text-destructive">Delete this track?</span> + <div className="flex gap-2"> + <Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}> + Cancel + </Button> + <Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}> + {saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />} + Delete + </Button> + </div> + </div> + ) : ( + <Button + variant="ghost" + size="sm" + className="text-destructive hover:bg-destructive/10 hover:text-destructive" + onClick={() => setConfirmingDelete(true)} + > + <Trash2 className="size-3" /> + Delete track + </Button> + )} + </div> + )} + </div> + + {/* Footer */} + <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/20 px-3 py-2.5"> + <Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving}> + <Sparkles className="size-3" /> + Edit with Copilot + </Button> + <Button + size="sm" + onClick={() => handleRun(selected.id)} + disabled={isRunning || saving} + > + {isRunning ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />} + {isRunning ? 'Running…' : 'Run now'} + </Button> + </div> + </div> + ) + })()} + </aside> + ) +} + +function DetailGrid({ children }: { children: React.ReactNode }) { + return ( + <dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs"> + {children} + </dl> + ) +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + <> + <dt className="text-muted-foreground">{label}</dt> + <dd className="min-w-0 break-words text-foreground">{value}</dd> + </> + ) +} diff --git a/apps/x/apps/renderer/src/extensions/track-block.tsx b/apps/x/apps/renderer/src/extensions/track-block.tsx deleted file mode 100644 index 4f2a1f0a..00000000 --- a/apps/x/apps/renderer/src/extensions/track-block.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { z } from 'zod' -import { useMemo, type ComponentType } from 'react' -import { mergeAttributes, Node } from '@tiptap/react' -import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' -import { Radio, Loader2, type LucideProps } from 'lucide-react' -import * as LucideIcons from 'lucide-react' -import { parse as parseYaml } from 'yaml' -import { TrackBlockSchema } from '@x/shared/dist/track-block.js' -import { useTrackStatus } from '@/hooks/use-track-status' - -function resolveIcon(iconName: string): ComponentType<LucideProps> | null { - const key = iconName - .split('-') - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join('') - const component = (LucideIcons as Record<string, unknown>)[key] - if (component != null) return component as ComponentType<LucideProps> - return null -} - -function TrackIcon({ icon, size }: { icon?: string; size: number }) { - if (icon) { - const Icon = resolveIcon(icon) - if (Icon) return <Icon size={size} /> - } - return <Radio size={size} /> -} - -function truncate(text: string, maxLen: number): string { - const clean = text.replace(/\s+/g, ' ').trim() - if (clean.length <= maxLen) return clean - return clean.slice(0, maxLen).trimEnd() + '…' -} - -// Detail shape for the open-track-modal window event. Defined here so the -// consumer (TrackModal) can import it without a circular dependency. -export type OpenTrackModalDetail = { - trackId: string - /** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */ - filePath: string - /** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */ - initialYaml: string - /** Invoked after a successful IPC delete so the editor can remove the node. */ - onDeleted: () => void -} - -// --------------------------------------------------------------------------- -// Chip (display-only) -// --------------------------------------------------------------------------- - -function TrackBlockView({ node, deleteNode, extension }: { - node: { attrs: Record<string, unknown> } - deleteNode: () => void - updateAttributes: (attrs: Record<string, unknown>) => void - extension: { options: { notePath?: string } } -}) { - const raw = node.attrs.data as string - const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, ""); - - const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => { - try { - return TrackBlockSchema.parse(parseYaml(cleaned)) - } catch(error) { console.error('error', error); return null } - }, [raw]) as z.infer<typeof TrackBlockSchema> | null; - - const trackId = track?.trackId ?? '' - const instruction = track?.instruction ?? '' - const active = track?.active ?? true - const schedule = track?.schedule - const eventMatchCriteria = track?.eventMatchCriteria ?? '' - const notePath = extension.options.notePath - const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? '' - - const triggerType: 'scheduled' | 'event' | 'manual' = - schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual' - - const allTrackStatus = useTrackStatus() - const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const } - const isRunning = runState.status === 'running' - - const handleOpen = (e: React.MouseEvent) => { - e.stopPropagation() - if (!trackId || !notePath) return - const detail: OpenTrackModalDetail = { - trackId, - filePath: notePath, - initialYaml: raw, - onDeleted: () => deleteNode(), - } - window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>( - 'rowboat:open-track-modal', - { detail }, - )) - } - - const handleKey = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - handleOpen(e as unknown as React.MouseEvent) - } - } - - return ( - <NodeViewWrapper - className="track-block-chip-wrapper" - data-type="track-block" - data-trigger={triggerType} - data-active={active ? 'true' : 'false'} - data-trackid={trackId} - > - <button - type="button" - className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`} - onClick={handleOpen} - onKeyDown={handleKey} - onMouseDown={(e) => e.stopPropagation()} - title={instruction ? `${trackId}: ${instruction}` : trackId} - > - <span className="track-block-chip-icon"> - {isRunning - ? <Loader2 size={24} className="animate-spin" /> - : <TrackIcon icon={track?.icon} size={24} />} - </span> - <span className="track-block-chip-id">{trackId || 'track'}</span> - {instruction && <span className="track-block-chip-sep">·</span>} - {instruction && ( - <span className="track-block-chip-instruction">{truncate(instruction, 80)}</span> - )} - {!active && <span className="track-block-chip-paused-label">paused</span>} - </button> - </NodeViewWrapper> - ) -} - -// --------------------------------------------------------------------------- -// Tiptap extension — unchanged schema, parseHTML, serialize -// --------------------------------------------------------------------------- - -export const TrackBlockExtension = Node.create({ - name: 'trackBlock', - group: 'block', - atom: true, - selectable: true, - draggable: false, - - addOptions() { - return { - notePath: undefined as string | undefined, - } - }, - - addAttributes() { - return { - data: { - default: '', - }, - } - }, - - parseHTML() { - return [ - { - tag: 'pre', - priority: 60, - getAttrs(element) { - const code = element.querySelector('code') - if (!code) return false - const cls = code.className || '' - if (cls.includes('language-track')) { - return { data: code.textContent || '' } - } - return false - }, - }, - ] - }, - - renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) { - return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })] - }, - - addNodeView() { - return ReactNodeViewRenderer(TrackBlockView) - }, - - addStorage() { - return { - markdown: { - serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { - state.write('```track\n' + node.attrs.data + '\n```') - state.closeBlock(node) - }, - parse: { - // handled by parseHTML - }, - }, - } - }, -}) diff --git a/apps/x/apps/renderer/src/extensions/track-target.tsx b/apps/x/apps/renderer/src/extensions/track-target.tsx deleted file mode 100644 index 8b092c74..00000000 --- a/apps/x/apps/renderer/src/extensions/track-target.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { mergeAttributes, Node } from '@tiptap/react' - -/** - * Track target markers — two Tiptap atom nodes that represent the open and - * close HTML comment markers bracketing a track's output region on disk: - * - * <!--track-target:ID--> → TrackTargetOpenExtension - * content in between → regular Tiptap nodes (paragraphs, lists, - * custom blocks, whatever tiptap-markdown parses) - * <!--/track-target:ID--> → TrackTargetCloseExtension - * - * The markers are *semantic boundaries*, not a UI container. Content between - * them is real, editable document content — fully rendered by the existing - * extension set and freely editable by the user. The backend's updateContent() - * in fileops.ts still locates the region on disk by these comment markers. - * - * Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker - * regex replace, converting each comment into a placeholder div that these - * extensions' parseHTML rules pick up. No content capture. - * - * Save path: both Tiptap's built-in markdown serializer - * (`addStorage().markdown.serialize`) AND the app's custom serializer - * (`blockToMarkdown` in markdown-editor.tsx) write the original comment form - * back out — they must stay in sync. - */ - -type MarkerVariant = 'open' | 'close' - -function buildMarkerExtension(variant: MarkerVariant) { - const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose' - const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close' - const commentFor = (id: string) => - variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->` - - return Node.create({ - name, - group: 'block', - atom: true, - selectable: true, - draggable: false, - - addAttributes() { - return { - trackId: { default: '' }, - } - }, - - parseHTML() { - return [ - { - tag: `div[data-type="${htmlType}"]`, - getAttrs(el) { - if (!(el instanceof HTMLElement)) return false - return { trackId: el.getAttribute('data-track-id') ?? '' } - }, - }, - ] - }, - - renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) { - return [ - 'div', - mergeAttributes(HTMLAttributes, { - 'data-type': htmlType, - 'data-track-id': (node.attrs.trackId as string) ?? '', - }), - ] - }, - - addStorage() { - return { - markdown: { - serialize( - state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, - node: { attrs: { trackId: string } }, - ) { - state.write(commentFor(node.attrs.trackId ?? '')) - state.closeBlock(node) - }, - parse: { - // handled via preprocessTrackTargets → parseHTML - }, - }, - } - }, - }) -} - -export const TrackTargetOpenExtension = buildMarkerExtension('open') -export const TrackTargetCloseExtension = buildMarkerExtension('close') diff --git a/apps/x/apps/renderer/src/hooks/use-track-status.ts b/apps/x/apps/renderer/src/hooks/use-track-status.ts index 18c1ba10..9d9b6306 100644 --- a/apps/x/apps/renderer/src/hooks/use-track-status.ts +++ b/apps/x/apps/renderer/src/hooks/use-track-status.ts @@ -1,6 +1,6 @@ import z from 'zod'; import { useSyncExternalStore } from 'react'; -import { TrackEvent } from '@x/shared/dist/track-block.js'; +import { TrackEvent } from '@x/shared/dist/track.js'; export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error'; @@ -59,7 +59,7 @@ function getSnapshot(): Map<string, TrackState> { /** * Returns a Map of all track run states, keyed by "trackId:filePath". * - * Usage in a track block component: + * Usage in a track-aware component: * const trackStatus = useTrackStatus(); * const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' }; * diff --git a/apps/x/apps/renderer/src/lib/frontmatter.ts b/apps/x/apps/renderer/src/lib/frontmatter.ts index 85a74dcf..128e6d3e 100644 --- a/apps/x/apps/renderer/src/lib/frontmatter.ts +++ b/apps/x/apps/renderer/src/lib/frontmatter.ts @@ -133,9 +133,19 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields } /** - * Extract ALL top-level YAML key/value pairs from raw frontmatter. - * Returns a flat record where scalar values are strings and list values are string[]. - * Skips `---` delimiters and blank lines. + * Keys that hold structured (nested object/array-of-object) data and must NOT + * be mangled by the flat-string FrontmatterProperties UI. These pass through + * unchanged on a round-trip — never exposed as editable fields, never + * re-emitted by buildFrontmatter (callers must splice them back from the + * original raw if they want to preserve them on save — see the helpers below). + */ +const STRUCTURED_KEYS = new Set(['track']) + +/** + * Extract editable top-level YAML key/value pairs from raw frontmatter. + * Returns a flat record where scalar values are strings and list-of-string + * values are string[]. Structured keys (e.g. `track:`) and any nested-object + * shapes are filtered out — they are not editable via this surface. */ export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> { const result: Record<string, string | string[]> = {} @@ -143,10 +153,12 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string, const lines = raw.split('\n') let currentKey: string | null = null + let pendingNested = false for (const line of lines) { if (line === '---' || line.trim() === '') { currentKey = null + pendingNested = false continue } @@ -155,39 +167,61 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string, if (topMatch) { const key = topMatch[1] const value = topMatch[2].trim() + pendingNested = false + if (STRUCTURED_KEYS.has(key)) { + currentKey = null + continue + } if (value) { result[key] = value currentKey = null } else { - // List will follow currentKey = key result[key] = [] } continue } - // List item under current key - if (currentKey) { - const itemMatch = line.match(/^\s+-\s+(.+)$/) - if (itemMatch) { - const arr = result[currentKey] - if (Array.isArray(arr)) { - arr.push(itemMatch[1].trim()) - } + if (!currentKey) continue + + // List item under current key. + const itemMatch = line.match(/^\s+-\s+(.*)$/) + if (itemMatch) { + const item = itemMatch[1].trim() + // If the list-item line itself contains a `key: value` pair, this is a + // nested-object shape (e.g. `- id: chicago-time` under `track:`). We + // can't represent that as a flat string array — drop the whole key. + if (/^\w[\w\s]*\w?:\s*\S/.test(item)) { + delete result[currentKey] + currentKey = null + pendingNested = true + continue } + const arr = result[currentKey] + if (Array.isArray(arr)) arr.push(item) + continue } + + // Indented continuation of a nested object — keep dropping its parent. + if (pendingNested && /^\s/.test(line)) continue } return result } /** - * Convert a Record of frontmatter fields back to a raw YAML frontmatter string. - * Returns null if no non-empty fields remain. + * Convert a Record of editable frontmatter fields back to a raw YAML + * frontmatter string. If `preserveRaw` is provided, structured keys (e.g. + * `track:`) are spliced back from the original raw byte-for-byte, so + * round-trips through the FrontmatterProperties UI never lose them. */ -export function buildFrontmatter(fields: Record<string, string | string[]>): string | null { +export function buildFrontmatter( + fields: Record<string, string | string[]>, + preserveRaw: string | null = null, +): string | null { const lines: string[] = [] for (const [key, value] of Object.entries(fields)) { + if (STRUCTURED_KEYS.has(key)) continue if (Array.isArray(value)) { if (value.length === 0) continue lines.push(`${key}:`) @@ -200,8 +234,55 @@ export function buildFrontmatter(fields: Record<string, string | string[]>): str lines.push(`${key}: ${trimmed}`) } } - if (lines.length === 0) return null - return `---\n${lines.join('\n')}\n---` + + // Splice preserved structured-key blocks (e.g. track:) back from preserveRaw. + const preservedBlocks: string[] = [] + if (preserveRaw) { + for (const key of STRUCTURED_KEYS) { + const block = extractTopLevelBlock(preserveRaw, key) + if (block) preservedBlocks.push(block) + } + } + + if (lines.length === 0 && preservedBlocks.length === 0) return null + const allLines = [...lines, ...preservedBlocks.flatMap(b => b.split('\n'))] + return `---\n${allLines.join('\n')}\n---` +} + +/** + * Return the byte-for-byte line block for a top-level key in raw frontmatter, + * including its nested children (any indented lines that follow), or null if + * the key is absent. Used to round-trip structured keys safely. + */ +function extractTopLevelBlock(raw: string, key: string): string | null { + const lines = raw.split('\n') + let start = -1 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line === '---') continue + const m = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/) + if (m && m[1] === key) { + start = i + break + } + } + if (start === -1) return null + let end = start + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i] + if (line === '---') break + if (/^\s/.test(line)) { + end = i + continue + } + if (line.trim() === '') { + // blank line — end of this top-level block + break + } + // another top-level key — stop + break + } + return lines.slice(start, end + 1).join('\n') } /** Map known tag values → category for legacy flat-list frontmatter. */ diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index b830a02f..5f604265 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -656,159 +656,11 @@ color: color-mix(in srgb, var(--foreground) 38%, transparent); } /* ============================================================= - Track Block — inline chip (display-only) - The chip just opens a modal (TrackModal). All mutations live in the - modal and go through IPC, so the editor never writes track state. + (Track inline chip and target-marker styles removed — tracks now + live entirely in the note's frontmatter and are managed via the + right-side track sidebar.) ============================================================= */ -.tiptap-editor .ProseMirror .track-block-chip-wrapper { - --track-accent: #64748b; /* default: manual/slate */ - margin: 8px 0; - display: block; -} - -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); } - -.tiptap-editor .ProseMirror .track-block-chip { - display: inline-flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 24px 16px; - font-family: inherit; - font-size: 16px; - line-height: 1.3; - color: var(--foreground); - background: color-mix(in srgb, var(--muted) 40%, transparent); - border: 1px solid var(--border); - border-radius: 8px; - cursor: pointer; - transition: background-color 0.15s ease; - user-select: none; -} - -.tiptap-editor .ProseMirror .track-block-chip:hover { - background: color-mix(in srgb, var(--muted) 70%, transparent); -} - -.tiptap-editor .ProseMirror .track-block-chip:active { - transform: translateY(0.5px); -} - -.tiptap-editor .ProseMirror .track-block-chip:focus-visible { - outline: 2px solid var(--track-accent); - outline-offset: 2px; -} - -.tiptap-editor .ProseMirror .track-block-chip-paused-state { - opacity: 0.65; -} - -.tiptap-editor .ProseMirror .track-block-chip-running { - box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent); - animation: track-chip-pulse 2s ease-in-out infinite; -} - -@keyframes track-chip-pulse { - 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); } - 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); } -} - -.tiptap-editor .ProseMirror .track-block-chip-icon { - flex-shrink: 0; - color: color-mix(in srgb, var(--foreground) 45%, transparent); -} - -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="up-next"] .track-block-chip-icon { color: #3b82f6; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="calendar"] .track-block-chip-icon { color: #22c55e; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="emails"] .track-block-chip-icon { color: #f59e0b; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="what-you-missed"] .track-block-chip-icon { color: #3b82f6; } -.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="priorities"] .track-block-chip-icon { color: #ef4444; } - -.tiptap-editor .ProseMirror .track-block-chip-id { - font-weight: 600; - color: color-mix(in srgb, var(--foreground) 75%, transparent); - white-space: nowrap; - flex-shrink: 0; -} - -.tiptap-editor .ProseMirror .track-block-chip-sep { - color: color-mix(in srgb, var(--foreground) 25%, transparent); - flex-shrink: 0; -} - -.tiptap-editor .ProseMirror .track-block-chip-instruction { - color: color-mix(in srgb, var(--foreground) 55%, transparent); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.tiptap-editor .ProseMirror .track-block-chip-paused-label { - flex-shrink: 0; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: color-mix(in srgb, var(--foreground) 55%, transparent); - background: color-mix(in srgb, var(--foreground) 10%, transparent); - padding: 1px 6px; - border-radius: 999px; -} - -.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip { - outline: 2px solid var(--track-accent); - outline-offset: 2px; -} - -/* ============================================================= - Track target markers — thin visual bookends around a track's - output region. The content BETWEEN these markers is normal, - editable document content (rendered by the existing extensions). - ============================================================= */ - -.tiptap-editor .ProseMirror div[data-type="track-target-open"] { - position: relative; - height: 1px; - margin: 14px 0 6px 0; - background: color-mix(in srgb, var(--foreground) 15%, transparent); - pointer-events: none; -} - -.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before { - content: 'track: ' attr(data-track-id); - position: absolute; - top: -8px; - left: 8px; - padding: 0 6px; - background: var(--background, #fff); - font-size: 10px; - font-weight: 500; - letter-spacing: 0.02em; - color: color-mix(in srgb, var(--foreground) 50%, transparent); - text-transform: none; - white-space: nowrap; - pointer-events: auto; -} - -.tiptap-editor .ProseMirror div[data-type="track-target-close"] { - height: 1px; - margin: 6px 0 14px 0; - background: color-mix(in srgb, var(--foreground) 10%, transparent); - pointer-events: none; -} - -.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode, -.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode { - outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent); - outline-offset: 1px; - pointer-events: auto; -} - /* Shared block styles (image, embed, chart, table) */ .tiptap-editor .ProseMirror .image-block-wrapper, .tiptap-editor .ProseMirror .embed-block-wrapper, diff --git a/apps/x/apps/renderer/src/styles/track-modal.css b/apps/x/apps/renderer/src/styles/track-modal.css index 697ada63..7428a26c 100644 --- a/apps/x/apps/renderer/src/styles/track-modal.css +++ b/apps/x/apps/renderer/src/styles/track-modal.css @@ -1,5 +1,7 @@ /* ============================================================= - Track Modal — dialog overlay for track block details / edits + Track sidebar styles. Filename is legacy (predates the modal → + sidebar refactor); the .track-modal-* class names are reused by + the sidebar's detail-view layout. ============================================================= */ .track-modal-content { @@ -309,3 +311,167 @@ .track-modal-run-btn:hover { background: color-mix(in srgb, var(--track-accent) 85%, black); } + +/* ============================================================= + Track sidebar — right panel that lists/edits tracks for the + currently-open note. Reuses the .track-modal-* inner styles. + ============================================================= */ + +.track-sidebar { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(420px, calc(100vw - 2rem)); + z-index: 60; + display: flex; + flex-direction: column; + background: var(--background, #fff); + border-left: 1px solid var(--border); + box-shadow: -8px 0 24px -12px rgba(0, 0, 0, 0.18); + overflow: hidden; +} + +.track-sidebar-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + min-height: 48px; +} + +.track-sidebar-back, +.track-sidebar-close { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--foreground) 65%, transparent); +} + +.track-sidebar-back:hover, +.track-sidebar-close:hover { + background: color-mix(in srgb, var(--foreground) 6%, transparent); +} + +.track-sidebar-title { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 14px; + font-weight: 600; + min-width: 0; +} + +.track-sidebar-subtitle { + font-size: 11px; + font-weight: 400; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-sidebar-list { + flex: 1; + overflow: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.track-sidebar-empty { + padding: 24px 16px; + text-align: center; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + font-size: 13px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.track-sidebar-empty-hint { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 45%, transparent); +} + +.track-sidebar-row { + --track-accent: #64748b; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--background); + border: 1px solid var(--border); + border-left: 3px solid var(--track-accent); + border-radius: 8px; + text-align: left; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.track-sidebar-row[data-trigger="scheduled"] { --track-accent: #6366f1; } +.track-sidebar-row[data-trigger="event"] { --track-accent: #a855f7; } +.track-sidebar-row[data-trigger="manual"] { --track-accent: #64748b; } +.track-sidebar-row[data-active="false"] { opacity: 0.65; } + +.track-sidebar-row:hover { + background: color-mix(in srgb, var(--foreground) 4%, transparent); +} + +.track-sidebar-row-icon { + color: var(--track-accent); + margin-top: 2px; +} + +.track-sidebar-row-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.track-sidebar-row-title { + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.track-sidebar-row-sub { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: color-mix(in srgb, var(--foreground) 55%, transparent); +} + +.track-sidebar-row-instruction { + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 70%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-sidebar-detail { + --track-accent: #64748b; + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; +} + +.track-sidebar-detail[data-trigger="scheduled"] { --track-accent: #6366f1; } +.track-sidebar-detail[data-trigger="event"] { --track-accent: #a855f7; } +.track-sidebar-detail[data-trigger="manual"] { --track-accent: #64748b; } +.track-sidebar-detail[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); } diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 86fe3f9e..8518162c 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -84,7 +84,13 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view. -**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards. +**Tracks (Auto-Updating Notes):** A note's body can be partially or fully agent-maintained — *living* notes that refresh on a schedule or react to incoming emails / calendar events. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the word "track" — load the \`tracks\` skill the moment you spot one. + +*Strong signals (load the skill, act without asking):* "every morning / daily / hourly…", "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "pin live updates of…", "track / follow X", "whenever a relevant email comes in…". + +*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). + +A track is a directive in a note's frontmatter (\`track:\` array entry) with one or more triggers (cron / window / once / event). Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor). When you set one up, tell them where to find it. **Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane. **Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it. diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index fb7ec4e9..b8454306 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -104,7 +104,7 @@ const definitions: SkillDefinition[] = [ { id: "tracks", title: "Tracks", - summary: "Create and manage track blocks — YAML-scheduled auto-updating content blocks in notes (weather, news, prices, status, dashboards). Insert at cursor (Cmd+K) or append to notes.", + summary: "Create and manage tracks — frontmatter directives that keep a note's body auto-updated on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).", content: tracksSkill, }, { diff --git a/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts index 9bc619be..3863b152 100644 --- a/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts @@ -1,7 +1,7 @@ export const skill = String.raw` # Notify User -Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result. +Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track detects something noteworthy, or when an agent wants to ping the user with a clickable result. ## When to use - **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive. @@ -62,7 +62,7 @@ The \`type=file\` path is workspace-relative (the same path you'd pass to \`work ## Anti-patterns - **Don't notify per step** of a multi-step task. Notify on completion, not on progress. -- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification. +- **Don't repeat what's already on screen.** If the result is already in the chat or in a note the user is viewing, skip the notification. - **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link. - **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done". `; 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 c9624c66..eb64c4b5 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 @@ -1,8 +1,8 @@ import { z } from 'zod'; import { stringify as stringifyYaml } from 'yaml'; -import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; +import { TrackSchema } from '@x/shared/dist/track.js'; -const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd(); +const schemaYaml = stringifyYaml(z.toJSONSchema(TrackSchema)).trimEnd(); const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.** @@ -19,7 +19,7 @@ The track agent can emit *rich blocks* — special fenced blocks the editor rend - \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."* - \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."* -You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output. +You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output. - Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`." - Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate." @@ -28,36 +28,93 @@ You **do not** need to write the block body yourself — describe the desired ou export const skill = String.raw` # Tracks Skill -You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor. +A track is a directive in a note's YAML frontmatter (under the ` + "`" + `track:` + "`" + ` array) that turns the note's body into a *living* document — refreshed on a schedule or reactively when a matching email / calendar event arrives. A note with no ` + "`" + `track:` + "`" + ` key is just static; one or more entries under it make it live. Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor). -## First: Just Do It — Do Not Ask About Edit Mode +When this skill is loaded, your job is: set up (or update) a track, run it once so the user immediately sees content, and tell them where to manage it. -Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks. +## Mode: act-first -- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed. -- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit. -- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact. +Track creation and editing are action-first. Read the file, update the frontmatter via ` + "`" + `workspace-edit` + "`" + `, run the track once. Do not ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic document editing, not to tracks. -## What Is a Track Block +- If another skill or earlier turn was waiting on edit-mode permission, treat the track request as implicit "direct mode" and proceed. +- You may ask **one** short clarifying question only when genuinely ambiguous (e.g. *which* note). Never ask about permission to edit. +- The Suggested Topics and Background Agent setup flows below are first-turn-confirmation exceptions — leave those intact. -A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has: -- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata. -- A sibling "target region" — an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run. +## Reading the user's intent -**Concrete example** (a track that shows the current time in Chicago every hour): +You're loaded any time the user might be asking for something dynamic. Two postures, depending on signal strength: -` + "```" + `track -trackId: chicago-time -instruction: | - Show the current time in Chicago, IL in 12-hour format. -active: true -schedule: - type: cron - expression: "0 * * * *" +### Strong signals — act, then confirm + +Just build the track. Don't ask permission. Confirm in one line at the end. + +- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here" +- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here" +- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…" +- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh" +- **Direct**: "track X" — the user used the word; you can too in your reply +- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here" + +### Medium signals — answer the one-off, then offer + +Answer the user's actual question first. Then add a single-line offer to keep it updated. If they say yes, build the track. If they don't engage, leave it — don't push twice. + +- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?" +- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" — especially when in a note context +- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal" +- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo" + +Offer line shape (one line, concrete): +> "I can keep this updated here, refreshing every morning — want that?" + +### Anti-signals — do NOT track + +- Definitional questions ("what is X?") +- One-off lookups ("look up X for me") +- Manual document work ("help me write…", "edit this paragraph…") +- General how-to ("how do I do Y?") + +## What to say to the user + +The user knows the feature as **tracks** and finds them in the **Track sidebar**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "instruction" in user-facing prose unless the user uses them first. + +After creating a track, surface where it lives: +> "Done — I've set up a track here that refreshes every morning. Running it once now so you see content right away. You can manage it from the Track sidebar (Radio icon, top-right of the editor)." + +After editing one: +> "Updated. Re-running now so you can see the new output." + +When skipping a re-run (because the user said not to or "later"): +> "Updated — I'll let it run on its next trigger." + +## What Is a Track (concretely) + +**Concrete example** — a note that shows the current Chicago time, refreshed hourly: + +` + "```" + `markdown +--- +track: + - id: chicago-time + instruction: | + Show the current time in Chicago, IL in 12-hour format. + active: true + triggers: + - type: cron + expression: "0 * * * *" +--- + +# Chicago time + +(empty — the agent will fill this in on the first run) ` + "```" + ` -<!--track-target:chicago-time--> -<!--/track-target:chicago-time--> +After the first run, the body might become: + +` + "```" + `markdown +# Chicago time + +2:30 PM, Central Time +` + "```" + ` Good use cases: - Weather / air quality for a location @@ -66,20 +123,35 @@ Good use cases: - Sports scores - Service status pages - Personal dashboards (today's calendar, steps, focus stats) -- Any recurring summary that decays fast +- Living summaries fed by incoming events (emails, meeting notes) +- Any recurring content that decays fast ## Anatomy -Each track has two parts that live next to each other in the note: +A track lives entirely in the note's frontmatter — there is no inline marker in the body. The agent writes whatever content the instruction demands into the body itself, choosing where to place it based on the existing structure. -1. The ` + "`" + `track` + "`" + ` code fence — contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `. -2. The target-comment region — ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML. +The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file: -The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence. +` + "```" + `markdown +--- +track: + - id: <kebab-id> + instruction: | + <what the agent should produce> + active: true + triggers: + - type: cron + expression: "0 * * * *" +--- + +# Note body +` + "```" + ` + +A note may have multiple entries under ` + "`" + `track:` + "`" + ` — they run independently. Each entry can have multiple triggers (e.g. an hourly cron AND an event trigger). Omit ` + "`" + `triggers` + "`" + ` for a manual-only track. ## Canonical Schema -Below is the authoritative schema for a track block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML: +Below is the authoritative schema for a single track entry (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML: ` + "```" + `yaml ${schemaYaml} @@ -100,18 +172,15 @@ 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. +When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest. -## Choosing a trackId +## Choosing an ` + "`" + `id` + "`" + ` - Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `. -- **Must be unique within the note file.** Before inserting, read the file and check: - - All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks - - All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments +- **Must be unique within the note's ` + "`" + `track:` + "`" + ` array.** Before inserting, read the file and check existing ` + "`" + `id:` + "`" + ` values. - If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `. -- Don't reuse an old ID even if the previous block was deleted — pick a fresh one. +- Don't reuse an old ID even if a previous entry was deleted — pick a fresh one. ## Writing a Good Instruction @@ -122,7 +191,7 @@ Track output lives in a personal knowledge base the user scans frequently. Aim f ### Core Rules - **Specific and actionable.** State exactly what to fetch or compute. -- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle. +- **Single-focus.** One track = one purpose. Split "weather + news + stocks" into three tracks, don't bundle. - **Imperative voice, 1-3 sentences.** - **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items". @@ -163,106 +232,131 @@ ${richBlockMenu} - **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete. - **References to past state** without a mechanism to access it ("as before", "same as last time"). -- **Bundling multiple purposes** into one instruction — split into separate track blocks. +- **Bundling multiple purposes** into one instruction — split into separate tracks. - **Open-ended prose requests** ("tell me about X", "give me thoughts on X"). -- **Output-shape words without a concrete shape** ("dashboard-like", "report-style"). -## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `) +## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or event-trigger ` + "`" + `matchCriteria` + "`" + `) -The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated. - -Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that. +The two free-form fields — ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated. ### The rule: always use a safe scalar style -**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines. +**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `, every time.** ### Preferred: literal block scalar (` + "`" + `|` + "`" + `) ` + "```" + `yaml -instruction: | - Show current local time for India, Chicago, and Indianapolis as a - 3-column markdown table: Location | Local Time | Offset vs India. - One row per location, 24-hour time (HH:MM), no extra prose. - Note: when a location is in DST, reflect that in the offset column. -eventMatchCriteria: | - Emails from the finance team about Q3 budget or OKRs. +track: + - id: world-clock + instruction: | + Show current local time for India, Chicago, and Indianapolis as a + 3-column markdown table: Location | Local Time | Offset vs India. + One row per location, 24-hour time (HH:MM), no extra prose. + active: true + triggers: + - type: cron + expression: "0 * * * *" + - type: event + matchCriteria: | + Emails from the finance team about Q3 budget or OKRs. ` + "```" + ` - ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed. -- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs. -- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line. -- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them. -- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `). +- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs. +- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line. ### Acceptable alternative: double-quoted on a single line -Fine for short single-sentence fields with no newline needs: +Fine for short single-sentence fields: ` + "```" + `yaml -instruction: "Show the current time in Chicago, IL in 12-hour format." -eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions." +track: + - id: chicago-time + instruction: "Show the current time in Chicago, IL in 12-hour format." + active: true ` + "```" + ` -- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `. -- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline. - -### Single-quoted on a single line (only if double-quoted would require heavy escaping) - -` + "```" + `yaml -instruction: 'He said "hi" at 9:00.' -` + "```" + ` - -- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `. -- No other escape sequences work. - ### Do NOT use plain (unquoted) scalars for these two fields -Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not. - -### Editing an existing track - -If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt. +Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits. ### Never-hand-write fields -` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged. +` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged. -## Schedules +## Triggers -Schedule is an **optional** discriminated union. Three types: +A track has zero or more **triggers** under a single ` + "`" + `triggers:` + "`" + ` array. Each trigger is one of four types: -### ` + "`" + `cron` + "`" + ` — recurring at exact times +- ` + "`" + `cron` + "`" + ` — fires at an exact time, recurring +- ` + "`" + `window` + "`" + ` — once per day, anywhere inside a time-of-day band +- ` + "`" + `once` + "`" + ` — one-shot at a future time +- ` + "`" + `event` + "`" + ` — fires when a matching event arrives (emails, calendar, etc.) + +A track can carry **multiple triggers** of any mix. Omit ` + "`" + `triggers` + "`" + ` (or use an empty array) for a **manual-only** track — the user triggers it via the Run button in the sidebar. + +### ` + "`" + `cron` + "`" + ` trigger ` + "```" + `yaml -schedule: - type: cron - expression: "0 * * * *" +triggers: + - type: cron + expression: "0 * * * *" ` + "```" + ` -Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour"). - -### ` + "`" + `window` + "`" + ` — recurring within a time-of-day range +### ` + "`" + `window` + "`" + ` trigger ` + "```" + `yaml -schedule: - type: window - cron: "0 0 * * 1-5" - startTime: "09:00" - endTime: "17:00" +triggers: + - type: window + startTime: "09:00" + endTime: "12:00" ` + "```" + ` -Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `–` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" — flexible timing with bounds. +Fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at ` + "`" + `startTime` + "`" + ` — once a fire lands at-or-after today's start, the trigger is done for the day. Use this when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band. -### ` + "`" + `once` + "`" + ` — one-shot at a future time +### ` + "`" + `once` + "`" + ` trigger ` + "```" + `yaml -schedule: - type: once - runAt: "2026-04-14T09:00:00" +triggers: + - type: once + runAt: "2026-04-14T09:00:00" ` + "```" + ` -Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix. +Local time, no ` + "`" + `Z` + "`" + ` suffix. + +### ` + "`" + `event` + "`" + ` trigger + +` + "```" + `yaml +triggers: + - type: event + matchCriteria: | + Emails about Q3 planning, roadmap decisions, or quarterly OKRs. +` + "```" + ` + +How event triggers work: +1. When a new event arrives, a fast LLM classifier checks each event trigger's ` + "`" + `matchCriteria` + "`" + ` against the event content. +2. If it might match, the track-run agent receives both the event payload and the existing note body, and decides whether to actually update. +3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content. + +### Combining multiple triggers + +A single track can have any combination — e.g. an hourly cron AND an event trigger: + +` + "```" + `yaml +track: + - id: q3-emails + instruction: | + Maintain a running summary of decisions and open questions about Q3 planning. + active: true + triggers: + - type: cron + expression: "0 9 * * 1-5" + - type: event + matchCriteria: | + Emails about Q3 planning, roadmap decisions, or quarterly OKRs. +` + "```" + ` + +This track refreshes on schedule (weekdays at 9am) AND on every relevant incoming email. ### Cron cookbook @@ -273,62 +367,25 @@ Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "` - ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight - ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight -**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** — the user triggers it via the Play button in the UI. - -## Event Triggers (third trigger type) - -In addition to manual and scheduled, a track can be triggered by **events** — incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update: - -` + "```" + `track -trackId: q3-planning-emails -instruction: | - Maintain a running summary of decisions and open questions about Q3 - planning, drawn from emails on the topic. -active: true -eventMatchCriteria: | - Emails about Q3 planning, roadmap decisions, or quarterly OKRs. -` + "```" + ` - -How it works: -1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content. -2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update. -3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content. - -When to suggest event triggers: -- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X"). -- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives"). -- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined — a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events). - -Writing good ` + "`" + `eventMatchCriteria` + "`" + `: -- Be descriptive but not overly narrow — Pass 1 routing is liberal by design. -- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `. - -Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely — they'll only run on schedule or manually. - ## Insertion Workflow **Reminder:** once you have enough to act, act. Do not pause to ask about edit mode. -### Cmd+K with cursor context +### Adding a track to an existing note -When the user invokes Cmd+K, the context includes an attachment mention like: -> User has attached the following files: -> - notes.md (text/markdown) at knowledge/notes.md (line 42) - -Workflow: -1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment. -2. ` + "`" + `workspace-readFile({ path })` + "`" + ` — always re-read fresh. -3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness. -4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text. -5. Construct the full track block (YAML + target pair). -6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `. +1. ` + "`" + `workspace-readFile({ path })` + "`" + ` — re-read fresh. +2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any). Note the existing ` + "`" + `track:` + "`" + ` ids if present. +3. Construct the new track entry as YAML. +4. ` + "`" + `workspace-edit` + "`" + `: + - **If the note has frontmatter and a ` + "`" + `track:` + "`" + ` array already**: anchor on a unique line in/near the array and splice your new entry in. + - **If the note has frontmatter but no ` + "`" + `track:` + "`" + ` array**: anchor on the closing ` + "`" + `---` + "`" + ` of the frontmatter, and insert ` + "`" + `track:\n - id: ...` + "`" + ` etc. just before it. + - **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (` + "`" + `---\n` + "`" + ` ... ` + "`" + `\n---\n` + "`" + ` followed by the original first line). ### Sidebar chat with a specific note 1. If a file is mentioned/attached, read it. 2. If ambiguous, ask one question: "Which note should I add the track to?" -3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair. -4. If the user specified a section ("under the Weather heading"), anchor on that heading. +3. Update the note's frontmatter ` + "`" + `track:` + "`" + ` array using the workflow above. ### No note context at all @@ -344,163 +401,135 @@ In that flow: 1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation. 2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed. 3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists. -4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?". +4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask. 5. Use the card title as the default note title / filename unless a small normalization is clearly needed. -6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. -7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed. +6. Keep the surrounding note scaffolding minimal but useful. The track entry should be the core of the note. ### Background agent setup flow Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet. -In this flow, treat "background agent" and "track block" as the same feature. The user-facing term can stay "background agent", but the implementation is a track block inside a note. Do **not** claim these are different systems, and do **not** redirect the user toward standalone agent files or ` + "`" + `agent-schedule.json` + "`" + ` unless they explicitly ask for that architecture. +In this flow, treat "background agent" and "track" as the same feature. The user-facing term can stay "background agent", but the implementation is a track in a note's frontmatter. Do **not** claim these are different systems. In that flow: 1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run. -2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder or there is a real ambiguity you cannot resolve. +2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder. 3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder. 4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists. -5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup instead of bouncing back to ask. -6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note. +5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup. +6. Keep the surrounding note scaffolding minimal but useful. -## The Exact Text to Insert +## The Exact Frontmatter Shape -Write it verbatim like this (including the blank line between fence and target): +For a brand-new note: -` + "```" + `track -trackId: <id> -instruction: | - <instruction, indented 2 spaces, may span multiple lines> -active: true -schedule: - type: cron - expression: "0 * * * *" +` + "```" + `markdown +--- +track: + - id: <kebab-id> + instruction: | + <instruction, indented 2 spaces, may span multiple lines> + active: true + triggers: + - type: cron + expression: "0 * * * *" +--- + +# <Note title> ` + "```" + ` -<!--track-target:<id>--> -<!--/track-target:<id>--> - **Rules:** -- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `. -- Target pair is **empty on creation**. The runner fills it on the first run. -- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why. +- ` + "`" + `track:` + "`" + ` is at the top level of the frontmatter, never nested. +- Each entry is a list item starting with ` + "`" + `- id:` + "`" + `. 2-space YAML indent. No tabs. +- ` + "`" + `triggers:` + "`" + ` is an array. Omit it for a manual-only track. Multiple entries are allowed (any mix of cron / window / once / event). +- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `. - **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `. -- Use 2-space YAML indent. No tabs. -- Top-level markdown only — never inside a code fence, blockquote, or table. +- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The track agent will edit the body on its first run. -## After Insertion +## After Creating or Editing a Track -- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly." -- **Then offer to run it once now** (see "Running a Track" below) — especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run. -- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself — use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent. +**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the ` + "`" + `run-track` + "`" + ` tool — same as the user clicking Run in the sidebar. -## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool) +Why default-on: +- For event-driven tracks (with ` + "`" + `event` + "`" + ` triggers), the body stays empty until the next matching event arrives. Running once gives the user immediate content. +- For tracks that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill ` + "`" + `context` + "`" + ` (see below) seeds rich initial content. +- After an edit, the user expects to see the updated output without an extra round-trip. -The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button — but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `). +Confirm in one line and tell the user where to find it: +> "Done — I've set up a track refreshing hourly. Running it once now so you see content right away. You can manage it from the Track sidebar." -### When to proactively offer to run +For an edit: +> "Updated. Re-running now so you can see the new output." -These are upsells — ask first, don't run silently. +If you skipped the re-run (user said not to): +> "Updated — I'll let it run on its next trigger." -- **Just created a new track block.** Before declaring done, offer: - > "Want me to run it once now to seed the initial content?" +**Do not** write content into the note body yourself — that's the track agent's job, delegated via ` + "`" + `run-track` + "`" + `. - This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) — otherwise the target region stays empty until the next matching event arrives. +## Using the ` + "`" + `run-track` + "`" + ` tool - For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below). +` + "`" + `run-track` + "`" + ` triggers a single run right now. You can pass an optional ` + "`" + `context` + "`" + ` string to bias *this run only* without modifying the track's instruction — the difference between a stock refresh and a smart backfill. -- **Just edited an existing track.** Offer: - > "Want me to run it now to see the updated output?" +### Backfill ` + "`" + `context` + "`" + ` examples -- **Explicit user request.** "run the X track", "test it", "refresh that block" → call the tool directly. - -### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case) - -The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill. - -**Examples:** - -- New track: "Track emails about Q3 planning" → after creating it, run with: - > context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary." - -- New track: "Summarize this week's customer calls" → run with: +- New event-driven track on Q3 emails → run with: + > context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary." +- New track on this week's customer calls → run with: > context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `." - - Manual refresh after the user mentions a recent change: > context: "Focus on changes from the last 7 days only." +- Plain refresh (user said "run it now"): **omit ` + "`" + `context` + "`" + `**. Don't invent it. -- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context — it can mislead the agent. - -### What to do with the result +### Reading the result The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `: -- **` + "`" + `action: 'replace'` + "`" + `** → the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `: - > "Done — track now shows: 72°F, partly cloudy in Chicago." - -- **` + "`" + `action: 'no_update'` + "`" + `** → the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why. - -- **` + "`" + `error` + "`" + ` set** → surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly. +- ` + "`" + `action: 'replace'` + "`" + ` → body changed. Confirm in one line; optionally cite the first line of ` + "`" + `contentAfter` + "`" + `. +- ` + "`" + `action: 'no_update'` + "`" + ` → agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` usually explains why. +- ` + "`" + `error: 'Already running'` + "`" + ` → another run is in flight; tell the user to retry shortly. +- Other ` + "`" + `error` + "`" + ` → surface concisely. ### Don'ts -- **Don't auto-run** after every edit — ask first. -- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give. -- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool). -- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn — one run per user-facing action. - -## Proactive Suggestions - -When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals: -- "I want to track / monitor / watch / keep an eye on / follow X" -- "Can you check on X every morning / hourly / weekly?" -- The user just asked a one-off question whose answer decays (weather, score, price, status, news). -- The user is building a time-sensitive page (weekly dashboard, morning briefing). - -Suggestion style — one line, concrete: -> "I can turn this into a track block that refreshes hourly — want that?" - -Don't upsell aggressively. If the user clearly wants a one-off answer, give them one. +- **Don't run more than once** per user-facing action — one tool call per turn. +- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — it can mislead the agent. +- **Don't write content into the note body yourself** — always delegate via ` + "`" + `run-track` + "`" + `. ## Don'ts -- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file. -- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track. +- **Don't reuse** an existing ` + "`" + `id` + "`" + ` in the same note's ` + "`" + `track:` + "`" + ` array. +- **Don't add ` + "`" + `triggers` + "`" + `** if the user explicitly wants a manual-only track. - **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` — runtime-managed. -- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence. -- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content. - **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks. - **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` — local time only. - **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file — always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor. ## Editing or Removing an Existing Track -**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines. +**Change triggers or instruction:** ` + "`" + `workspace-edit` + "`" + ` the relevant fields inside the ` + "`" + `track:` + "`" + ` array. Anchor on the unique ` + "`" + `id: <id>` + "`" + ` line plus a few surrounding lines. **Pause without deleting:** flip ` + "`" + `active: false` + "`" + `. -**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty. +**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full track entry (from its ` + "`" + `- id:` + "`" + ` line down to just before the next ` + "`" + `- id:` + "`" + ` line or the closing ` + "`" + `---` + "`" + ` of the frontmatter), ` + "`" + `newString` + "`" + ` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit. ## Quick Reference -Minimal template: +Minimal template (frontmatter only): -` + "```" + `track -trackId: <kebab-id> -instruction: | - <what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces> -active: true -schedule: - type: cron - expression: "0 * * * *" +` + "```" + `yaml +track: + - id: <kebab-id> + instruction: | + <what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces> + active: true + triggers: + - type: cron + expression: "0 * * * *" ` + "```" + ` -<!--track-target:<kebab-id>--> -<!--/track-target:<kebab-id>--> - Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m). -YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing. +YAML style reminder: ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing. `; export default skill; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index c57d4dfc..03ccf0f5 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -28,7 +28,6 @@ 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"; -import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; import type { IBrowserControlService } from "../browser-control/service.js"; import type { INotificationService } from "../notification/service.js"; // Parser libraries are loaded dynamically inside parseFile.execute() @@ -1551,44 +1550,25 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = { }, isAvailable: async () => isComposioConfigured(), }, - 'update-track-content': { - description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.", + 'run-track': { + description: "Manually trigger a track to run now on its host note. Equivalent to the user clicking the Run button on the track in the sidebar, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking track from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.", inputSchema: z.object({ filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"), - trackId: z.string().describe("The track block's trackId"), - content: z.string().describe("The new content to place inside the track's target region"), - }), - execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => { - try { - await updateContent(filePath, trackId, content); - await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() }); - return { success: true, message: `Updated track ${trackId} in ${filePath}` }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { success: false, error: msg }; - } - }, - }, - - 'run-track-block': { - description: "Manually trigger a track block to run now. Equivalent to the user clicking the Play button on the block, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking block from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new content.", - inputSchema: z.object({ - filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"), - trackId: z.string().describe("The track block's trackId (must exist in the file)"), + id: z.string().describe("The track's id (must exist in the note's frontmatter `track:` array)"), context: z.string().optional().describe( - "Optional extra context for the track agent to consider for THIS run only — does not modify the block's instruction. " + + "Optional extra context for the track agent to consider for THIS run only — does not modify the track's instruction. " + "Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " + "or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " + "Omit for a plain refresh." ), }), - execute: async ({ filePath, trackId, context }: { filePath: string; trackId: string; context?: string }) => { + execute: async ({ filePath, id, context }: { filePath: string; id: string; context?: string }) => { const knowledgeRelativePath = filePath.replace(/^knowledge\//, ''); try { // Lazy import to break a module-init cycle: // builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js"); - const result = await triggerTrackUpdate(trackId, knowledgeRelativePath, context, 'manual'); + const result = await triggerTrackUpdate(id, knowledgeRelativePath, context, 'manual'); return { success: !result.error, runId: result.runId, diff --git a/apps/x/packages/core/src/application/lib/parse-frontmatter.ts b/apps/x/packages/core/src/application/lib/parse-frontmatter.ts index 19a44328..a57676b8 100644 --- a/apps/x/packages/core/src/application/lib/parse-frontmatter.ts +++ b/apps/x/packages/core/src/application/lib/parse-frontmatter.ts @@ -1,9 +1,10 @@ -import { parse as parseYaml } from "yaml"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; /** - * Parse the YAML frontmatter from the input string. Returns the frontmatter and content. - * @param input - The input string to parse. - * @returns The frontmatter and content. + * Parse the YAML frontmatter from the input string. Trims the body — handy + * when you only care about the frontmatter or treat the body as opaque + * markdown (e.g. agent instructions). Use {@link splitFrontmatter} when you + * need to round-trip the body byte-for-byte. */ export function parseFrontmatter(input: string): { frontmatter: unknown | null; @@ -24,4 +25,55 @@ export function parseFrontmatter(input: string): { frontmatter: null, content: input, }; -} \ No newline at end of file +} + +/** + * Split a file's frontmatter from its body without trimming or reformatting + * the body. Used by callers that round-trip the file (read → mutate + * frontmatter → re-emit) — preserving body bytes prevents whitespace drift + * across writes. Pair with {@link joinFrontmatter} on the way out. + * + * - `frontmatter` is always an object (empty `{}` if absent or not a map). + * - `body` is the rest of the file verbatim, including any leading/trailing + * whitespace. + */ +export function splitFrontmatter(content: string): { + frontmatter: Record<string, unknown>; + body: string; +} { + if (!content.startsWith('---')) { + return { frontmatter: {}, body: content }; + } + const close = /\r?\n---\r?\n/.exec(content); + if (!close) { + return { frontmatter: {}, body: content }; + } + const yamlText = content.slice(3, close.index).trim(); + const body = content.slice(close.index + close[0].length); + let parsed: unknown = {}; + if (yamlText) { + try { + parsed = parseYaml(yamlText); + } catch { + return { frontmatter: {}, body: content }; + } + } + const frontmatter = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + ? parsed as Record<string, unknown> + : {}; + return { frontmatter, body }; +} + +/** + * Re-emit a file with the given frontmatter object and body. If the + * frontmatter object is empty, no `---` fence is written — the file is body + * only. Pairs with {@link splitFrontmatter}. + */ +export function joinFrontmatter( + frontmatter: Record<string, unknown>, + body: string, +): string { + if (Object.keys(frontmatter).length === 0) return body; + const yamlText = stringifyYaml(frontmatter).replace(/\n$/, ''); + return `---\n${yamlText}\n---\n${body}`; +} 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 fee2b124..c79f86b9 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -1,214 +1,154 @@ import path from 'path'; import fs from 'fs'; -import { stringify as stringifyYaml, parse as parseYaml } from 'yaml'; -import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; +import { stringify as stringifyYaml } from 'yaml'; +import { TrackSchema } from '@x/shared/dist/track.js'; import { WorkDir } from '../config/config.js'; +import { splitFrontmatter } from '../application/lib/parse-frontmatter.js'; import z from 'zod'; const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md'); -interface Section { - heading: string; - track: z.infer<typeof TrackBlockSchema>; -} +// Bump this whenever the canonical Today.md template changes (TRACKS list, +// instructions, default body, etc.). On app start, ensureDailyNote() compares +// the on-disk `templateVersion` against this constant — if older or missing, +// the existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with +// the new template, preserving the body byte-for-byte. +const CANONICAL_DAILY_NOTE_VERSION = 1; -const SECTIONS: Section[] = [ +// Window triggers below fire once per day, anywhere inside their time-of-day +// band — so the user opening the app late in the morning still gets the +// morning run. See schedule-utils.ts for the exact semantics. + +const TRACKS: z.infer<typeof TrackSchema>[] = [ { - heading: '## Up Next', - track: { - trackId: 'up-next', - icon: 'clock', - instruction: -`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today. - -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."). - -Use the event's start time of day ("at 3pm", "this afternoon") rather than a countdown ("in 40 minutes"). Countdowns go stale between syncs. - -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. - -If nothing remains today, output exactly: Clear for the rest of the day. - -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, - }, + id: 'overview', + instruction: +`In a section titled "Overview" at the top of the note: 2–3 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from calendar_sync/ and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Skip the update if the prior content is still suitable and less than 24h old. VERY IMPORTANT: Ensure that image is wide / low-height!`, + active: true, + triggers: [ + // Three windows give the user a fresh ranking morning, midday, and + // post-lunch even with no events landing in between. + { type: 'window', startTime: '08:00', endTime: '12:00' }, + { type: 'window', startTime: '12:00', endTime: '15:00' }, + { type: 'window', startTime: '15:00', endTime: '18:00' }, + ], }, { - heading: '## Calendar', - track: { - trackId: 'calendar', - icon: 'calendar-days', - instruction: -`Emit today's meetings as a calendar block titled "Today's Meetings". - -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: + id: 'calendar', + instruction: +`In a section titled "Calendar", emit today's meetings as a \`calendar\` block titled "Today's Meetings". Read calendar_sync/ via workspace-readdir → workspace-readFile each .json. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a conferenceLink.`, + active: true, + triggers: [{ + type: 'event', + matchCriteria: `Calendar event changes affecting today — additions, updates, cancellations, reschedules.`, - active: true, - }, + }], }, { - heading: '## Emails', - track: { - trackId: 'emails', - icon: 'mail', - instruction: -`Maintain a digest of email threads worth the user's attention today. Output everything as a single fenced code block with language "emails" (plural) — never individual "email" (singular) blocks. The content must be a JSON object: {"title":"Today's Emails","emails":[...]} where each entry has threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response. + id: 'emails', + instruction: +`In a section titled "Emails", maintain a digest of email threads worth attention today. Output everything as a **single** fenced code block with language \`emails\` (plural — never individual \`email\` blocks per thread). The body must be JSON shaped \`{"title":"Today's Emails","emails":[...]}\`. -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 entry for a new threadId, or update the existing entry 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. +Each entry in the array: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads that need a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. -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. +Skip marketing, auto-notifications, and closed threads. Without an event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/), prioritizing threads with frontmatter action = "reply" or "respond". With an event payload, integrate any qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if the threadId is already shown). Do not re-list threads the user has already seen unless their state changed. -If there is genuinely nothing to surface, output the single line: No new emails. - -Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`, - eventMatchCriteria: +If nothing qualifies: "No new emails."`, + active: true, + triggers: [{ + type: 'event', + matchCriteria: `New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`, - active: true, - }, + }], }, { - heading: '## What You Missed', - track: { - trackId: 'what-you-missed', - icon: 'history', - instruction: -`Short markdown summary of what happened yesterday that matters this morning. - -Data sources: -- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md — use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments. -- gmail_sync/ — skim for threads from yesterday that went unresolved or still need a reply. - -Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them. - -Write concise markdown — a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today. - -If nothing notable happened, output exactly: Quiet day yesterday — nothing to flag. - -Do NOT manufacture content to fill the section.`, - active: true, - schedule: { - type: 'cron', - expression: '0 7 * * *', - }, - }, + id: 'what-you-missed', + instruction: +`In a section titled "What you missed", write a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from knowledge/Meetings/<source>/<yesterday>/ (workspace-readdir recursive on knowledge/Meetings, filter folders matching yesterday's date, read each file). Skim gmail_sync/ for threads that went unresolved. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."`, + active: true, + triggers: [ + // Three windows give the user a fresh ranking morning, midday, and + // post-lunch even with no events landing in between. + { type: 'window', startTime: '08:00', endTime: '12:00' }, + { type: 'window', startTime: '12:00', endTime: '15:00' }, + { type: 'window', startTime: '15:00', endTime: '18:00' }, + ], }, { - heading: '## Today\'s Priorities', - track: { - trackId: 'priorities', - icon: 'list-todo', - instruction: -`Ranked markdown list of the real, actionable items the user should focus on today. + id: 'priorities', + instruction: +`In a section titled "Priorities", a ranked markdown list of actionable items the user should focus on today. -Data sources: -- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ — action items assigned to the user are often the most important source. -- knowledge/ — use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups. -- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section — useful for alignment. +Sources: yesterday's meeting action items (knowledge/Meetings/<source>/<yesterday>/), open follow-ups across knowledge/ (workspace-grep for "- [ ]"), the "What you missed" section. -Rules: -- Do NOT list calendar events as tasks — they're already in the Calendar section. -- Do NOT list trivial admin (filing small invoices, archiving spam). -- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review"). -- Add a brief reason for each item when it's not self-evident. +Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline. -If nothing genuinely needs attention, output exactly: No pressing tasks today — good day to make progress on bigger items. +With an event payload (gmail or calendar): re-emit the full list only if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). Otherwise skip the update. -Do NOT invent busywork.`, - active: true, - schedule: { - type: 'cron', - expression: '30 7 * * *', +If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."`, + active: true, + triggers: [ + // Three windows give the user a fresh ranking morning, midday, and + // post-lunch even with no events landing in between. + { type: 'window', startTime: '08:00', endTime: '12:00' }, + { type: 'window', startTime: '12:00', endTime: '15:00' }, + { type: 'window', startTime: '15:00', endTime: '18:00' }, + { + type: 'event', + matchCriteria: +`New or updated email threads that may shift today's priorities — urgent reply requests, deadline-bearing items, escalations from people the user cares about.`, }, - }, + { + type: 'event', + matchCriteria: +`Calendar changes today that may shift priorities — a meeting moved to clash with a deadline, an unexpected event added, a key meeting cancelled freeing up time.`, + }, + ], }, ]; -function buildDailyNoteContent(): string { - const parts: string[] = ['# Today', '']; - for (const { heading, track } of SECTIONS) { - const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd(); - parts.push( - heading, - '', - '```track', - yaml, - '```', - '', - `<!--track-target:${track.trackId}-->`, - `<!--/track-target:${track.trackId}-->`, - '', - ); - } - return parts.join('\n'); +function buildDailyNoteContent(body: string = '# Today\n'): string { + const fm = stringifyYaml( + { templateVersion: CANONICAL_DAILY_NOTE_VERSION, track: TRACKS }, + { lineWidth: 0, blockQuote: 'literal' }, + ).trimEnd(); + return `---\n${fm}\n---\n${body}`; } -function migrateEmojiHeadings(): void { - if (!fs.existsSync(DAILY_NOTE_PATH)) return; - let content = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8'); - const original = content; - const replacements: [string, string][] = [ - ['## ⏱ Up Next', '## Up Next'], - ['## 📅 Calendar', '## Calendar'], - ['## 📧 Emails', '## Emails'], - ['## 📰 What You Missed', '## What You Missed'], - ["## ✅ Today's Priorities", "## Today's Priorities"], - ]; - for (const [from, to] of replacements) { - content = content.split(from).join(to); - } - if (content !== original) { - fs.writeFileSync(DAILY_NOTE_PATH, content, 'utf-8'); - console.log('[DailyNote] Migrated emoji headings'); - } -} - -function migrateTrackIcons(): void { - if (!fs.existsSync(DAILY_NOTE_PATH)) return; - let content = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8'); - const original = content; - - const iconMap = new Map<string, string>( - SECTIONS.flatMap(({ track }) => track.icon ? [[track.trackId, track.icon]] : []) - ); - - content = content.replace(/```track\n([\s\S]*?)\n```/g, (match, yaml) => { - try { - const parsed = parseYaml(yaml) as Record<string, unknown>; - if (!parsed.trackId || parsed.icon) return match; - const icon = iconMap.get(parsed.trackId as string); - if (!icon) return match; - const updated = yaml.replace(/^(trackId: .+)$/m, `$1\nicon: ${icon}`); - return '```track\n' + updated + '\n```'; - } catch { - return match; - } - }); - - if (content !== original) { - fs.writeFileSync(DAILY_NOTE_PATH, content, 'utf-8'); - console.log('[DailyNote] Migrated track icons'); - } +function readCurrentTemplateVersion(): number { + if (!fs.existsSync(DAILY_NOTE_PATH)) return -1; + const raw = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8'); + const { frontmatter } = splitFrontmatter(raw); + const v = frontmatter.templateVersion; + return typeof v === 'number' ? v : 0; } export function ensureDailyNote(): void { - migrateEmojiHeadings(); - migrateTrackIcons(); - if (fs.existsSync(DAILY_NOTE_PATH)) return; + // Fresh install — no existing file. + if (!fs.existsSync(DAILY_NOTE_PATH)) { + fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8'); + console.log(`[DailyNote] Created Today.md (v${CANONICAL_DAILY_NOTE_VERSION})`); + return; + } + + // Up-to-date — nothing to do. + const currentVersion = readCurrentTemplateVersion(); + if (currentVersion >= CANONICAL_DAILY_NOTE_VERSION) return; + + // Migrate aggressively: rename existing → backup, write a fresh canonical + // template (no body carried over). Today.md is a flagship demo whose + // content is meant to be regenerated by the tracks anyway — preserving the + // old body just leaves orphan sections behind on rename/restructure. The + // .bkp file is the recovery path; its name doesn't end in `.md`, so the + // scheduler and event router naturally skip it. Pre-rewrite inline-fence + // notes are caught by this same path. + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`; + fs.renameSync(DAILY_NOTE_PATH, backupPath); fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8'); - console.log('[DailyNote] Created today.md'); + console.log( + `[DailyNote] Migrated v${currentVersion} → v${CANONICAL_DAILY_NOTE_VERSION}; ` + + `previous version saved to ${backupPath}`, + ); } diff --git a/apps/x/packages/core/src/knowledge/track/bus.ts b/apps/x/packages/core/src/knowledge/track/bus.ts index dc664b94..cb8cb89b 100644 --- a/apps/x/packages/core/src/knowledge/track/bus.ts +++ b/apps/x/packages/core/src/knowledge/track/bus.ts @@ -1,4 +1,4 @@ -import type { TrackEventType } from '@x/shared/dist/track-block.js'; +import type { TrackEventType } from '@x/shared/dist/track.js'; type Handler = (event: TrackEventType) => void; diff --git a/apps/x/packages/core/src/knowledge/track/events.ts b/apps/x/packages/core/src/knowledge/track/events.ts index d54ad215..80ddb59e 100644 --- a/apps/x/packages/core/src/knowledge/track/events.ts +++ b/apps/x/packages/core/src/knowledge/track/events.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; -import { PrefixLogger, trackBlock } from '@x/shared'; -import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; +import { PrefixLogger, track } from '@x/shared'; +import type { KnowledgeEvent } from '@x/shared/dist/track.js'; import { WorkDir } from '../../config/config.js'; import * as workspace from '../../workspace/workspace.js'; import { fetchAll } from './fileops.js'; @@ -59,10 +59,17 @@ async function listAllTracks(): Promise<ParsedTrack[]> { continue; } for (const t of parsedTracks) { + const eventCriteria = (t.track.triggers ?? []) + .filter(trig => trig.type === 'event') + .map(trig => trig.matchCriteria) + .filter(Boolean) + .join('; '); + // Skip tracks with no event triggers — they're not event-eligible. + if (!eventCriteria) continue; tracks.push({ - trackId: t.track.trackId, + trackId: t.track.id, filePath, - eventMatchCriteria: t.track.eventMatchCriteria ?? '', + eventMatchCriteria: eventCriteria, instruction: t.track.instruction, active: t.track.active, }); @@ -89,7 +96,7 @@ async function processOneEvent(filename: string): Promise<void> { try { const raw = fs.readFileSync(pendingPath, 'utf-8'); const parsed = JSON.parse(raw); - event = trackBlock.KnowledgeEventSchema.parse(parsed); + event = track.KnowledgeEventSchema.parse(parsed); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.log(`Malformed event ${filename}, moving to done with error:`, msg); diff --git a/apps/x/packages/core/src/knowledge/track/fileops.ts b/apps/x/packages/core/src/knowledge/track/fileops.ts index b7ade8de..a6fd4c73 100644 --- a/apps/x/packages/core/src/knowledge/track/fileops.ts +++ b/apps/x/packages/core/src/knowledge/track/fileops.ts @@ -3,9 +3,10 @@ import fs from 'fs/promises'; import path from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { WorkDir } from '../../config/config.js'; -import { TrackBlockSchema } from '@x/shared/dist/track-block.js'; +import { TrackSchema } from '@x/shared/dist/track.js'; import { TrackStateSchema } from './types.js'; import { withFileLock } from '../file-lock.js'; +import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js'; const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); @@ -13,6 +14,29 @@ function absPath(filePath: string): string { return path.join(KNOWLEDGE_DIR, filePath); } +// --------------------------------------------------------------------------- +// Track-array helpers (read/write the `track:` key in a parsed frontmatter) +// --------------------------------------------------------------------------- + +function getTrackArray(fm: Record<string, unknown>): unknown[] { + const raw = fm.track; + return Array.isArray(raw) ? raw : []; +} + +function setTrackArray(fm: Record<string, unknown>, tracks: unknown[]): Record<string, unknown> { + const next = { ...fm }; + if (tracks.length === 0) { + delete next.track; + } else { + next.track = tracks; + } + return next; +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> { let content: string; try { @@ -20,56 +44,118 @@ export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackSt } catch { return []; } - - const lines = content.split('\n'); - const blocks: z.infer<typeof TrackStateSchema>[] = []; - let i = 0; - const contentFenceStartMatcher = /<!--track-target:(.+)-->/; - const contentFenceEndMatcher = /<!--\/track-target:(.+)-->/; - while (i < lines.length) { - if (lines[i].trim() === '```track') { - const fenceStart = i; - i++; - const blockLines: string[] = []; - while (i < lines.length && lines[i].trim() !== '```') { - blockLines.push(lines[i]); - i++; - } - try { - const data = parseYaml(blockLines.join('\n')); - const result = TrackBlockSchema.safeParse(data); - if (result.success) { - blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' }); - } - } catch { /* skip */ } - } else if (contentFenceStartMatcher.test(lines[i])) { - const match = contentFenceStartMatcher.exec(lines[i]); - if (match) { - const trackId = match[1]; - // have we already collected this track block? - const existingBlock = blocks.find(b => b.track.trackId === trackId); - if (!existingBlock) { - i++; - continue; - } - const contentStart = i + 1; - while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) { - i++; - } - const contentEnd = i; - existingBlock.content = lines.slice(contentStart, contentEnd).join('\n'); - } - } - i++; + const { frontmatter } = splitFrontmatter(content); + const tracks: z.infer<typeof TrackStateSchema>[] = []; + for (const raw of getTrackArray(frontmatter)) { + const result = TrackSchema.safeParse(raw); + if (result.success) tracks.push({ track: result.data }); } - return blocks; + return tracks; } -export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> { - const blocks = await fetchAll(filePath); - return blocks.find(b => b.track.trackId === trackId) ?? null; +export async function fetch(filePath: string, id: string): Promise<z.infer<typeof TrackStateSchema> | null> { + const all = await fetchAll(filePath); + return all.find(t => t.track.id === id) ?? null; } +export async function fetchYaml(filePath: string, id: string): Promise<string | null> { + const t = await fetch(filePath, id); + if (!t) return null; + return stringifyYaml(t.track).trimEnd(); +} + +export async function readNoteBody(filePath: string): Promise<string> { + let content: string; + try { + content = await fs.readFile(absPath(filePath), 'utf-8'); + } catch { + return ''; + } + return splitFrontmatter(content).body; +} + +// --------------------------------------------------------------------------- +// Write +// --------------------------------------------------------------------------- + +function findRawIndex(rawTracks: unknown[], id: string): number { + return rawTracks.findIndex( + (raw) => raw && typeof raw === 'object' && (raw as Record<string, unknown>).id === id, + ); +} + +export async function updateTrack( + filePath: string, + id: string, + updates: Partial<z.infer<typeof TrackSchema>>, +): Promise<void> { + return withFileLock(absPath(filePath), async () => { + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const { frontmatter, body } = splitFrontmatter(content); + const rawTracks = getTrackArray(frontmatter); + const idx = findRawIndex(rawTracks, id); + if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`); + const next = [...rawTracks]; + next[idx] = { ...(rawTracks[idx] as Record<string, unknown>), ...updates }; + const nextFm = setTrackArray(frontmatter, next); + await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8'); + }); +} + +export async function replaceTrackYaml( + filePath: string, + id: string, + newYaml: string, +): Promise<void> { + return withFileLock(absPath(filePath), async () => { + const parsed = TrackSchema.safeParse(parseYaml(newYaml)); + if (!parsed.success) throw new Error(`Invalid track YAML: ${parsed.error.message}`); + if (parsed.data.id !== id) { + throw new Error(`id cannot be changed (was "${id}", got "${parsed.data.id}")`); + } + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const { frontmatter, body } = splitFrontmatter(content); + const rawTracks = getTrackArray(frontmatter); + const idx = findRawIndex(rawTracks, id); + if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`); + const next = [...rawTracks]; + next[idx] = parsed.data; + const nextFm = setTrackArray(frontmatter, next); + await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8'); + }); +} + +export async function deleteTrack(filePath: string, id: string): Promise<void> { + return withFileLock(absPath(filePath), async () => { + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const { frontmatter, body } = splitFrontmatter(content); + const rawTracks = getTrackArray(frontmatter); + const idx = findRawIndex(rawTracks, id); + if (idx === -1) return; // already gone + const next = [...rawTracks]; + next.splice(idx, 1); + const nextFm = setTrackArray(frontmatter, next); + await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8'); + }); +} + +/** + * Replace the note's body. Frontmatter is preserved (including the `track:` + * array). Used by the runner to commit the agent's body edits without granting + * the agent write access to its own runtime state. + */ +export async function writeNoteBody(filePath: string, newBody: string): Promise<void> { + return withFileLock(absPath(filePath), async () => { + const content = await fs.readFile(absPath(filePath), 'utf-8'); + const { frontmatter } = splitFrontmatter(content); + await fs.writeFile(absPath(filePath), joinFrontmatter(frontmatter, newBody), 'utf-8'); + }); +} + +// --------------------------------------------------------------------------- +// Note-level summaries (tracks-list view) +// --------------------------------------------------------------------------- + type TrackNoteSummary = { path: string; trackCount: number; @@ -112,24 +198,19 @@ export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const files: string[] = []; - for (const entry of entries) { if (entry.name.startsWith('.')) continue; - const childRelPath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name; - if (entry.isDirectory()) { files.push(...await walk(childRelPath)); continue; } - if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { files.push(childRelPath); } } - return files; } catch { return []; @@ -156,160 +237,31 @@ export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> { }); } -export async function setNoteTracksActive(filePath: string, active: boolean): Promise<TrackNoteSummary | null> { +export async function setNoteTracksActive( + filePath: string, + active: boolean, +): Promise<TrackNoteSummary | null> { return withFileLock(absPath(filePath), async () => { - const blocks = await fetchAll(filePath); - if (blocks.length === 0) return null; - - const alreadyMatches = blocks.every(({ track }) => (track.active !== false) === active); - if (alreadyMatches) { - return summarizeTrackNote(filePath, blocks); - } - const content = await fs.readFile(absPath(filePath), 'utf-8'); - const lines = content.split('\n'); - const updatedBlocks = blocks - .map((block) => ({ - ...block, - track: { ...block.track, active }, - })) - .sort((a, b) => b.fenceStart - a.fenceStart); + const { frontmatter, body } = splitFrontmatter(content); + const rawTracks = getTrackArray(frontmatter); + if (rawTracks.length === 0) return null; - for (const block of updatedBlocks) { - const yaml = stringifyYaml(block.track).trimEnd(); - const yamlLines = yaml ? yaml.split('\n') : []; - lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); + const allMatch = rawTracks.every( + (raw) => raw && typeof raw === 'object' + && ((raw as Record<string, unknown>).active !== false) === active, + ); + if (!allMatch) { + const updated = rawTracks.map((raw) => + raw && typeof raw === 'object' + ? { ...(raw as Record<string, unknown>), active } + : raw, + ); + const nextFm = setTrackArray(frontmatter, updated); + await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8'); } - await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); - return summarizeTrackNote(filePath, updatedBlocks); - }); -} - -/** - * Fetch a track block and return its canonical YAML string (or null if not found). - * Useful for IPC handlers that need to return the fresh YAML without taking a - * dependency on the `yaml` package themselves. - */ -export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> { - const block = await fetch(filePath, trackId); - if (!block) return null; - return stringifyYaml(block.track).trimEnd(); -} - -export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> { - return withFileLock(absPath(filePath), async () => { - let content = await fs.readFile(absPath(filePath), 'utf-8'); - const openTag = `<!--track-target:${trackId}-->`; - const closeTag = `<!--/track-target:${trackId}-->`; - const openIdx = content.indexOf(openTag); - const closeIdx = content.indexOf(closeTag); - if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { - content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx); - } else { - const block = await fetch(filePath, trackId); - if (!block) { - throw new Error(`Track ${trackId} not found in ${filePath}`); - } - const lines = content.split('\n'); - const insertAt = Math.min(block.fenceEnd + 1, lines.length); - const contentFence = [openTag, newContent, closeTag]; - lines.splice(insertAt, 0, ...contentFence); - content = lines.join('\n'); - } - await fs.writeFile(absPath(filePath), content, 'utf-8'); - }); -} - -export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> { - return withFileLock(absPath(filepath), async () => { - const block = await fetch(filepath, trackId); - if (!block) { - throw new Error(`Track ${trackId} not found in ${filepath}`); - } - block.track = { ...block.track, ...updates }; - - // read file contents - let content = await fs.readFile(absPath(filepath), 'utf-8'); - const lines = content.split('\n'); - const yaml = stringifyYaml(block.track).trimEnd(); - const yamlLines = yaml ? yaml.split('\n') : []; - lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); - content = lines.join('\n'); - await fs.writeFile(absPath(filepath), content, 'utf-8'); - }); -} - -/** - * Replace the entire YAML of a track block on disk with a new string. - * Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim — - * used when the user explicitly edits raw YAML in the modal. - * The new YAML must still parse to a valid TrackBlock with a matching trackId, - * otherwise the write is rejected. - */ -export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> { - return withFileLock(absPath(filePath), async () => { - const block = await fetch(filePath, trackId); - if (!block) { - throw new Error(`Track ${trackId} not found in ${filePath}`); - } - const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml)); - if (!parsed.success) { - throw new Error(`Invalid track YAML: ${parsed.error.message}`); - } - if (parsed.data.trackId !== trackId) { - throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`); - } - - const content = await fs.readFile(absPath(filePath), 'utf-8'); - const lines = content.split('\n'); - const yamlLines = newYaml.trimEnd().split('\n'); - lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```'); - await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); - }); -} - -/** - * Remove a track block and its sibling target region from the file. - */ -export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> { - return withFileLock(absPath(filePath), async () => { - const block = await fetch(filePath, trackId); - if (!block) { - // Already gone — treat as success. - return; - } - - const content = await fs.readFile(absPath(filePath), 'utf-8'); - const lines = content.split('\n'); - const openTag = `<!--track-target:${trackId}-->`; - const closeTag = `<!--/track-target:${trackId}-->`; - - // Find target region (may not exist) - let targetStart = -1; - let targetEnd = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(openTag)) { targetStart = i; } - if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; } - } - - // Build a list of [start, end] ranges to remove, sorted descending so - // indices stay valid as we splice. - const ranges: Array<[number, number]> = []; - ranges.push([block.fenceStart, block.fenceEnd]); - if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) { - ranges.push([targetStart, targetEnd]); - } - ranges.sort((a, b) => b[0] - a[0]); - - for (const [start, end] of ranges) { - lines.splice(start, end - start + 1); - // Also drop a trailing blank line if the removal left two in a row. - if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') { - lines.splice(start, 1); - } - } - - await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8'); + const validated = await fetchAll(filePath); + return summarizeTrackNote(filePath, validated); }); } diff --git a/apps/x/packages/core/src/knowledge/track/routing.ts b/apps/x/packages/core/src/knowledge/track/routing.ts index 49ab29d3..2d8f99e9 100644 --- a/apps/x/packages/core/src/knowledge/track/routing.ts +++ b/apps/x/packages/core/src/knowledge/track/routing.ts @@ -1,6 +1,6 @@ import { generateObject } from 'ai'; -import { trackBlock, PrefixLogger } from '@x/shared'; -import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; +import { track, PrefixLogger } from '@x/shared'; +import type { KnowledgeEvent } from '@x/shared/dist/track.js'; import { createProvider } from '../../models/models.js'; import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js'; import { captureLlmUsage } from '../../analytics/usage.js'; @@ -19,12 +19,12 @@ export interface ParsedTrack { const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system. -You will receive an event (something that happened — an email, meeting, message, etc.) and a list of track blocks. Each track block has: +You will receive an event (something that happened — an email, meeting, message, etc.) and a list of tracks. Each track has: - trackId: an identifier (only unique within its file) - filePath: the note file the track lives in -- eventMatchCriteria: a description of what kinds of signals are relevant to this track +- matchCriteria: a description of what kinds of signals are relevant to this track (collected from the track's event triggers) -Your job is to identify which track blocks MIGHT be relevant to this event. +Your job is to identify which tracks MIGHT be relevant to this event. Rules: - Be LIBERAL in your selections. Include any track that is even moderately relevant. @@ -47,7 +47,7 @@ async function resolveModel() { function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string { const trackList = batch - .map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n eventMatchCriteria: ${t.eventMatchCriteria}`) + .map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n matchCriteria: ${t.eventMatchCriteria}`) .join('\n\n'); return `## Event @@ -58,7 +58,7 @@ Time: ${event.createdAt} ${event.payload} -## Track Blocks +## Tracks ${trackList}`; } @@ -99,7 +99,7 @@ export async function findCandidates( model, system: ROUTING_SYSTEM_PROMPT, prompt: buildRoutingPrompt(event, batch), - schema: trackBlock.Pass1OutputSchema, + schema: track.Pass1OutputSchema, }); captureLlmUsage({ useCase: 'track_block', diff --git a/apps/x/packages/core/src/knowledge/track/run-agent.ts b/apps/x/packages/core/src/knowledge/track/run-agent.ts index 685305b2..fef67ad7 100644 --- a/apps/x/packages/core/src/knowledge/track/run-agent.ts +++ b/apps/x/packages/core/src/knowledge/track/run-agent.ts @@ -3,32 +3,29 @@ import { Agent, ToolAttachment } from '@x/shared/dist/agent.js'; import { BuiltinTools } from '../../application/lib/builtin-tools.js'; import { WorkDir } from '../../config/config.js'; -const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date. +const TRACK_RUN_INSTRUCTIONS = `You are a track runner — a background agent that keeps a live note in the user's personal knowledge base up to date. -Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others — optimize for **information density and scannability**, not conversational prose. +Your goal on each run: update the body of the note so that, given the track's instruction, the content is the most useful and up-to-date version it can be. The user is maintaining a personal knowledge base and will scan this note alongside many others — optimize for **information density and scannability**, not conversational prose. # Background Mode You are running as a scheduled or event-triggered background task — **there is no user present** to clarify, approve, or watch. - Do NOT ask clarifying questions — make the most reasonable interpretation of the instruction and proceed. - Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work. -- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line. +- Do NOT produce chat-style output. The user sees only the changes you make to the note plus your final summary line. # Message Anatomy Every run message has this shape: - Update track **<trackId>** in \`<filePath>\`. + Update track **<id>** in \`<filePath>\`. **Time:** <localized datetime> (<timezone>) **Instruction:** <the user-authored track instruction — usually 1-3 sentences describing what to produce> - **Current content:** - <the existing contents of the target region, or "(empty — first run)"> - - Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`. + Start by calling \`workspace-readFile\` on \`<filePath>\` to read the current note (frontmatter + body). Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file. For **manual** runs, an optional trailing block may appear: @@ -40,20 +37,49 @@ Apply context for this run only — it is not a permanent edit to the instructio For **event-triggered** runs, a trailing block appears instead: **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant) - **Event match criteria for this track:** <from the track's YAML> + **Event match criteria for this track:** <from the track's frontmatter> **Event payload:** <the event body — e.g., an email> **Decision:** ... skip if not relevant ... On event runs you are the Pass 2 judge — see "The No-Update Decision" below. +# Editing the Note + +You have full read/write access to the note body via the standard workspace tools: +- \`workspace-readFile\` — read the current state of the note (frontmatter included; you can ignore the frontmatter). +- \`workspace-edit\` — apply patches. +- \`workspace-writeFile\` — replace the entire file (use sparingly; prefer \`workspace-edit\`). + +**Do NOT modify the YAML frontmatter at the top of the file** (the \`---\`-delimited block). It contains the track configuration and runtime state owned by the user and the runtime. Editing it can corrupt the track's schedule, history, or the note's metadata. + +# Section Placement + +Each track's instruction may name a **section** in the note where its content lives — e.g. *"in a section titled 'Overview' at the top"* or *"in a section titled 'Photo' right after Overview"*. You own that section and only that section. + +How to handle sections: + +- Sections are H2 headings (\`## Section Name\`). Match by exact heading text. +- **If the named section exists**: replace its content (everything between that heading and the next H2 — or end of file) with your new output. Heading itself stays intact. +- **If the section is missing**: create it. Use the placement hint to decide where: + - "at the top" → just below the H1 title (or first line if there's none). + - "after X" → immediately after section X. If X doesn't exist either, fall back to natural reading order. + - no hint → append to the end of the body. +- **Never modify another track's section content.** Other agents own those. +- **Never duplicate a section.** If two H2 headings match yours, consolidate into the first. +- The user may rename your section's heading. If you can't find it by exact name on a later run, recreate it per the placement hint. + +After writing your section, **re-check its position**. The first time tracks run on a fresh note, sections land in firing order rather than reading order, so the file ends up out of sequence. If your section is now in the wrong place relative to your placement hint (e.g. your "Photo" section is meant to sit right after "Overview" but ended up at the bottom), **move your own section block** (your H2 heading + its content, no surrounding blank lines lost) to the correct position. Cut-and-paste only — never rewrite or reorder *other* tracks' sections; they will self-correct on their own next runs. + +A section can hold prose, lists, or rich blocks (calendar/email/image/etc.) per the instruction. You always write a **complete** replacement for the section you own — not a diff. + # What Good Output Looks Like -This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information. +This is a personal knowledge tracker. The user scans many such notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information. - **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs. -- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative — do not improvise a different layout. +- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. - **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks. -- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know. +- **No commentary or caveats** unless the data itself is genuinely uncertain. - **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately. If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events → \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.). @@ -62,7 +88,7 @@ If the instruction does not specify a format, pick the tightest shape that fits: The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card. -Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs. +Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs. ## \`table\` — tabular data (JSON) @@ -178,7 +204,7 @@ Use for: linking to a video or design that should render inline. } \`\`\` -Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form. +Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. ## \`iframe\` — arbitrary embedded webpage (JSON) @@ -227,43 +253,26 @@ The instruction was authored in a prior conversation you cannot see. Treat it as Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") — these are decoration. -# Current Content Handling - -The **Current content** block shows what lives in the target region right now. Three cases: - -1. **"(empty — first run)"** — produce the content from scratch. -2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep. -3. **Content that does NOT match the instruction's format** — the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch. - -You always write a **complete** replacement, not a diff. - # The No-Update Decision -You may finish a run without calling \`update-track-content\`. Two legitimate cases: +You may finish a run without writing anything. Two legitimate cases: -1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update. -2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically. +1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information, skip the update. +2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system records "no update" automatically. When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*. -# Writing the Result - -Call \`update-track-content\` **at most once per run**: -- Pass \`filePath\` and \`trackId\` exactly as given in the message. -- Pass the **complete** new content as \`content\` — the entire replacement for the target region. -- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those. -- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only. - # Tools You have the full workspace toolkit. Quick reference for common cases: +- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off. - **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace. -- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data. +- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data. - **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files. - **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized. - **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering. -- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view). +- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes. # The Knowledge Graph @@ -284,7 +293,7 @@ Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration): - Do **not** fabricate or speculate. -- Do **not** write partial or placeholder content into the target region — leave existing content intact by not calling \`update-track-content\`. +- Do **not** write partial or placeholder content — leave the existing body intact by skipping the edit. - Explain the failure in the summary line. # Final Summary @@ -310,7 +319,7 @@ export function buildTrackRunAgent(): z.infer<typeof Agent> { return { name: 'track-run', - description: 'Background agent that updates track block content', + description: 'Background agent that keeps a track-driven note up to date', instructions: TRACK_RUN_INSTRUCTIONS, tools, }; diff --git a/apps/x/packages/core/src/knowledge/track/runner.ts b/apps/x/packages/core/src/knowledge/track/runner.ts index ab48d12e..048af97f 100644 --- a/apps/x/packages/core/src/knowledge/track/runner.ts +++ b/apps/x/packages/core/src/knowledge/track/runner.ts @@ -1,5 +1,5 @@ import z from 'zod'; -import { fetchAll, updateTrackBlock } from './fileops.js'; +import { fetchAll, updateTrack, readNoteBody } from './fileops.js'; import { createRun, createMessage } from '../../runs/runs.js'; import { getTrackBlockModel } from '../../models/defaults.js'; import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js'; @@ -31,30 +31,41 @@ function buildMessage( const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' }); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - let msg = `Update track **${track.track.trackId}** in \`${filePath}\`. + // Workspace-relative path the agent's tools (workspace-readFile, + // workspace-edit) expect. Internal fileops storage is knowledge/-relative, + // so always prefix here when handing it to the agent. + const wsPath = `knowledge/${filePath}`; + + let msg = `Update track **${track.track.id}** in \`${wsPath}\`. **Time:** ${localNow} (${tz}) **Instruction:** ${track.track.instruction} -**Current content:** -${track.content || '(empty — first run)'} - -Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`; +Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`; if (trigger === 'event') { + const eventCriteria = (track.track.triggers ?? []) + .filter(t => t.type === 'event') + .map(t => t.matchCriteria) + .filter(Boolean); + const criteriaText = eventCriteria.length === 0 + ? '(none — should not happen for event-triggered runs)' + : eventCriteria.length === 1 + ? eventCriteria[0] + : eventCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n'); msg += ` **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below) **Event match criteria for this track:** -${track.track.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)'} +${criteriaText} **Event payload:** ${context ?? '(no payload)'} -**Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call \`update-track-content\`. Only call the tool if the event provides new or changed information that should be reflected in the track.`; +**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that should be reflected in the note.`; } else if (context) { msg += `\n\n**Context:**\n${context}`; } @@ -73,7 +84,7 @@ const runningTracks = new Set<string>(); // --------------------------------------------------------------------------- /** - * Trigger an update for a specific track block. + * Trigger an update for a specific track. * Can be called by any trigger system (manual, cron, event matching). */ export async function triggerTrackUpdate( @@ -94,17 +105,14 @@ export async function triggerTrackUpdate( try { const tracks = await fetchAll(filePath); logger.log('fetched tracks from file', tracks); - const track = tracks.find(t => t.track.trackId === trackId); + const track = tracks.find(t => t.track.id === trackId); if (!track) { logger.log('track not found', trackId, filePath, trigger, context); return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' }; } - const contentBefore = track.content; + const bodyBefore = await readNoteBody(filePath); - // 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', @@ -116,7 +124,7 @@ export async function triggerTrackUpdate( // Set lastRunAt and lastRunId immediately (before agent executes) so // the scheduler's next poll won't re-trigger this track. - await updateTrackBlock(filePath, trackId, { + await updateTrack(filePath, trackId, { lastRunAt: new Date().toISOString(), lastRunId: agentRun.id, }); @@ -134,12 +142,11 @@ export async function triggerTrackUpdate( await waitForRunCompletion(agentRun.id); const summary = await extractAgentResponse(agentRun.id); - const updatedTracks = await fetchAll(filePath); - const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content; - const didUpdate = contentAfter !== contentBefore; + const bodyAfter = await readNoteBody(filePath); + const didUpdate = bodyAfter !== bodyBefore; - // Update summary on completion - await updateTrackBlock(filePath, trackId, { + // Patch summary into frontmatter on completion. + await updateTrack(filePath, trackId, { lastRunSummary: summary ?? undefined, }); @@ -155,8 +162,8 @@ export async function triggerTrackUpdate( trackId, runId: agentRun.id, action: didUpdate ? 'replace' : 'no_update', - contentBefore: contentBefore ?? null, - contentAfter: contentAfter ?? null, + contentBefore: bodyBefore, + contentAfter: bodyAfter, summary, }; } catch (err) { @@ -170,7 +177,7 @@ export async function triggerTrackUpdate( error: msg, }); - return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg }; + return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg }; } } finally { runningTracks.delete(key); diff --git a/apps/x/packages/core/src/knowledge/track/schedule-utils.ts b/apps/x/packages/core/src/knowledge/track/schedule-utils.ts index 69dd2c60..3463c1c9 100644 --- a/apps/x/packages/core/src/knowledge/track/schedule-utils.ts +++ b/apps/x/packages/core/src/knowledge/track/schedule-utils.ts @@ -1,14 +1,24 @@ import { CronExpressionParser } from 'cron-parser'; -import type { TrackSchedule } from '@x/shared/dist/track-block.js'; +import type { Trigger } from '@x/shared/dist/track.js'; const GRACE_MS = 2 * 60 * 1000; // 2 minutes +/** Subset of Trigger that fires on a clock — the schedulable types. */ +export type TimedTrigger = Extract<Trigger, { type: 'cron' | 'window' | 'once' }>; + /** - * Determine if a scheduled track is due to run. - * All schedule types enforce a 2-minute grace period — if the scheduled time - * was more than 2 minutes ago, it's considered a miss and skipped. + * Determine if a timed trigger is due to fire. + * + * - `cron` and `once` enforce a 2-minute grace window — if the scheduled time + * was more than 2 minutes ago, it's considered a miss and skipped (avoids + * replay storms after the app was offline at the trigger time). + * - `window` is forgiving: it fires at most once per day, anywhere inside the + * configured time-of-day band. The day's cycle is anchored at `startTime` — + * once a fire lands at-or-after today's startTime, the trigger is done for + * the day. Use this for tracks that should "happen sometime in the morning" + * rather than "at exactly 8:00am." */ -export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string | null): boolean { +export function isTriggerDue(schedule: TimedTrigger, lastRunAt: string | null): boolean { const now = new Date(); switch (schedule.type) { @@ -34,7 +44,7 @@ export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string | } } case 'window': { - // Time-of-day filter (applies regardless of lastRunAt state). + // Must be inside the time-of-day band. const [startHour, startMin] = schedule.startTime.split(':').map(Number); const [endHour, endMin] = schedule.endTime.split(':').map(Number); const startMinutes = startHour * 60 + startMin; @@ -43,16 +53,17 @@ export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string | if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false; if (!lastRunAt) return true; - try { - const interval = CronExpressionParser.parse(schedule.cron, { - currentDate: now, - }); - const prevRun = interval.prev().toDate(); - if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false; - return now.getTime() <= prevRun.getTime() + GRACE_MS; - } catch { - return false; - } + + // Daily cycle anchored at startTime. If we've already fired + // strictly after today's startTime, skip until tomorrow. The + // strict comparison (>, not >=) means a fire happening exactly + // at a window boundary belongs to the earlier window — so two + // adjacent windows sharing an endpoint (e.g. 08–12 and 12–15) + // each still get their own fire on the same day. + const cycleStart = new Date(now); + cycleStart.setHours(startHour, startMin, 0, 0); + if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false; + return true; } case 'once': { if (lastRunAt) return false; // Already ran diff --git a/apps/x/packages/core/src/knowledge/track/scheduler.ts b/apps/x/packages/core/src/knowledge/track/scheduler.ts index 19327dc7..d894e6d9 100644 --- a/apps/x/packages/core/src/knowledge/track/scheduler.ts +++ b/apps/x/packages/core/src/knowledge/track/scheduler.ts @@ -2,7 +2,7 @@ import { PrefixLogger } from '@x/shared'; import * as workspace from '../../workspace/workspace.js'; import { fetchAll } from './fileops.js'; import { triggerTrackUpdate } from './runner.js'; -import { isTrackScheduleDue } from './schedule-utils.js'; +import { isTriggerDue, type TimedTrigger } from './schedule-utils.js'; const log = new PrefixLogger('TrackScheduler'); const POLL_INTERVAL_MS = 15_000; // 15 seconds @@ -33,17 +33,23 @@ async function processScheduledTracks(): Promise<void> { for (const trackState of tracks) { const { track } = trackState; if (!track.active) continue; - if (!track.schedule) continue; + if (!track.triggers || track.triggers.length === 0) continue; - const due = isTrackScheduleDue(track.schedule, track.lastRunAt ?? null); - log.log(`Track "${track.trackId}" in ${relativePath}: schedule=${track.schedule.type}, lastRunAt=${track.lastRunAt ?? 'never'}, due=${due}`); + const timed: TimedTrigger[] = track.triggers.filter( + (t): t is TimedTrigger => t.type !== 'event', + ); + if (timed.length === 0) continue; - if (due) { - log.log(`Triggering "${track.trackId}" in ${relativePath}`); - triggerTrackUpdate(track.trackId, relativePath, undefined, 'timed').catch(err => { - log.log(`Error running ${track.trackId}:`, err); - }); + const dueTrigger = timed.find(t => isTriggerDue(t, track.lastRunAt ?? null)); + if (!dueTrigger) { + log.log(`Track "${track.id}" in ${relativePath}: ${timed.length} timed trigger(s), none due`); + continue; } + + log.log(`Triggering "${track.id}" in ${relativePath} (matched ${dueTrigger.type})`); + triggerTrackUpdate(track.id, relativePath, undefined, 'timed').catch(err => { + log.log(`Error running ${track.id}:`, err); + }); } } } diff --git a/apps/x/packages/core/src/knowledge/track/types.ts b/apps/x/packages/core/src/knowledge/track/types.ts index cdf0f1b2..cae5ab06 100644 --- a/apps/x/packages/core/src/knowledge/track/types.ts +++ b/apps/x/packages/core/src/knowledge/track/types.ts @@ -1,9 +1,6 @@ import z from "zod"; -import { TrackBlockSchema } from "@x/shared/dist/track-block.js"; +import { TrackSchema } from "@x/shared/dist/track.js"; export const TrackStateSchema = z.object({ - track: TrackBlockSchema, - fenceStart: z.number(), - fenceEnd: z.number(), - content: z.string(), -}); \ No newline at end of file + track: TrackSchema, +}); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index cde673b8..266b0726 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -9,7 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js'; export * as serviceEvents from './service-events.js' export * as inlineTask from './inline-task.js'; export * as blocks from './blocks.js'; -export * as trackBlock from './track-block.js'; +export * as track from './track.js'; export * as promptBlock from './prompt-block.js'; export * as frontmatter from './frontmatter.js'; export * as bases from './bases.js'; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d827c32e..d03dd128 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; -import { TrackEvent } from './track-block.js'; +import { TrackEvent } from './track.js'; import { UserMessageContent } from './message.js'; import { RowboatApiConfig } from './rowboat-account.js'; import { ZListToolkitsResponse } from './composio.js'; @@ -614,7 +614,7 @@ const ipcSchemas = { // Track channels 'track:run': { req: z.object({ - trackId: z.string(), + id: z.string(), filePath: z.string(), }), res: z.object({ @@ -625,22 +625,22 @@ const ipcSchemas = { }, 'track:get': { req: z.object({ - trackId: z.string(), + id: z.string(), filePath: z.string(), }), res: z.object({ success: z.boolean(), - // Fresh, authoritative YAML of the track block from disk. - // Renderer should use this for display/edit — never its Tiptap node attr. + // Fresh, authoritative YAML of the track from frontmatter. + // Renderer should use this for display/edit — never a stale cached copy. yaml: z.string().optional(), error: z.string().optional(), }), }, 'track:update': { req: z.object({ - trackId: z.string(), + id: z.string(), filePath: z.string(), - // Partial TrackBlock updates — merged into the block's YAML on disk. + // Partial Track updates — merged into the entry on disk. // Backend is the sole writer; avoids races with scheduler/runner writes. updates: z.record(z.string(), z.unknown()), }), @@ -652,7 +652,7 @@ const ipcSchemas = { }, 'track:replaceYaml': { req: z.object({ - trackId: z.string(), + id: z.string(), filePath: z.string(), yaml: z.string(), }), @@ -664,7 +664,7 @@ const ipcSchemas = { }, 'track:delete': { req: z.object({ - trackId: z.string(), + id: z.string(), filePath: z.string(), }), res: z.object({ diff --git a/apps/x/packages/shared/src/track-block.ts b/apps/x/packages/shared/src/track.ts similarity index 61% rename from apps/x/packages/shared/src/track-block.ts rename to apps/x/packages/shared/src/track.ts index cbb370cc..17812f03 100644 --- a/apps/x/packages/shared/src/track-block.ts +++ b/apps/x/packages/shared/src/track.ts @@ -1,33 +1,54 @@ import z from 'zod'; -export const TrackScheduleSchema = z.discriminatedUnion('type', [ +// --------------------------------------------------------------------------- +// Triggers — when a track fires +// --------------------------------------------------------------------------- +// +// A track can carry zero or more triggers under the `triggers:` key. +// Each trigger is one of: +// - cron: exact time, recurring +// - window: once per day, anywhere inside a time-of-day band +// - once: one-shot at a future time +// - event: driven by incoming signals (emails, calendar events, etc.) +// +// A track can have multiple triggers — e.g. a daily cron trigger AND an event +// trigger. Omit `triggers` (or pass an empty array) for a manual-only track. +// --------------------------------------------------------------------------- + +export const TriggerSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('cron').describe('Fires at exact cron times'), expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'), }).describe('Recurring at exact times'), z.object({ - type: z.literal('window').describe('Fires at most once per cron occurrence, only within a time-of-day window'), - cron: z.string().describe('5-field cron expression, quoted'), - startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'), - endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'), - }).describe('Recurring within a time-of-day window'), + type: z.literal('window').describe('Fires once per day, anywhere inside a time-of-day band'), + startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the track fires after this time, it won\'t fire again until the next day.'), + endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'), + }).describe('Recurring within a daily time-of-day window'), z.object({ type: z.literal('once').describe('Fires once and never again'), runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'), }).describe('One-shot future run'), -]).describe('Optional schedule. Omit entirely for manual-only tracks.'); + z.object({ + type: z.literal('event').describe('Fires when a matching event arrives'), + matchCriteria: z.string().describe('Describe the kinds of events that should consider this track for an update (e.g. "Emails about Q3 planning"). Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload.'), + }).describe('Event-driven'), +]); -export type TrackSchedule = z.infer<typeof TrackScheduleSchema>; +export type Trigger = z.infer<typeof TriggerSchema>; -export const TrackBlockSchema = z.object({ - trackId: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'), +// --------------------------------------------------------------------------- +// Track entity +// --------------------------------------------------------------------------- + +export const TrackSchema = z.object({ + id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'), instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'), - 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(), + triggers: z.array(TriggerSchema).optional().describe('When this track fires. A track can have multiple triggers — e.g. an hourly cron AND an event trigger. Omit (or use an empty array) for a manual-only track.'), 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.'), - icon: z.string().optional().describe('Lucide icon name for the chip (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'), + icon: z.string().optional().describe('Lucide icon name for status display (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'), 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'), @@ -59,7 +80,7 @@ export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>; export const Pass1OutputSchema = z.object({ candidates: z.array(z.object({ - trackId: z.string().describe('The track block identifier'), + trackId: z.string().describe('The track identifier'), filePath: z.string().describe('The note file path the track lives in'), })).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'), }); @@ -86,5 +107,5 @@ export const TrackRunCompleteEvent = z.object({ export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]); -export type TrackBlock = z.infer<typeof TrackBlockSchema>; +export type Track = z.infer<typeof TrackSchema>; export type TrackEventType = z.infer<typeof TrackEvent>; From 9014c79f2c853c9e9fed6934ff1329d7eaeabc5d Mon Sep 17 00:00:00 2001 From: Gagancreates <gaganp000999@gmail.com> Date: Fri, 8 May 2026 00:26:57 +0530 Subject: [PATCH 049/143] feat: render html files in knowledge view via sandboxed iframe --- apps/x/KNOWLEDGE_FILE_VIEWER.md | 246 ++++++++++++++++++ apps/x/PLAN.md | 83 ++++++ apps/x/apps/renderer/src/App.tsx | 10 + .../src/components/html-file-viewer.tsx | 38 +++ apps/x/test-fixtures/html-viewer-test.html | 104 ++++++++ 5 files changed, 481 insertions(+) create mode 100644 apps/x/KNOWLEDGE_FILE_VIEWER.md create mode 100644 apps/x/PLAN.md create mode 100644 apps/x/apps/renderer/src/components/html-file-viewer.tsx create mode 100644 apps/x/test-fixtures/html-viewer-test.html diff --git a/apps/x/KNOWLEDGE_FILE_VIEWER.md b/apps/x/KNOWLEDGE_FILE_VIEWER.md new file mode 100644 index 00000000..55d0f688 --- /dev/null +++ b/apps/x/KNOWLEDGE_FILE_VIEWER.md @@ -0,0 +1,246 @@ +# Knowledge File Viewer — Research & Implementation Plan + +## Current State + +The gap is a single `<pre>` fallback in `App.tsx:4523–4527`. The decision tree today: + +``` +selectedPath ends in .md → MarkdownEditor (full ProseMirror, works great) +selectedPath is anything else → <pre> raw text dump ← THIS IS THE ENTIRE GAP +``` + +Everything else needed already exists: + +| What's needed | What exists | Where | +|---|---|---| +| Read binary files | `shell:readFileBase64` IPC handler | `apps/main/src/ipc.ts:648–667` | +| Read text files | `workspace:readFile` with `encoding` param | `packages/shared/src/ipc.ts:55–67` | +| File type detection | `attachment-presentation.ts` utilities | `renderer/src/lib/attachment-presentation.ts` | +| Audio player component | `AudioFileCard` (base64 → `<audio>`) | `renderer/src/components/ai-elements/file-path-card.tsx` | +| Image thumbnail | `SystemFileCard` (base64 → `<img>`) | Same file as above | +| Navigate to knowledge path | `onOpenKnowledgeFile` context | `renderer/src/contexts/file-card-context.tsx` | + +The 10MB cap on `shell:readFileBase64` is the main constraint to watch. + +--- + +## Recommended Architecture + +### The Core Idea: `app://` Custom Protocol + +**Never use `file://` for serving local content.** In Electron, `file://` has elevated same-origin privileges — an HTML file loaded that way can read other files from the filesystem. + +Register a custom scheme **before `app.whenReady()`** in `apps/main/src/main.ts`: + +```typescript +protocol.registerSchemesAsPrivileged([{ + scheme: 'app', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true // CRITICAL for video seeking (byte-range requests) + } +}]); +``` + +Then in the handler, resolve paths inside the workspace root and block traversal: + +```typescript +protocol.handle('app', (req) => { + const filePath = resolveAndGuard(req.url, WORKSPACE_ROOT); + if (!filePath) return new Response('Forbidden', { status: 403 }); + return net.fetch(pathToFileURL(filePath).toString()); +}); +``` + +This single protocol handles images, video, DOCX, and HTML all from one place. + +--- + +## File Type Strategy + +### Images (PNG, JPG, WEBP, GIF, SVG, AVIF) + +**Approach:** Native `<img>` via `app://` protocol. + +```tsx +<img src={`app://local/${encodeURIComponent(relativePath)}`} className="max-w-full" /> +``` + +- Chromium renders all of these natively. Zero dependencies. +- HEIC/HEIF is not natively supported on Windows — use `sharp` in main process to convert to JPEG first. +- Strip EXIF before sending to LLM (GPS data). `sharp` does this automatically on JPEG output. + +--- + +### Video (MP4, WebM, MOV) + +**Approach:** Native `<video>` via `app://` protocol with `stream: true`. + +```tsx +<video controls src={`app://local/${encodeURIComponent(relativePath)}`} className="w-full" /> +``` + +`stream: true` is the only non-obvious requirement — it enables HTTP byte-range requests so scrubbing/seeking works. Without it, the entire file downloads before playback starts. + +**Supported formats:** H.264/AAC in MP4, WebM (VP8/VP9/AV1). MKV partially. For WMV/AVI on Windows, fall back to "Open in system." + +**Do NOT route through `shell:readFileBase64`** — 10MB cap will silently fail on real video files. The custom protocol streams directly from disk. + +--- + +### PDF + +**Approach:** Chromium's built-in PDFium renderer via `<webview>` with `plugins: true`. + +```tsx +<webview + src={`app://local/${encodeURIComponent(relativePath)}`} + webpreferences="plugins=on,javascript=off,contextIsolation=on" + sandbox + style={{ width: '100%', height: '100%' }} +/> +``` + +Requires `webviewTag: true` in the parent BrowserWindow's `webPreferences`. Zero bundle size cost — Chromium already ships PDFium. Native zoom, scroll, print. + +**Alternative if you need text extraction / annotations:** `pdfjs-dist` in a sandboxed iframe. ~35MB bundle cost, but gives you page events, text selection, and highlight APIs. Overkill unless annotation features are planned. + +--- + +### HTML Files + +**Approach:** Sandboxed `<webview>` in an isolated session partition, with **all network blocked**. + +```tsx +<webview + src={`app://local/${encodeURIComponent(relativePath)}`} + partition="sandbox-html" + webpreferences="contextIsolation=on,nodeIntegration=off" + sandbox +/> +``` + +In `main.ts`, create the partition and block all outbound network: + +```typescript +const sandboxSession = session.fromPartition('sandbox-html', { cache: false }); +sandboxSession.setPermissionRequestHandler((_, __, cb) => cb(false)); +sandboxSession.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_, cb) => + cb({ cancel: true }) +); +``` + +Relative assets (`./style.css`, `./images/photo.jpg`) served via the `app://` handler still work. External requests are silently blocked. + +--- + +### DOCX / DOC + +**Approach:** `docx-preview` for display, `mammoth.js` for LLM text extraction. They solve different problems — do not use them as alternatives. + +- **`docx-preview`** — reproduces Word's visual layout in the DOM (tables, fonts, headings, images as base64). High fidelity for reading. +- **`mammoth.js`** — converts to clean semantic HTML, strips all visual formatting. For feeding document content to the model. + +```typescript +// display +import { renderAsync } from 'docx-preview'; +const buffer = await window.api.readFileBytes(filePath); // needs new IPC handler +await renderAsync(buffer, containerElement); + +// LLM extraction +import mammoth from 'mammoth'; +const { value: html } = await mammoth.convertToHtml({ arrayBuffer: buffer }); +``` + +A new `read-file-bytes` IPC handler is needed in `main/src/ipc.ts` that returns a raw `Uint8Array` — the existing `shell:readFileBase64` returns a base64 string which would need decoding. + +--- + +## Split-Pane Layout + +**Recommended library: `react-resizable-panels`** (Brian Vaughn, React core team alum). Powers `shadcn/ui`'s `<Resizable>` component. Used in production by OpenAI and Adobe. + +```tsx +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; + +<PanelGroup direction="horizontal" autoSaveId="knowledge-chat-layout"> + <Panel defaultSize={55} minSize={30}> + <FileViewer path={selectedPath} /> + </Panel> + <PanelResizeHandle className="w-1.5 bg-border hover:bg-primary/50 transition-colors" /> + <Panel defaultSize={45} minSize={25}> + <ChatView /> + </Panel> +</PanelGroup> +``` + +`autoSaveId` persists the split ratio to `localStorage` automatically across sessions. + +**Alternative: `allotment`** — extracted directly from VS Code's C++ split-view code. Pixel-identical to VS Code. Slightly less React-idiomatic API. + +--- + +## Security Model + +| Concern | Pattern | +|---|---| +| Local file access | Main process only via `ipcMain.handle`. Renderer never reads filesystem directly. | +| Protocol | Custom `app://` scheme, not `file://`. All local resources routed through validated handler. | +| Path traversal | Every path resolved to absolute, checked with `startsWith(WORKSPACE_ROOT)`. | +| Renderer isolation | `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`. | +| Untrusted HTML | Separate `session.fromPartition('sandbox-html')` with network blocked. | + +--- + +## Implementation Steps + +### Step 1 — Register `app://` protocol in `main.ts` +Before `app.whenReady()`. One change, covers images, video, PDF, and HTML. + +### Step 2 — Add `read-file-bytes` IPC handler in `ipc.ts` +Returns raw `Uint8Array` for DOCX rendering. Avoids base64 encode/decode overhead for large files. + +### Step 3 — Create `KnowledgeFileViewer` component +`apps/x/apps/renderer/src/components/knowledge-file-viewer.tsx` + +Extension routing: + +| Extensions | Renderer | +|---|---| +| `.png .jpg .jpeg .webp .gif .svg .avif` | `<img>` via `app://` | +| `.mp4 .mov .webm` | `<video>` via `app://` | +| `.pdf` | `<webview plugins sandbox>` | +| `.html .htm` | `<webview partition="sandbox-html">` | +| `.docx .doc` | `docx-preview` in sandboxed iframe | +| `.mp3 .wav .m4a` | Reuse existing `AudioFileCard` | +| everything else | "Open in system" button (`shell.openPath`) | + +### Step 4 — Replace `<pre>` fallback in `App.tsx:4522–4527` +One-line swap. All routing logic lives in `KnowledgeFileViewer`. + +### Step 5 — Add split-pane layout +Install `react-resizable-panels`, wrap knowledge view (file viewer + chat) in `PanelGroup`. + +--- + +## Dependencies to Add + +| Package | Purpose | Bundle cost | +|---|---|---| +| `react-resizable-panels` | Split pane layout | ~15KB | +| `docx-preview` | DOCX visual rendering | ~500KB | +| `mammoth` | DOCX → semantic HTML for LLM | ~300KB | +| `pdfjs-dist` | PDF with text extraction (optional) | ~35MB — only if PDFium isn't enough | + +Images, video, PDF (via PDFium), and HTML have zero additional dependencies. + +--- + +## What to Avoid + +- **`<iframe src="file:///...">` for anything** — always use `app://`. +- **Routing large files through `shell:readFileBase64`** — 10MB cap silently fails. +- **Using `mammoth` for display** — it strips all formatting. LLM extraction only. +- **Assuming `webviewTag` is enabled** — check `main.ts` BrowserWindow creation before shipping PDF/HTML webviews. diff --git a/apps/x/PLAN.md b/apps/x/PLAN.md new file mode 100644 index 00000000..188c06d2 --- /dev/null +++ b/apps/x/PLAN.md @@ -0,0 +1,83 @@ +# HTML File Rendering — Implementation Plan + +## Goal +Replace the `<pre>` raw text fallback in the knowledge view with a proper HTML file renderer using `<iframe srcdoc sandbox="allow-scripts">`. + +## Scope +- Only HTML file rendering for now +- No layout changes, no split pane +- No other file types in this PR + +--- + +## Phase 1 — IPC: Read HTML file content and pass to renderer + +### What +Add an IPC handler in the main process that reads a local HTML file and returns its content as a string to the renderer. + +### Work +1. Add `knowledge:readHtmlFile` handler in `apps/main/src/ipc.ts` + - Accepts a workspace-relative path + - Resolves to absolute path, validates it stays inside workspace root (path traversal guard) + - Reads file as UTF-8 string + - Returns the HTML string to renderer +2. Add the channel type to `packages/shared/src/ipc.ts` + +### Test ✅ +- Open a `.html` file from the knowledge tree +- Console log the returned string in the renderer +- Verify: correct HTML content is returned, no errors +- Verify: attempting a path like `../../secret.txt` is rejected with an error + +--- + +## Phase 2 — Renderer: Detect `.html` files and render in iframe + +### What +In `App.tsx`, detect when `selectedPath` is an `.html` file and render it in a sandboxed `<iframe srcdoc>` instead of the `<pre>` fallback. + +### Work +1. In the file loading logic (`App.tsx:1284–1357`), when extension is `.html`: + - Call `knowledge:readHtmlFile` via IPC + - Store the HTML string in state +2. In the knowledge view render switch (`App.tsx:4522–4527`): + - Add a condition: if extension is `.html` → render `<HtmlFileViewer html={htmlContent} />` + - Otherwise fall through to existing `<pre>` fallback +3. Create `apps/renderer/src/components/html-file-viewer.tsx`: + - Accepts `html: string` prop + - Renders `<iframe srcdoc={html} sandbox="allow-scripts" />` with full width/height, no border + +### Test ✅ +- Open a real `.html` file from the knowledge tree +- Verify: file renders visually in the iframe (not raw text) +- Verify: a non-html file still shows the `<pre>` fallback (no regression) +- Verify: an HTML file with a `<script>` tag runs its JS (allow-scripts works) +- Verify: an HTML file with `<script src="https://evil.com">` — open network tab, confirm no request is made (allow-same-origin is absent, so external scripts are blocked by default CSP) + +--- + +## Phase 3 — Polish: Loading state, error state, empty file handling + +### What +Handle edge cases so the viewer never shows a broken or confusing UI. + +### Work +1. Loading state — show a spinner while the IPC call is in flight +2. Error state — if `knowledge:readHtmlFile` throws (file deleted, permission error), show a clean error message with the file path +3. Empty file — if HTML string is empty, show "This file is empty" instead of a blank iframe +4. Large files — if file is over a reasonable size limit (e.g. 5MB), show "File too large to preview — Open in system" button that calls `shell.openPath` + +### Test ✅ +- Open a valid HTML file → renders correctly +- Delete the file while it's open, trigger a reload → error state shown cleanly +- Open an empty `.html` file → "This file is empty" message shown +- Simulate a file over 5MB → "File too large" message with open button shown +- Verify: no console errors in any of the above scenarios + +--- + +## Out of scope for this PR +- PDF, DOCX, image, video rendering +- Split pane / resizable layout +- Relative asset loading (`./style.css`) — Phase 2 uses `srcdoc` which has no base URL; assets will not load. Acceptable for now, documented as known limitation. +- `app://` custom protocol — not needed until we handle relative assets diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index ec54e95b..96ae3742 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -13,6 +13,7 @@ import { ChatInputWithMentions, type StagedAttachment } from './components/chat- import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; +import { HtmlFileViewer } from '@/components/html-file-viewer'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; @@ -1424,6 +1425,11 @@ function App() { } const requestId = (fileLoadRequestIdRef.current += 1) const pathToLoad = selectedPath + // For HTML files, clear stale content immediately so the viewer shows + // its loading state instead of rendering the previous file's bytes. + if (pathToLoad.toLowerCase().endsWith('.html') || pathToLoad.toLowerCase().endsWith('.htm')) { + setFileContent('') + } let cancelled = false ;(async () => { try { @@ -4819,6 +4825,10 @@ function App() { /> )} </div> + ) : selectedPath?.toLowerCase().endsWith('.html') || selectedPath?.toLowerCase().endsWith('.htm') ? ( + <div className="flex-1 min-h-0 overflow-hidden"> + <HtmlFileViewer html={fileContent} path={selectedPath} /> + </div> ) : ( <div className="flex-1 overflow-auto p-4"> <pre className="text-sm font-mono text-foreground whitespace-pre-wrap"> diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx new file mode 100644 index 00000000..3aceb117 --- /dev/null +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react' +import { Loader2Icon } from 'lucide-react' + +interface HtmlFileViewerProps { + html: string + path: string +} + +export function HtmlFileViewer({ html, path }: HtmlFileViewerProps) { + const [iframeLoaded, setIframeLoaded] = useState(false) + + useEffect(() => { + setIframeLoaded(false) + }, [path, html]) + + const showSpinner = !html || !iframeLoaded + + return ( + <div className="relative h-full w-full"> + {html && ( + <iframe + key={path} + srcDoc={html} + sandbox="allow-scripts" + className="h-full w-full border-0 bg-white" + title="HTML preview" + onLoad={() => setIframeLoaded(true)} + /> + )} + {showSpinner && ( + <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background text-muted-foreground"> + <Loader2Icon className="size-6 animate-spin" /> + <p className="text-sm">Rendering preview…</p> + </div> + )} + </div> + ) +} diff --git a/apps/x/test-fixtures/html-viewer-test.html b/apps/x/test-fixtures/html-viewer-test.html new file mode 100644 index 00000000..7e5eeb6e --- /dev/null +++ b/apps/x/test-fixtures/html-viewer-test.html @@ -0,0 +1,104 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>HTML Viewer Test + + + +

HTML Viewer Test Page

+ +
+

1. Inline Styles

+

If this page looks styled and clean, inline CSS works.

+
+ +
+

2. Interactive JavaScript

+

Counter: 0

+ +

If clicking increments the counter, allow-scripts works.

+
+ +
+

3. Sandbox Verification

+

The next button tries to access window.parent:

+ +

Click to test.

+
+ +
+

4. External Network Request

+

Tries to fetch from a remote URL (should fail due to no allow-same-origin):

+ +

Click to test.

+
+ +
+

5. Table Rendering

+ + + + + + + +
FeatureStatus
Inline CSSWorking
Inline JSWorking
Sandbox isolationActive
+
+ +
+

6. Auto-run script on load

+

Script did not run.

+ +
+ + From 754561d893f1eacf8b1915401729047d6b97866d Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 8 May 2026 00:59:33 +0530 Subject: [PATCH 050/143] feat: add error, empty, and oversize states to html viewer --- apps/x/apps/renderer/src/App.tsx | 7 +- .../src/components/html-file-viewer.tsx | 97 +++++++++++++++++-- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 96ae3742..057aacb8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1425,10 +1425,11 @@ function App() { } const requestId = (fileLoadRequestIdRef.current += 1) const pathToLoad = selectedPath - // For HTML files, clear stale content immediately so the viewer shows - // its loading state instead of rendering the previous file's bytes. + // HtmlFileViewer self-loads (with size check, error states, etc.) + // Skip the generic loader so we don't double-fetch large files. if (pathToLoad.toLowerCase().endsWith('.html') || pathToLoad.toLowerCase().endsWith('.htm')) { setFileContent('') + return } let cancelled = false ;(async () => { @@ -4827,7 +4828,7 @@ function App() { ) : selectedPath?.toLowerCase().endsWith('.html') || selectedPath?.toLowerCase().endsWith('.htm') ? (
- +
) : (
diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 3aceb117..41e7326c 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -1,33 +1,114 @@ import { useEffect, useState } from 'react' -import { Loader2Icon } from 'lucide-react' +import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' + +const MAX_SIZE_BYTES = 5 * 1024 * 1024 + +type ViewerState = + | { kind: 'loading' } + | { kind: 'loaded'; html: string } + | { kind: 'empty' } + | { kind: 'tooLarge'; sizeMB: number } + | { kind: 'error'; message: string } interface HtmlFileViewerProps { - html: string path: string } -export function HtmlFileViewer({ html, path }: HtmlFileViewerProps) { +export function HtmlFileViewer({ path }: HtmlFileViewerProps) { + const [state, setState] = useState({ kind: 'loading' }) const [iframeLoaded, setIframeLoaded] = useState(false) useEffect(() => { + let cancelled = false + setState({ kind: 'loading' }) setIframeLoaded(false) - }, [path, html]) - const showSpinner = !html || !iframeLoaded + ;(async () => { + try { + const stat = await window.ipc.invoke('workspace:stat', { path }) + if (cancelled) return + if (stat.kind !== 'file') { + setState({ kind: 'error', message: 'Selected path is not a file.' }) + return + } + if (stat.size > MAX_SIZE_BYTES) { + setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) }) + return + } + const result = await window.ipc.invoke('workspace:readFile', { path }) + if (cancelled) return + if (!result.data || result.data.trim() === '') { + setState({ kind: 'empty' }) + return + } + setState({ kind: 'loaded', html: result.data }) + } catch (err) { + if (cancelled) return + const message = err instanceof Error ? err.message : String(err) + setState({ kind: 'error', message }) + } + })() + + return () => { + cancelled = true + } + }, [path]) + + if (state.kind === 'error') { + return ( +
+ +

Could not load preview

+

{state.message}

+

{path}

+
+ ) + } + + if (state.kind === 'empty') { + return ( +
+ +

This file is empty

+
+ ) + } + + if (state.kind === 'tooLarge') { + return ( +
+ +

File too large to preview

+

+ {state.sizeMB.toFixed(1)} MB — preview limit is {(MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB. +

+ +
+ ) + } return (
- {html && ( + {state.kind === 'loaded' && (