diff --git a/CLAUDE.md b/CLAUDE.md index 51a11e35..75a0f6a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,8 @@ 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` | +| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.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/.gitignore b/apps/x/.gitignore index c2658d7d..db195fb4 100644 --- a/apps/x/.gitignore +++ b/apps/x/.gitignore @@ -1 +1,2 @@ node_modules/ +test-fixtures/ diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md new file mode 100644 index 00000000..572e9a6f --- /dev/null +++ b/apps/x/ANALYTICS.md @@ -0,0 +1,158 @@ +# 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 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. + +## 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` / `live_note_agent` / `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` | +| `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` | +| `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) | +| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site | +| `live_note_agent` | `window` | yes | Pass 2 agent run — fired inside a configured time-of-day window | same call site | +| `live_note_agent` | `event` | yes | Pass 2 agent run — Pass 1 routing flagged the note for an incoming event | same call site | +| `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) | + +##### `live_note_agent` sub-use-case shape + +For the live-note feature specifically, `sub_use_case` discriminates **what kind of work happened**: + +- `routing` — Pass 1 LLM classifier deciding which live notes might be relevant to an incoming event. One emit per Pass 1 batch. +- `manual` / `cron` / `window` / `event` — Pass 2 agent run, tagged with the trigger that woke it up. The runner reads its `trigger` argument (`LiveNoteTriggerType`) and passes it directly as `subUseCase`, so dashboards can break runs down by trigger source. + +This means a single end-to-end event flow emits both `routing` (Pass 1) and `event` (Pass 2). A scheduled cron fire emits only `cron`. A user clicking Run emits only `manual`. There is no separate "run" sub-use-case anymore — the trigger IS the sub-use-case for Pass 2. + +`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/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md new file mode 100644 index 00000000..fe31d019 --- /dev/null +++ b/apps/x/LIVE_NOTE.md @@ -0,0 +1,408 @@ +# Live Notes + +> A single `live:` frontmatter block that turns a markdown note into a self-updating artifact — refreshed on a schedule (cron / windows), in response to incoming events (Gmail, Calendar), or on demand. + +A live note has exactly **one** `live:` block in its YAML frontmatter. The block carries a persistent **objective** (what the note should keep being), an optional **triggers** object (when the agent should fire), and runtime fields the system writes back. The body below the H1 is owned by the live-note agent — it freely synthesizes, dedupes, and reorganizes the content to satisfy the objective. A note with no `live:` key is just a static note. + +**Example** (a note that shows the current Chicago time, refreshed hourly): + +~~~markdown +--- +live: + objective: | + Show the current time in Chicago, IL in 12-hour format. Keep it as one + short line, no extra prose. + active: true + triggers: + cronExpr: "0 * * * *" + lastAttemptAt: "2026-05-08T15:00:00.123Z" + lastRunAt: "2026-05-08T15:00:01.234Z" + lastRunId: "..." + lastRunSummary: "Updated — 3:00 PM, Central Time." + lastRunError: null +--- + +# Chicago time + +3:00 PM, Central Time +~~~ + +## Table of Contents + +1. [Product Overview](#product-overview) +2. [Architecture at a Glance](#architecture-at-a-glance) +3. [Technical Flows](#technical-flows) +4. [Schema Reference](#schema-reference) +5. [Body Structure](#body-structure) +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 + +### One note, one objective + +A live note has at most one `live:` block. The block has exactly one `objective`. The objective can be long and cover multiple sub-topics — the agent treats the note holistically and is free to lay out the body however the objective suggests. **There is no second objective per note.** When the user asks Copilot to "also keep an eye on X" in an already-live note, Copilot is trained to extend the existing objective in natural language rather than fork a second block. + +This is intentional: the user is *delegating awareness*, not configuring automations. Multiple agents per note led to ownership confusion, scope boundaries, and orchestration concerns that don't fit a personal-knowledge tool. + +### Triggers + +The `triggers` object has three independently optional sub-fields. Each one is its own channel; mix freely. + +| Field | When it fires | Shape | +|---|---|---| +| **`cronExpr`** | At exact cron times | `cronExpr: "0 * * * *"` | +| **`windows`** | Once per day per window, anywhere inside a time-of-day band | `windows: [{ startTime: "09:00", endTime: "12:00" }]` | +| **`eventMatchCriteria`** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` | + +A `triggers` block with no fields (or no `triggers` key at all) is **manual-only** — the agent fires only when the user clicks Run in the panel. + +`cronExpr` enforces 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. `windows` are forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, the agent fires the moment the app is open. Each window's daily cycle is anchored at `startTime`. + +The `once` trigger from the prior model has been **dropped** — it didn't fit the "ongoing awareness" framing. + +### Creating a live note + +Two paths, both producing identical on-disk YAML: + +1. **Hand-written** — type the `live:` block directly into the note's frontmatter. 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 "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. + +When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block. + +### Viewing and managing live notes + +The editor toolbar has a Radio-icon button (right side) that opens the **Live Note panel** for the current note. The panel: + +- **Empty state** (passive note) — "Make this note live" CTA that hands off to Copilot for the natural-language flow. +- **Editor** — single panel with: objective textarea, triggers editor (cron / windows list / eventMatchCriteria, each independently shown via add/remove), status row (last-run summary + active toggle), Advanced (collapsed: model + provider), footer (Edit with Copilot · Save · Run now), and a danger-zone "Make passive" button. +- **Status hook** — `useLiveNoteAgentStatus` subscribes to `live-note-agent:events` IPC; the Run button shows a spinner whenever the agent is running. + +Every mutation 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 the runtime agent does + +When a trigger fires, the live-note agent receives a short message: +- The workspace-relative path to the note and a localized timestamp. +- The objective. +- 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"). + +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. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites. +3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first. +4. Never modify YAML frontmatter — that's owned by the user and the runtime. +5. End with a 1-2 sentence summary stored as `lastRunSummary`. + +The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP). + +--- + +## Architecture at a Glance + +``` +Editor toolbar Radio button ─click──► LiveNoteSidebar (React) + │ + ├──► IPC: live-note:get / set / + │ setActive / delete / run + │ +Backend (main process) + ├─ Scheduler loop (15 s) ──┐ + ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent + └─ Builtin tool │ │ + run-live-note-agent ────┘ ▼ + workspace-readFile / -edit + │ + ▼ + body region(s) rewritten on disk + frontmatter lastRun* patched +``` + +**Single-writer invariant** — the renderer is never a file writer for the `live:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/live-note/fileops.ts` (`setLiveNote`, `patchLiveNote`, `setLiveNoteActive`, `deleteLiveNote`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `live:` 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-live-note-panel', { detail: { filePath } }))` is the sole entry point from editor toolbar → panel. `rowboat:open-copilot-edit-live-note` opens the Copilot sidebar with the note attached. + +--- + +## Technical Flows + +### Scheduling (cron / windows) + +- **Module**: `packages/core/src/knowledge/live-note/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`). +- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, `fetchLiveNote(relPath)` for each. +- For each note with a `live:` block where `active !== false`, `dueTimedTrigger(triggers, lastRunAt)` returns `'cron'`, `'window'`, or `null` — pure cycle check, no backoff. The scheduler then calls `backoffRemainingMs(lastAttemptAt)` separately so it can log "matched cron, backoff 4m remaining" rather than collapse the two reasons. +- When due AND not in backoff, fire `runLiveNoteAgent(relPath, source)` where `source` is `'cron'` or `'window'` (the granular trigger surfaces all the way to the agent message — see Trigger granularity). +- **Cycle anchoring** — anchored on `lastRunAt`, which is bumped only on *successful* completions. A failed run leaves the cycle unfired so the scheduler retries. +- **Backoff** — `RETRY_BACKOFF_MS = 5 min`. If `lastAttemptAt` is within that window, the scheduler skips the note. Covers both in-flight runs (the in-memory concurrency guard handles the common case; backoff is the disk-persistent backstop) and post-failure storming. Manual runs (clicked Run) bypass this — they don't go through the scheduler. +- **Cron grace** — `cronExpr` enforces a 2-minute grace; missed schedules are skipped, not replayed. +- **Windows** have no grace — anywhere inside the band counts. A failed run inside the band leaves the window unfired; the next eligible tick (after backoff) retries. +- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a *successful* 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** — `initLiveNoteScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initLiveNoteEventProcessor()`. + +### Event pipeline + +**Producers** — any data source that should feed live notes emits events: +- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: })`. +- **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/live-note/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/.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. `listEventEligibleLiveNotes()` scans every `.md` under `knowledge/`. Only notes where `live.active !== false` and `live.triggers?.eventMatchCriteria` is set are event-eligible. +3. `findCandidates(event, eligible)` runs Pass 1 LLM routing (below). +4. For each candidate, `runLiveNoteAgent(filePath, 'event', event.payload)` **sequentially** — preserves total ordering within the event. +5. Enrich the event JSON with `processedAt`, `candidateFilePaths`, `runIds`, `error?`, then move to `events/done/.json`. + +**Pass 1 routing** (`routing.ts`): +- **Short-circuit** — if `event.targetFilePath` is set (manual re-run events), skip the LLM and return that note directly. +- Batches of `BATCH_SIZE = 20`. +- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `{ filePaths: string[] }`. Direct path-based dedup (no composite key needed since live-note is one-per-file). + +**Pass 2 decision** happens inside the live-note agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body. + +### Trigger granularity + +Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | 'event'` — propagated end-to-end through `runLiveNoteAgent(filePath, trigger, context?)`, the `liveNoteBus` start event, and the `live-note-agent:events` IPC payload. + +- The **scheduler** passes `'cron'` or `'window'` based on which sub-trigger `dueTimedTrigger` matched. +- The **event processor** always passes `'event'`. +- The **panel Run button** and the **`run-live-note-agent` builtin tool** both pass `'manual'`. + +`buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context). + +This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". + +### Run flow (`runLiveNoteAgent`) + +Module: `packages/core/src/knowledge/live-note/runner.ts`. + +1. **Concurrency guard** — static `runningLiveNotes: Set` keyed by `filePath`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. +2. **Fetch live note** via `fetchLiveNote(filePath)`. +3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff. +4. **Create agent run** — `createRun({ agentId: 'live-note-agent' })`. +5. **Bump `lastAttemptAt` + `lastRunId` immediately** (before the agent executes). `lastAttemptAt` is the disk-persistent backoff anchor — the scheduler suppresses fires within `RETRY_BACKOFF_MS` (5 min) of it, covering both in-flight runs and post-failure backoff. **`lastRunAt` is not touched here** — that field is the cycle anchor and should only move on success. +6. **Emit `live_note_agent_start`** on the `liveNoteBus` with the trigger type (`manual` / `timed` / `event`). +7. **Send agent message** built by `buildMessage(filePath, live, 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. **On success:** `patchLiveNote(filePath, { lastRunAt: now, lastRunSummary, lastRunError: undefined })`. +11. **On failure:** `patchLiveNote(filePath, { lastRunError: msg })`. **`lastRunAt` and `lastRunSummary` are deliberately untouched** so the user keeps seeing the last good state in the UI, and the scheduler treats the cycle as unfired (windows will retry inside the same band, gated only by the 5-min backoff). +12. **Emit `live_note_agent_complete`** with `summary` or `error`. +13. **Cleanup**: `runningLiveNotes.delete(filePath)` in a `finally` block. + +Returned to callers: `{ filePath, runId, action, contentBefore, contentAfter, summary, error? }`. + +**Stops** — when the user clicks Stop in the panel, `live-note:stop` resolves the latest `lastRunId` and calls `runsCore.stop(runId, false)`. The runner's `waitForRunCompletion` throws, the failure branch records `lastRunError`, and the bus emits `complete` with the error. The cycle stays unfired (so the run is retried on the next tick after backoff expires) — exactly the same path as any other failure. + +### IPC surface + +| Channel | Caller → handler | Purpose | +|---|---|---| +| `live-note:run` | Renderer (panel Run button) | Fires `runLiveNoteAgent(..., 'manual')` | +| `live-note:get` | Panel on open | Returns the parsed `LiveNote \| null` from frontmatter | +| `live-note:set` | Panel save | Validates + writes the whole `live:` block | +| `live-note:setActive` | Panel toggle | Flips `active` | +| `live-note:delete` | Panel "Make passive" | Removes the entire `live:` block | +| `live-note:stop` | Panel Stop button | Resolves the live block's `lastRunId` and calls `runsCore.stop(runId)` | +| `live-note:listNotes` | Background-agents view | Lists all live notes with summary fields | +| `live-note-agent:events` | Server → renderer (`webContents.send`) | Forwards `liveNoteBus` events to `useLiveNoteAgentStatus` | + +Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/live-note/fileops.ts`. + +### Concurrency & FIFO guarantees + +- **Per-note serialization** — the `runningLiveNotes` guard in `runner.ts`. A note is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`. +- **Backend is single writer for `live:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `live:` 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 note 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/live-note.ts`: + +- `LiveNoteSchema` — the entire `live:` block. Fields: `objective`, `active` (default true), `triggers?`, `model?`, `provider?`. **Runtime-managed (never hand-write):** `lastAttemptAt`, `lastRunAt`, `lastRunId`, `lastRunSummary`, `lastRunError`. +- `TriggersSchema` — single object with three optional sub-fields: `cronExpr`, `windows`, `eventMatchCriteria`. Each window is `{ startTime, endTime }` (24-hour HH:MM, local). +- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidateFilePaths`, `runIds`, `error`) are populated when moving to `done/`. +- `Pass1OutputSchema` — `{ filePaths: string[] }`. + +The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` — so editing `LiveNoteSchema` propagates to the skill on the next build. + +--- + +## Body Structure + +The agent owns the entire body below the H1. There is **no formal section ownership** anymore — the agent edits, reorganizes, and dedupes freely. + +The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/live-note/agent.ts`): + +- **Defaults** (used when the objective doesn't specify a layout): + - H1 stays the note title. + - First, a 1-3 sentence rolling summary capturing the current state. + - Then content organized by sub-topic under H2 headings, freshest/most-important first. + - Tightness over decoration. +- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly. +- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. +- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. + +--- + +## Default Note Policy + +Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block. + +**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start: + +- File missing → mark processed and do nothing. +- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body. +- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again. + +--- + +## Renderer UI + +- **Toolbar pill** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon pill with a state-dependent label, top-right of the editor toolbar. `markdown-editor.tsx` derives the state via `useLiveNoteForPath(notePath)` and passes a `LivePillState` prop: + - `passive` → muted "Make live" label. + - `idle` → "Live · 5 m" using `formatRelativeTime(lastRunAt)`. + - `running` → "Updating…" with `animate-pulse` and a soft `bg-primary/10` highlight. + - `error` → "Live · failed 5 m" in amber, off `lastAttemptAt`. + Click dispatches `rowboat:open-live-note-panel` with `{ filePath }`. The hook ticks once a minute so the relative-time label stays fresh while the user has the editor open. +- **Panel** — `apps/renderer/src/components/live-note-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-live-note-panel`; on open, calls `live-note:get` and renders. All mutations go through IPC. + - Constant top header: Radio icon, "Live note" title, note name subtitle, X close. + - Empty state (passive): "Make this note live" CTA — hands off to Copilot via `rowboat:open-copilot-edit-live-note`. + - Editor (live): status row (schedule summary + active toggle — pulses with `animate-pulse` and `bg-primary/10` while running, label flips to "Updating…"), persistent error banner showing `lastRunError` until the next successful run, objective textarea, triggers editor (cron field + windows list + eventMatchCriteria textarea, each independently add/remove), last-run details, Advanced (collapsed; model + provider), footer (Edit with Copilot · Save · Run now / Stop), danger-zone "Make passive". The footer's primary action toggles between Run-now (idle) and Stop (running, destructive variant) — Stop calls `live-note:stop`. +- **Status hook** — `apps/renderer/src/hooks/use-live-note-agent-status.ts`. Subscribes to `live-note-agent:events` IPC and maintains a `Map`. +- **Live-state hook** — `apps/renderer/src/hooks/use-live-note-for-path.ts`. Fetches `live-note:get` on mount, refetches when the agent run completes (so `lastRunAt` / `lastRunSummary` / `lastRunError` are fresh), refetches when the file changes externally, and ticks once a minute for relative-time labels. Used by the markdown editor (toolbar pill) and could be reused by anyone needing reactive live-note state for a single path. +- **Edit-with-Copilot flow** — panel dispatches `rowboat:open-copilot-edit-live-note` (App.tsx listener handles it via `submitFromPalette`). +- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` has `STRUCTURED_KEYS = new Set(['live'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `live:` block back from `preserveRaw` on save. + +--- + +## Prompts Catalog + +Every LLM-facing prompt in the feature, with file pointers. 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 live notes *might* be relevant to an incoming event. Liberal — prefers false positives; the live-note agent does Pass 2. +- **File**: `packages/core/src/knowledge/live-note/routing.ts` (`ROUTING_SYSTEM_PROMPT`). +- **Output**: structured `Pass1OutputSchema` — `{ filePaths: string[] }`. +- **Invoked by**: `findCandidates()` per batch of 20 notes via `generateObject({ model, system, prompt, schema })`. + +### 2. Routing user prompt template + +- **Purpose**: formats the event and the current batch of live notes into the user message for Pass 1. +- **File**: `packages/core/src/knowledge/live-note/routing.ts` (`buildRoutingPrompt`). +- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedLiveNote[]` (each: `filePath`, `objective`, `eventMatchCriteria`). +- **Output**: plain text, two sections — `## Event` and `## Live notes`. + +### 3. Live-note agent instructions + +- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the default body structure, prescribes patch-style updates, points at the knowledge graph. +- **File**: `packages/core/src/knowledge/live-note/agent.ts` (`LIVE_NOTE_AGENT_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**: `buildLiveNoteAgent()`, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`. + +### 4. Live-note agent message (`buildMessage`) + +- **Purpose**: the user message seeded into each agent run. +- **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`). +- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (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) and to make patch-style edits. + +Three branches by `trigger`: +- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` 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 listing the note's `eventMatchCriteria` and the event payload, with the directive to skip the edit if the event isn't truly relevant. + +### 5. Live Note skill (Copilot-facing) + +- **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills. +- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant. +- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` is interpolated into the "Canonical Schema" section. Edits to `LiveNoteSchema` propagate automatically. +- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('live-note')` fires. +- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`. + +### 6. Copilot trigger paragraph + +- **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded. +- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live 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 make live)**: definitional questions, one-off lookups, manual document editing. +- **Extend-not-fork rule**: explicit guidance — "if the note is already live, extend its existing objective in natural language; never create a second objective." + +### 7. `run-live-note-agent` tool — `context` parameter description + +- **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-live-note-agent` tool definition). +- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), optional `context`. +- **Output**: flows into `runLiveNoteAgent(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message. +- **Key use case**: backfill a newly-made-live note so its body 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` (`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. + +--- + +## Logging + +All live-note logs use the `PrefixLogger` with the prefix `LiveNote:` so they're greppable as a group. Every component logs lifecycle events at one consistent level. + +| Component | Prefix | What it logs | +|---|---|---| +| Scheduler | `LiveNote:Scheduler` | One tick summary per tick when work happened (`tick — scanned N md, K live, fired J, backoff M`). Per-note ` — firing (matched cron)` and ` — skip (matched window, backoff 4m remaining)`. Quiet when no live notes or none due. | +| Agent (runner) | `LiveNote:Agent` | ` — start trigger=cron runId=…`, ` — done action=replace summary="…"` (truncated to 120 chars), ` — failed: `, ` — skip: already running`. | +| Routing | `LiveNote:Routing` | `event: — routing against N live notes`, `event: — Pass1 → K candidates: a.md, b.md`, `event: — Pass1 batch X failed: …`. | +| Events | `LiveNote:Events` | `event: — received source=gmail type=email.synced`, `event: — dispatching to K candidates: …`, `event: — processed ok=2 errors=0`. | +| Fileops | (only logs failures) | Lock contention or write errors. Otherwise silent. | + +Conventions: +- Lower-case verbs (`firing`, `skip`, `done`, `failed`) so lines scan visually. +- File path is always the second token where applicable. +- Run summaries truncated to 120 chars with a single `…` so log lines stay under terminal-width. +- Scheduler emits *one* tick summary per tick, not a row per note. Per-note rows only when something fires or hits a notable skip. + +## File Map + +| Purpose | File | +|---|---| +| Zod schemas (live note, triggers, events, Pass1) | `packages/shared/src/live-note.ts` | +| IPC channel schemas | `packages/shared/src/ipc.ts` | +| IPC handlers (main process) | `apps/main/src/ipc.ts` | +| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` | +| File operations (`fetchLiveNote`, `setLiveNote`, `patchLiveNote`, `deleteLiveNote`, `setLiveNoteActive`, `readNoteBody`, `listLiveNotes`) | `packages/core/src/knowledge/live-note/fileops.ts` | +| Scheduler (cron / windows) | `packages/core/src/knowledge/live-note/scheduler.ts` | +| Trigger due-check helper (`computeNextDue` / `dueTimedTrigger`) | `packages/core/src/knowledge/live-note/schedule-utils.ts` | +| Event producer + consumer loop | `packages/core/src/knowledge/live-note/events.ts` | +| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/live-note/routing.ts` | +| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` | +| Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` | +| Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` | +| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_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/live-note/skill.ts` | +| Skill registration | `packages/core/src/application/assistant/skills/index.ts` | +| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | +| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` | +| Editor toolbar (Radio button → panel) | `apps/renderer/src/components/editor-toolbar.tsx` | +| Live Note panel (single-view editor) | `apps/renderer/src/components/live-note-sidebar.tsx` | +| Status hook (`useLiveNoteAgentStatus`) | `apps/renderer/src/hooks/use-live-note-agent-status.ts` | +| Renderer frontmatter helper (preserves `live:`) | `apps/renderer/src/lib/frontmatter.ts` | +| App-level listeners (panel open + Copilot edit) | `apps/renderer/src/App.tsx` | +| Live Notes view (sidebar nav target) | `apps/renderer/src/components/live-notes-view.tsx` | +| CSS (panel styles, legacy filenames) | `apps/renderer/src/styles/live-note-panel.css`, `apps/renderer/src/styles/editor.css` | +| Main process startup (schedulers & processors) | `apps/main/src/main.ts` | diff --git a/apps/x/TRACKS.md b/apps/x/TRACKS.md deleted file mode 100644 index 3caf9e41..00000000 --- a/apps/x/TRACKS.md +++ /dev/null @@ -1,343 +0,0 @@ -# Track Blocks - -> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, 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. - -**Example** (a Chicago-time track 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 * * * *" -``` - - -2:30 PM, Central Time - -~~~ - -## Table of Contents - -1. [Product Overview](#product-overview) -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) - ---- - -## Product Overview - -### Trigger types - -A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track. - -| Trigger | When it fires | How to express it | -|---|---|---| -| **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"` | - -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. - -### Creating a track - -Three paths, all produce 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. - -### Viewing and managing a track - -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. - -Clicking the chip opens the **track modal**, where everything happens: - -- **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). - -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`. - -### What Copilot can do - -- **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. - -### After a run - -- The **target region** (between `` 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. - ---- - -## Architecture at a Glance - -``` -Editor chip (display-only) ──click──► TrackModal (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 -``` - -**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. - -**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. - ---- - -## Technical Flows - -### 4.1 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()`. - -### 4.2 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: })`. -- **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'`. - -**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/.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/.json`, unlink from `pending/`. - -**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. -- 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. - -**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. - -### 4.3 Run flow (`triggerTrackUpdate`) - -Module: `packages/core/src/knowledge/track/runner.ts`. - -1. **Concurrency guard** — static `runningTracks: Set` 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. - -Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`. - -### 4.4 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 | - -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 - -- **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 `` 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. -- **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`: - -- `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' }`. -- `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. - ---- - -## 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`). - -### 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. -- **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`. -- **Invoked by**: `findCandidates()` at `routing.ts:73+`, 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). - -### 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). -- **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`. - -### 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`. - -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. -- **`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. - -### 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. -- **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. - -### 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. - -### 7. `run-track-block` 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. -- **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"`. - -### 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 Map - -| Purpose | File | -|---|---| -| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.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` | -| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` | -| Schedule 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` | -| 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` | -| 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` | -| 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/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/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/browser/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts index b83ea7cb..7c97ea7a 100644 --- a/apps/x/apps/main/src/browser/control-service.ts +++ b/apps/x/apps/main/src/browser/control-service.ts @@ -1,8 +1,24 @@ import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js'; -import type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js'; +import type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js'; +import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js'; import { browserViewManager } from './view.js'; import { normalizeNavigationTarget } from './navigation.js'; +async function getSuggestedSkills(url: string | undefined): Promise { + if (!url) return undefined; + try { + const status = await ensureLoaded(); + if (status.status === 'ready' || status.status === 'stale') { + const matched = matchSkillsForUrl(status.index, url); + if (matched.length === 0) return undefined; + return matched.map((e) => ({ id: e.id, title: e.title, path: e.path })); + } + } catch (err) { + console.warn('[browser-control] suggestedSkills lookup failed:', err); + } + return undefined; +} + function buildSuccessResult( action: BrowserControlAction, message: string, @@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult( 'new-tab', target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', page, ); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'switch-tab': { @@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { } await browserViewManager.ensureActiveTabReady(signal); const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('navigate', `Navigated to ${target}.`, page); + const suggestedSkills = await getSuggestedSkills(page?.url); + const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'back': { @@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService { if (!result.ok || !result.page) { return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.'); } - return buildSuccessResult('read-page', 'Read the current page.', result.page); + const suggestedSkills = await getSuggestedSkills(result.page.url); + const success = buildSuccessResult('read-page', 'Read the current page.', result.page); + return suggestedSkills ? { ...success, suggestedSkills } : success; } case 'click': { diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index d319c5fb..90b7d849 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -109,19 +109,62 @@ export class BrowserViewManager extends EventEmitter { private visible = false; private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; private snapshotCache = new Map(); + private cleanupWindowListeners: (() => void) | null = null; attach(window: BrowserWindow): void { + this.cleanupWindowListeners?.(); + this.cleanupWindowListeners = null; this.window = window; - window.on('closed', () => { + const hostWebContents = window.webContents; + + 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 = () => { + 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; this.attachedTabId = null; this.visible = false; this.snapshotCache.clear(); - }); + }; + + hostWebContents.on('did-start-loading', handleDidStartLoading); + hostWebContents.on('render-process-gone', handleRenderProcessGone); + window.on('closed', handleClosed); + + this.cleanupWindowListeners = () => { + if (!hostWebContents.isDestroyed()) { + hostWebContents.removeListener('did-start-loading', handleDidStartLoading); + hostWebContents.removeListener('render-process-gone', handleRenderProcessGone); + } + if (!window.isDestroyed()) { + window.removeListener('closed', handleClosed); + } + }; } private getSession(): Session { 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 new file mode 100644 index 00000000..aaaaa3bc --- /dev/null +++ b/apps/x/apps/main/src/deeplink.ts @@ -0,0 +1,165 @@ +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 among action / oauth-completion / + * navigation automatically. Use this from notification click handlers and + * other URL entry points. + * + * OAuth completion (rowboat://oauth/google/done?session=) 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); + } +} + +export function dispatchDeepLink(url: string): void { + if (!url.startsWith(URL_PREFIX)) return; + + pendingUrl = url; + + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + if (win.webContents.isLoading()) return; + + win.webContents.send("app:openUrl", { url }); + pendingUrl = null; +} + +interface MeetingNotesAction { + type: "take-meeting-notes" | "join-and-take-meeting-notes"; + eventId: string; +} + +type ParsedAction = MeetingNotesAction; + +function parseAction(url: string): ParsedAction | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, ""); + if (host !== ACTION_HOST) return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const type = params.get("type"); + if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { + const eventId = params.get("eventId"); + return eventId ? { type, eventId } : null; + } + return null; +} + +async function dispatchAction(url: string): Promise { + const parsed = parseAction(url); + if (!parsed) return; + + const openMeeting = parsed.type === "join-and-take-meeting-notes"; + await handleTakeMeetingNotes(parsed.eventId, openMeeting); +} + +async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise { + const win = mainWindowRef; + if (!win || win.isDestroyed()) return; + focusWindow(win); + + const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); + let event: unknown; + try { + const raw = await fs.readFile(filePath, "utf-8"); + event = JSON.parse(raw); + } catch (err) { + console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); + return; + } + + const payload = { event, openMeeting }; + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("app:takeMeetingNotes", payload); + }); + return; + } + + win.webContents.send("app:takeMeetingNotes", payload); +} + +// --- OAuth completion (rowboat-mode Google connect) --- + +interface OAuthCompletion { + provider: "google"; + state: string; +} + +/** + * Match rowboat://oauth/google/done?session=. 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 { + 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(); + win.focus(); +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a9de9572..638af656 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -8,6 +8,7 @@ import { listProviders, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; +import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; @@ -34,6 +35,8 @@ 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 { 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'; @@ -44,14 +47,28 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; 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 { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; +import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; +import { getInstallationId } from '@x/core/dist/analytics/installation.js'; +import { API_URL } from '@x/core/dist/config/env.js'; import { - fetchYaml, - updateTrackBlock, - replaceTrackBlockYaml, - deleteTrackBlock, -} from '@x/core/dist/knowledge/track/fileops.js'; + fetchLiveNote, + setLiveNote, + setLiveNoteActive, + deleteLiveNote, + listLiveNotes, +} from '@x/core/dist/knowledge/live-note/fileops.js'; +import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js'; +import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js'; +import { + fetchTask, + patchTask, + createTask, + deleteTask, + listTasks, + readRunIds as readTaskRunIds, +} from '@x/core/dist/background-tasks/fileops.js'; import { browserIpcHandlers } from './browser/ipc.js'; /** @@ -342,7 +359,7 @@ function emitServiceEvent(event: z.infer): void { } } -export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { +export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { if (!win.isDestroyed() && win.webContents) { @@ -371,14 +388,27 @@ export async function startServicesWatcher(): Promise { }); } -let tracksWatcher: (() => void) | null = null; -export function startTracksWatcher(): void { - if (tracksWatcher) return; - tracksWatcher = trackBus.subscribe((event) => { +let liveNoteAgentWatcher: (() => void) | null = null; +export function startLiveNoteAgentWatcher(): void { + if (liveNoteAgentWatcher) return; + liveNoteAgentWatcher = liveNoteBus.subscribe((event) => { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { if (!win.isDestroyed() && win.webContents) { - win.webContents.send('tracks:events', event); + win.webContents.send('live-note-agent:events', event); + } + } + }); +} + +let backgroundTaskAgentWatcher: (() => void) | null = null; +export function startBackgroundTaskAgentWatcher(): void { + if (backgroundTaskAgentWatcher) return; + backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('bg-task-agent:events', event); } } }); @@ -415,6 +445,15 @@ 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(), + apiUrl: API_URL, + }; + }, 'workspace:getRoot': async () => { return workspace.getRoot(); }, @@ -445,6 +484,20 @@ export function setupIpcHandlers() { 'workspace:remove': async (_event, args) => { return workspace.remove(args.path, args.opts); }, + 'gmail:getImportant': async (_event, args) => { + return listImportantThreads({ cursor: args.cursor, limit: args.limit }); + }, + 'gmail:getEverythingElse': async (_event, args) => { + return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); + }, + 'gmail:triggerSync': async () => { + triggerGmailSync(); + return {}; + }, + 'gmail:saveMessageHeight': async (_event, args) => { + saveMessageBodyHeight(args.threadId, args.messageId, args.height); + return {}; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, @@ -479,6 +532,35 @@ export function setupIpcHandlers() { await runsCore.deleteRun(args.runId); return { success: true }; }, + 'runs:downloadLog': async (event, args) => { + const runFileName = `${args.runId}.jsonl`; + if (path.basename(runFileName) !== runFileName) { + return { success: false, error: 'Invalid run id' }; + } + + const sourcePath = path.join(WorkDir, 'runs', runFileName); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${runFileName}.log`, + filters: [ + { name: 'Chat Log', extensions: ['log'] }, + { name: 'JSONL', extensions: ['jsonl'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { success: false }; + } + + try { + await fs.copyFile(sourcePath, result.filePath); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download chat log'; + return { success: false, error: message }; + } + }, 'models:list': async () => { if (await isSignedIn()) { return await listGatewayModels(); @@ -600,11 +682,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 () => { @@ -645,6 +724,11 @@ export function setupIpcHandlers() { const error = await shell.openPath(filePath); return { error: error || undefined }; }, + 'shell:showItemInFolder': async (_event, args) => { + const filePath = resolveShellPath(args.path); + shell.showItemInFolder(filePath); + return { success: true }; + }, 'shell:readFileBase64': async (_event, args) => { const filePath = resolveShellPath(args.path); const stat = await fs.stat(filePath); @@ -665,6 +749,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); @@ -780,48 +877,135 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, - // Track handlers - 'track:run': async (_event, args) => { - const result = await triggerTrackUpdate(args.trackId, args.filePath); - return { success: !result.error, summary: result.summary ?? undefined, error: result.error }; + // Live-note handlers + 'live-note:run': async (_event, args) => { + const result = await runLiveNoteAgent(args.filePath, 'manual', args.context); + return { + success: !result.error, + runId: result.runId, + action: result.action, + summary: result.summary, + contentAfter: result.contentAfter, + error: result.error, + }; }, - 'track:get': async (_event, args) => { + 'live-note:get': async (_event, args) => { try { - const yaml = await fetchYaml(args.filePath, args.trackId); - if (yaml === null) return { success: false, error: 'Track not found' }; - return { success: true, yaml }; + const live = await fetchLiveNote(args.filePath); + return { success: true, live }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, - 'track:update': async (_event, args) => { + 'live-note:set': async (_event, args) => { try { - await updateTrackBlock(args.filePath, args.trackId, args.updates as Record); - const yaml = await fetchYaml(args.filePath, args.trackId); - if (yaml === null) return { success: false, error: 'Track vanished after update' }; - return { success: true, yaml }; + await setLiveNote(args.filePath, args.live); + const live = await fetchLiveNote(args.filePath); + return { success: true, live }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, - 'track:replaceYaml': async (_event, args) => { + 'live-note:setActive': async (_event, args) => { try { - await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml); - const yaml = await fetchYaml(args.filePath, args.trackId); - if (yaml === null) return { success: false, error: 'Track vanished after replace' }; - return { success: true, yaml }; + await setLiveNoteActive(args.filePath, args.active); + const live = await fetchLiveNote(args.filePath); + return { success: true, live }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, - 'track:delete': async (_event, args) => { + 'live-note:delete': async (_event, args) => { try { - await deleteTrackBlock(args.filePath, args.trackId); + await deleteLiveNote(args.filePath); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, + 'live-note:stop': async (_event, args) => { + try { + const live = await fetchLiveNote(args.filePath); + if (!live?.lastRunId) { + return { success: false, error: 'No active run for this note' }; + } + await runsCore.stop(live.lastRunId, false); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'live-note:listNotes': async () => { + const notes = await listLiveNotes(); + return { notes }; + }, + // Bg-task handlers + 'bg-task:run': async (_event, args) => { + const result = await runBackgroundTask(args.slug, 'manual', args.context); + return { + success: !result.error, + runId: result.runId, + summary: result.summary, + error: result.error, + }; + }, + 'bg-task:get': async (_event, args) => { + try { + const task = await fetchTask(args.slug); + return { success: true, task }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:patch': async (_event, args) => { + try { + const task = await patchTask(args.slug, args.partial); + return { success: true, task }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:create': async (_event, args) => { + try { + const { slug } = await createTask({ + name: args.name, + instructions: args.instructions, + ...(args.triggers ? { triggers: args.triggers } : {}), + ...(args.model ? { model: args.model } : {}), + ...(args.provider ? { provider: args.provider } : {}), + }); + return { success: true, slug }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:delete': async (_event, args) => { + try { + await deleteTask(args.slug); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:stop': async (_event, args) => { + try { + const task = await fetchTask(args.slug); + if (!task?.lastRunId) { + return { success: false, error: 'No active run for this task' }; + } + await runsCore.stop(task.lastRunId, false); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + 'bg-task:list': async (_event, args) => { + return listTasks(args); + }, + 'bg-task:listRunIds': async (_event, args) => { + const runIds = await readTaskRunIds(args.slug, args.limit); + return { runIds }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index eea21481..ab026fff 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -4,7 +4,8 @@ import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, - startTracksWatcher, + startLiveNoteAgentWatcher, + startBackgroundTaskAgentWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -23,19 +24,33 @@ 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 initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; -import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; +import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; +import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js"; +import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js"; +import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js"; +import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; +import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.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 { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js"; 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, + dispatchUrl, + extractDeepLinkFromArgv, + setMainWindowForDeepLinks, +} from "./deeplink.js"; const execAsync = promisify(exec); @@ -45,6 +60,44 @@ 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.isPackaged && !app.requestSingleInstanceLock()) { + console.error('[Main] Another Rowboat instance is already running; exiting this process.'); + 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) dispatchUrl(initialUrl); +} + +// macOS sends URLs via 'open-url' (both first launch and while running). +app.on("open-url", (event, url) => { + event.preventDefault(); + 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) dispatchUrl(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.). @@ -65,7 +118,9 @@ function initializeExecutionEnvironment(): void { ).trim(); const env = JSON.parse(stdout) as Record; - process.env = { ...env, ...process.env }; + // Let the user's shell environment win for overlapping keys like PATH. + // Finder/launched GUI apps on macOS often start with a stripped PATH. + process.env = { ...process.env, ...env }; } catch (error) { console.error('Failed to load shell environment', error); } @@ -83,16 +138,29 @@ const rendererPath = app.isPackaged : path.join(__dirname, "../../../renderer/dist"); // Development console.log("rendererPath", rendererPath); -// Register custom protocol for serving built renderer files in production. -// This keeps SPA routes working when users deep link into the packaged app. +// Register custom protocol for serving built renderer files in production +// AND for serving local workspace files to the renderer (images, PDFs, video). +// +// app://workspace/ → workspace file (path-traversal guarded) +// app:///... → renderer SPA (existing behavior) function registerAppProtocol() { protocol.handle("app", (request) => { const url = new URL(request.url); - // url.pathname starts with "/" - let urlPath = url.pathname; + // Workspace files: app://workspace/ + if (url.host === "workspace") { + try { + const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + if (!relPath) return new Response("Not Found", { status: 404 }); + const absPath = resolveWorkspacePath(relPath); + return net.fetch(pathToFileURL(absPath).toString()); + } catch { + return new Response("Forbidden", { status: 403 }); + } + } - // If it's "/" or a SPA route (no extension), serve index.html + // Renderer SPA — existing logic + let urlPath = url.pathname; if (urlPath === "/" || !path.extname(urlPath)) { urlPath = "/index.html"; } @@ -111,8 +179,8 @@ protocol.registerSchemesAsPrivileged([ supportFetchAPI: true, corsEnabled: true, allowServiceWorkers: true, - // optional but often helpful: - // stream: true, + // Required for byte-range requests so