diff --git a/CLAUDE.md b/CLAUDE.md index b10d5234..75a0f6a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Long-form docs for specific features. Read the relevant file before making chang | Feature | Doc | |---------|-----| -| Tracks — frontmatter directives that keep a note's body auto-updated (cron / window / once / event / multi-trigger), section-placement model, sidebar UI, Copilot skill, prompts catalog | `apps/x/TRACKS.md` | +| 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/ANALYTICS.md b/apps/x/ANALYTICS.md index f0372dd5..572e9a6f 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -22,7 +22,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run | Property | Type | Notes | |---|---|---| -| `use_case` | enum | `copilot_chat` / `track_block` / `meeting_note` / `knowledge_sync` | +| `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` | @@ -42,8 +42,11 @@ Every `llm_usage` emit point in the codebase: | `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) | | `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` | | `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` | -| `track_block` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/track/routing.ts:104` | -| `track_block` | `run` | yes | Pass 2 track block execution | `packages/core/src/knowledge/track/runner.ts:109` (createRun) | +| `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) | @@ -53,6 +56,15 @@ Every `llm_usage` emit point in the codebase: | `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` diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md new file mode 100644 index 00000000..2fc43786 --- /dev/null +++ b/apps/x/LIVE_NOTE.md @@ -0,0 +1,410 @@ +# 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 (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates 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. + +--- + +## Daily-Note Template & Migrations + +`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals. + +**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: + +- File missing → fresh write at canonical version. +- File at-or-above canonical → no-op. +- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body). + +The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced. + +--- + +## 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` | +| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | +| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | +| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | +| Copilot skill | `packages/core/src/application/assistant/skills/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 3d9662f2..00000000 --- a/apps/x/TRACKS.md +++ /dev/null @@ -1,366 +0,0 @@ -# Tracks - -> Frontmatter directives that keep a markdown note's body auto-updated — on a schedule, when a relevant event arrives, or on demand. - -A track is a single entry in a note's YAML frontmatter under the `track:` array. Each entry defines an instruction, optional triggers (cron / window / once / event — any mix), and (after the first run) some runtime state. When a trigger fires, a background agent edits the **note body** to satisfy the instruction. A note with no `track:` key is just a static note. - -**Example** (a note that shows the current Chicago time, refreshed hourly): - -~~~markdown ---- -track: - - id: chicago-time - instruction: | - Show the current time in Chicago, IL in 12-hour format. - active: true - triggers: - - type: cron - expression: "0 * * * *" - lastRunAt: "2026-05-07T15:00:01.234Z" - lastRunId: "..." - lastRunSummary: "Updated — 3:00 PM, Central Time." ---- - -# 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. [Section Placement](#section-placement) -6. [Daily-Note Template & Migrations](#daily-note-template--migrations) -7. [Renderer UI](#renderer-ui) -8. [Prompts Catalog](#prompts-catalog) -9. [File Map](#file-map) - ---- - -## Product Overview - -### Triggers - -A track has zero or more triggers under a single `triggers:` array. Each trigger is one of four types and can be mixed freely: - -| Type | When it fires | Shape | -|---|---|---| -| **`cron`** | At exact cron times | `{ type: cron, expression: "0 * * * *" }` | -| **`window`** | Once per day, anywhere inside a time-of-day band | `{ type: window, startTime: "09:00", endTime: "12:00" }` | -| **`once`** | Once at a future time, then never | `{ type: once, runAt: "2026-04-14T09:00:00" }` | -| **`event`** | When a matching event arrives (e.g. new Gmail thread) | `{ type: event, matchCriteria: "Emails about Q3 planning" }` | - -A track with no `triggers` (or an empty array) is **manual-only** — fires only when the user clicks Run in the sidebar. - -`cron` and `once` enforce a 2-minute grace window — if the scheduled time passed more than 2 minutes ago (e.g. the app was offline), the run is skipped, not replayed. `window` is forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, it fires the moment the app is open. The day's cycle is anchored at `startTime`. - -A single track can carry multiple triggers. The flagship example is in Today.md's `priorities` track: three `window` entries (morning / midday / post-lunch) plus two `event` entries (gmail / calendar) — five triggers total, giving a baseline rebuild three times per day plus reactive updates on incoming signals. - -### Creating a track - -Two paths, both producing identical on-disk YAML: - -1. **Hand-written** — type the entry directly into a note's frontmatter under `track:`. The scheduler picks it up on its next 15-second tick. -2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond the literal word "track" (see "Prompts Catalog → Copilot trigger paragraph" for the signal taxonomy); it loads the `tracks` skill, edits the note's frontmatter via `workspace-edit`, then **runs the track once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. - -There is no inline-block creation flow anymore. The Cmd+K palette is search-only and does not invoke Copilot. - -### Viewing and managing tracks - -The editor has a Radio-icon button in the top toolbar (right side) that opens the **Track Sidebar** for the current note. The sidebar: - -- **List view** — one row per track in the note's frontmatter. Title is the track's `id`; subtitle is the trigger summary plus a `Paused ·` prefix when applicable, plus the instruction's first line as a tertiary line. A Play button on the right runs that track. -- **Detail view** (click a row) — back arrow + tabs (*What* / *Schedule* / *Events* / *Details*), an advanced raw-YAML editor, danger-zone delete, and a footer with "Edit with Copilot" + "Run now". -- **Status hook** — `useTrackStatus` subscribes to `tracks:events` IPC; rows show a spinner whenever a track is running, regardless of hover state. - -Every mutation in the sidebar goes through IPC to the backend — the renderer never writes the file itself. This avoids races with the scheduler/runner writing runtime fields like `lastRunAt`. - -### What the runtime agent does - -When a trigger fires, a background agent ("track-run") receives a short message: -- The track's `id`, the workspace-relative path to the note, and a localized timestamp. -- The instruction. -- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). - -The agent's system prompt tells it to: -1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). -2. Find or create the H2 section the instruction names (placement model below). -3. Update only that section's content. Never modify YAML frontmatter — that's owned by the user and the runtime. -4. After writing, re-check its section's position; cut-and-paste only its own block if it's misplaced (handles the cold-start firing-order problem). -5. End with a one-line summary stored as `lastRunSummary`. - -The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP) — there's no special "track-content" tool anymore; tracks just ship general edits. - ---- - -## Architecture at a Glance - -``` -Editor toolbar Radio button ─click──► TrackSidebar (React) - │ - ├──► IPC: track:get / update / - │ replaceYaml / delete / run - │ -Backend (main process) - ├─ Scheduler loop (15 s) ──┐ - ├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent - └─ Builtin tool run-track ─┘ │ - ▼ - workspace-readFile / -edit - │ - ▼ - body region rewritten on disk - frontmatter lastRun* patched -``` - -**Single-writer invariant** — the renderer is never a file writer for the `track:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrack`, `replaceTrackYaml`, `deleteTrack`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `track:` key (and `buildFrontmatter` splices it back from the original raw on save), so the FrontmatterProperties UI can't accidentally mangle it. - -**Event contract** — `window.dispatchEvent(CustomEvent('rowboat:open-track-sidebar', { detail: { filePath } }))` is the sole entry point from editor toolbar → sidebar. `rowboat:open-copilot-edit-track` opens the Copilot sidebar with the note attached. - ---- - -## Technical Flows - -### 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 tracks via `fetchAll(relPath)`. -- For each track with `active === true` and at least one timed trigger (`cron` / `window` / `once`), `find` the first due trigger via `isTriggerDue(t, lastRunAt)` (`schedule-utils.ts`). -- When due, fire `triggerTrackUpdate(track.id, relPath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates). -- **Grace window** — `cron` and `once` enforce a 2-minute grace; missed schedules are skipped, not replayed. `window` has no grace — anywhere inside the band counts. -- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a fire lands strictly after today's `startTime`, that window is done for the day. The strict comparison handles the boundary case (e.g. an 08:00–12:00 + a 12:00–15:00 window each get to fire even when the morning fire happens exactly at 12:00:00). -- **Startup** — `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`. - -### Event pipeline - -**Producers** — any data source that should feed tracks 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/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/`. Only tracks with at least one `event`-type trigger appear in the routing list; their `eventMatchCriteria` is the joined `matchCriteria` from all event triggers (`'; '`-separated). -3. `findCandidates(event, allTracks)` runs Pass 1 LLM routing (below). -4. For each candidate, `triggerTrackUpdate(id, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event. -5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then move to `events/done/.json`. - -**Pass 1 routing** (`routing.ts`): -- **Short-circuit** — if `event.targetTrackId` + `event.targetFilePath` are set (manual re-run events), skip the LLM and return that track directly. -- Filter to `active && instruction && eventMatchCriteria` tracks. -- Batches of `BATCH_SIZE = 20`. -- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `id` is only unique per file. - -**Pass 2 decision** happens inside the track-run agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body. - -### Run flow (`triggerTrackUpdate`) - -Module: `packages/core/src/knowledge/track/runner.ts`. - -1. **Concurrency guard** — static `runningTracks: Set` keyed by `${id}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. -2. **Fetch track** via `fetchAll(filePath)`, locate by `id`. -3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff. -4. **Create agent run** — `createRun({ agentId: 'track-run' })`. -5. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger; for `once` tracks the "done" marker is already set. -6. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`). -7. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly. -8. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. -9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`. -10. **Patch `lastRunSummary`** via `updateTrack(filePath, id, { lastRunSummary })`. -11. **Emit `track_run_complete`** with `summary` or `error`. -12. **Cleanup**: `runningTracks.delete(key)` in a `finally` block. - -Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`. - -### IPC surface - -| Channel | Caller → handler | Purpose | -|---|---|---| -| `track:run` | Renderer (sidebar Run button) | Fires `triggerTrackUpdate(..., 'manual')` | -| `track:get` | Sidebar on detail open | Returns fresh per-track YAML from disk via `fetchYaml(filePath, id)` | -| `track:update` | Sidebar toggle / partial edits | `updateTrack` merges a partial into the on-disk entry | -| `track:replaceYaml` | Sidebar advanced raw-YAML save | `replaceTrackYaml` validates + writes the full entry | -| `track:delete` | Sidebar danger-zone confirm | `deleteTrack` removes the entry from the `track:` array | -| `track:setNoteActive` | Background-agents view toggle | Flips `active` on every track in a note | -| `track:listNotes` | Background-agents view load | Lists all notes that contain at least one track, with summary fields | -| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to `useTrackStatus` | - -Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`. - -### Concurrency & FIFO guarantees - -- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`. -- **Backend is single writer for `track:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `track:` byte-for-byte across saves. -- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file. -- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially. -- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point. - ---- - -## Schema Reference - -All canonical schemas live in `packages/shared/src/track.ts`: - -- `TrackSchema` — a single entry in the frontmatter `track:` array. Fields: `id` (kebab-case, unique within the note), `instruction`, `active` (default true), `triggers?`, `model?`, `provider?`, `icon?`. **Runtime-managed (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`. -- `TriggerSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' | 'event' }`. Window has just `startTime` + `endTime` (no `cron` field — the cycle is anchored at `startTime`). -- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`. -- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`. - -The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(TrackSchema))` — so editing `TrackSchema` propagates to the skill on the next build. - ---- - -## Section Placement - -Tracks no longer have formal target regions. Each instruction names a section by H2 heading (e.g. *"in a section titled 'Overview' at the top"*) and the agent finds or creates that section. - -The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/track/run-agent.ts`): - -- Sections are **H2 headings** (`## Section Name`). Match by exact heading text. -- **Existing**: replace its content (everything between that heading and the next H2 — or end of file). Heading itself stays. -- **Missing**: create it. The placement hint determines location: - - "at the top" → just below the H1 title. - - "after X" → immediately after section X. - - no hint → append. -- **Self-heal**: after writing, the agent re-checks its section's position. If misplaced (the cold-start case where empty notes get sections in firing order rather than reading order), the agent moves only its **own** H2 block — never reorders other tracks' sections. -- **Boundaries**: never modify another track's section content; never duplicate; never touch frontmatter; if the user renamed the heading, recreate per the placement hint. - -This keeps tracks loosely coupled: each one stakes out a section by name, and the rest of the body is entirely the user's. - ---- - -## Daily-Note Template & Migrations - -`Today.md` is the canonical demo of what tracks can do. It ships with six tracks (overview/photo combined into one, calendar, emails, what-you-missed, priorities) showing pure-cron, pure-event, multi-window, and multi-trigger configurations. - -**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: - -- File missing → fresh write at canonical version. -- File at-or-above canonical → no-op. -- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template with the body byte-preserved via `splitFrontmatter` from `application/lib/parse-frontmatter.ts`. - -Any change to the canonical TRACKS list, instructions, default body, or trigger config should bump the constant. Existing users will get the new template on next launch with their body sections preserved; their `lastRunAt` and any custom additions to the tracks list are dropped (the .bkp file is the recovery path). - ---- - -## Renderer UI - -The chip-in-editor model is gone. Replacements: - -- **Toolbar button** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon ghost button at the top-right of the editor toolbar. `markdown-editor.tsx` passes `onOpenTracks` (only when a `notePath` is available) which dispatches `rowboat:open-track-sidebar` with `{ filePath }`. -- **Sidebar** — `apps/renderer/src/components/track-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-track-sidebar`; on open, calls `workspace:readFile` and parses tracks from the frontmatter on the renderer side (uses the same `TrackSchema` from `@x/shared`). All mutations go through IPC. - - Constant top header: Radio icon, "Tracks" title, note name subtitle, X close. Uses the `bg-sidebar` design tokens to match the app's left sidebar. - - List view: one row per track. Title is `id`; subtitle is the trigger summary (with `Paused ·` prefix); third line is the instruction's first line, truncated. Run button always visible while running, otherwise fades in on hover. - - Detail view: back arrow + track id; status row (trigger summary + Active/Paused toggle); tabs (`What` / `Schedule` / `Events` / `Details`); advanced raw-YAML editor; danger-zone delete; footer (Edit with Copilot + Run now). -- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` IPC and maintains a `Map<"${id}:${filePath}", RunState>` keyed by composite key. -- **Edit-with-Copilot flow** — sidebar dispatches `rowboat:open-copilot-edit-track` (App.tsx listener handles it via `submitFromPalette`). -- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` adds `STRUCTURED_KEYS = new Set(['track'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `track:` block back from `preserveRaw` on save. This means the property panel can edit `tags` / `status` / etc. without ever clobbering the tracks frontmatter. - ---- - -## Prompts Catalog - -Every LLM-facing prompt in the feature, with file pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app. - -### 1. Routing system prompt (Pass 1 classifier) - -- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; the run-agent does Pass 2. -- **File**: `packages/core/src/knowledge/track/routing.ts` (`ROUTING_SYSTEM_PROMPT`). -- **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`. -- **Invoked by**: `findCandidates()` 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 for Pass 1. -- **File**: `packages/core/src/knowledge/track/routing.ts` (`buildRoutingPrompt`). -- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `matchCriteria` — joined from all event triggers, `'; '`-separated). -- **Output**: plain text, two sections — `## Event` and `## Tracks`. - -### 3. Track-run agent instructions - -- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the section-placement contract (find/create/self-heal), points at the knowledge graph, and prescribes general `workspace-readFile` / `workspace-edit` as the write path. -- **File**: `packages/core/src/knowledge/track/run-agent.ts` (`TRACK_RUN_INSTRUCTIONS`). -- **Inputs**: `${WorkDir}` template literal substituted at module load. -- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`. -- **Invoked by**: `buildTrackRunAgent()`, 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. -- **File**: `packages/core/src/knowledge/track/runner.ts` (`buildMessage`). -- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `track.id`, `track.instruction`, all event triggers' `matchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. -- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run). - -Three branches by `trigger`: -- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-track` tool uses this path for both plain refreshes and context-biased backfills. -- **`timed`** — same as `manual`. Called by the scheduler with no `context`. -- **`event`** — adds a Pass 2 decision block listing all event triggers' `matchCriteria` (numbered if multiple) and the event payload, with the directive to skip the edit if the event isn't truly relevant. - -### 5. Tracks skill (Copilot-facing) - -- **Purpose**: teaches Copilot the frontmatter `track:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, user-facing language (call them "tracks"; surface the **Track sidebar** by name), the auto-run-once-on-create/edit default, schema, triggers, multi-trigger combos, YAML-safety rules, insertion workflow, and the `run-track` tool with `context` backfills. -- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts`. Exported `skill` constant. -- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(TrackSchema))` is interpolated into the "Canonical Schema" section. Edits to `TrackSchema` propagate automatically. -- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires. -- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`. - -### 6. Copilot trigger paragraph - -- **Purpose**: tells Copilot *when* to load the `tracks` skill, and frames how aggressively to act once loaded. -- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Tracks (Auto-Updating Notes)" paragraph). -- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…"). -- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up. -- **Anti-signals (do NOT track)**: definitional questions, one-off lookups, manual document editing. - -### 7. `run-track` 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-track` tool definition). -- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), `id`, optional `context`. -- **Output**: flows into `triggerTrackUpdate(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message. -- **Key use case**: backfill a newly-created event-driven track so its section isn't empty on day 1. - -### 8. Calendar sync digest (event payload template) - -- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`. -- **File**: `packages/core/src/knowledge/sync_calendar.ts` (`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. - ---- - -## File Map - -| Purpose | File | -|---|---| -| Zod schemas (track, triggers, events, Pass1) | `packages/shared/src/track.ts` | -| IPC channel schemas | `packages/shared/src/ipc.ts` | -| IPC handlers (main process) | `apps/main/src/ipc.ts` | -| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` | -| File operations (fetchAll / fetch / updateTrack / replaceTrackYaml / deleteTrack / readNoteBody / list / setActive) | `packages/core/src/knowledge/track/fileops.ts` | -| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` | -| Trigger due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` | -| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` | -| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` | -| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` | -| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` | -| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` | -| Track state type | `packages/core/src/knowledge/track/types.ts` | -| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | -| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | -| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | -| Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` | -| Skill registration | `packages/core/src/application/assistant/skills/index.ts` | -| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | -| `run-track` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` | -| Editor toolbar (Radio button → sidebar) | `apps/renderer/src/components/editor-toolbar.tsx` | -| Track sidebar (list + detail view) | `apps/renderer/src/components/track-sidebar.tsx` | -| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` | -| Renderer frontmatter helper (preserves `track:`) | `apps/renderer/src/lib/frontmatter.ts` | -| App-level listeners (sidebar open + Copilot edit) | `apps/renderer/src/App.tsx` | -| CSS (sidebar styles, legacy filename) | `apps/renderer/src/styles/track-modal.css`, `apps/renderer/src/styles/editor.css` | -| Main process startup (schedulers & processors) | `apps/main/src/main.ts` | diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3d888ee9..a667b512 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -46,18 +46,17 @@ 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 { 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, - listNotesWithTracks, - setNoteTracksActive, - updateTrack, - replaceTrackYaml, - deleteTrack, -} from '@x/core/dist/knowledge/track/fileops.js'; + fetchLiveNote, + setLiveNote, + setLiveNoteActive, + deleteLiveNote, + listLiveNotes, +} from '@x/core/dist/knowledge/live-note/fileops.js'; import { browserIpcHandlers } from './browser/ipc.js'; /** @@ -137,14 +136,6 @@ function resolveShellPath(filePath: string): string { return workspace.resolveWorkspacePath(filePath); } -function toKnowledgeTrackPath(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, ''); - if (!normalized.startsWith('knowledge/')) { - throw new Error('Track note path must be within knowledge/') - } - return normalized.slice('knowledge/'.length) -} - type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -385,14 +376,14 @@ 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); } } }); @@ -813,59 +804,66 @@ 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.id, 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.id); - 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 updateTrack(args.filePath, args.id, args.updates as Record); - const yaml = await fetchYaml(args.filePath, args.id); - 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 replaceTrackYaml(args.filePath, args.id, args.yaml); - const yaml = await fetchYaml(args.filePath, args.id); - if (yaml === null) return { success: false, error: 'Track vanished after replace' }; - return { success: true, yaml }; + 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 deleteTrack(args.filePath, args.id); + await deleteLiveNote(args.filePath); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, - 'track:setNoteActive': async (_event, args) => { + 'live-note:stop': async (_event, args) => { try { - const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active); - if (!note) return { success: false, error: 'No tracks found in note' }; - return { success: true, note }; + 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) }; } }, - 'track:listNotes': async () => { - const notes = await listNotesWithTracks(); + 'live-note:listNotes': async () => { + const notes = await listLiveNotes(); return { notes }; }, // Billing handler diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 3cfd3f4f..accc971e 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -4,7 +4,7 @@ import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, - startTracksWatcher, + startLiveNoteAgentWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -24,8 +24,8 @@ import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js" import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js"; -import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js"; -import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js"; +import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js"; +import { init as initLiveNoteEventProcessor } from "@x/core/dist/knowledge/live-note/events.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; @@ -328,14 +328,14 @@ app.whenReady().then(async () => { // start services watcher startServicesWatcher(); - // start tracks watcher - startTracksWatcher(); + // start live-note agent event watcher (forwards bus → renderer) + startLiveNoteAgentWatcher(); - // start track scheduler (cron/window/once) - initTrackScheduler(); + // start live-note scheduler (cron / window) + initLiveNoteScheduler(); - // start track event processor (consumes events/pending/, triggers matching tracks) - initTrackEventProcessor(); + // start live-note event processor (consumes events/pending/, routes to matching live notes) + initLiveNoteEventProcessor(); // start gmail sync initGmailSync(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b525462b..2dff9fd8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -22,7 +22,7 @@ import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; -import { BackgroundAgentsView } from '@/components/background-agents-view'; +import { LiveNotesView } from '@/components/live-notes-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -66,7 +66,7 @@ import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' -import { TrackSidebar } from '@/components/track-sidebar' +import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' @@ -175,7 +175,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' -const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__' +const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -305,7 +305,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH -const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH +const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -351,34 +351,19 @@ const buildSuggestedTopicExplorePrompt = ({ `- Description: ${description}`, `- Target folder if we set this up: knowledge/${folder}/`, '', - `Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`, - 'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.', + `Please start by telling me that you can set up a live note for "${title}" under knowledge/${folder}/.`, + 'Then briefly explain what that live note would track and ask me if you should set it up.', 'Do not create or modify anything yet.', 'Treat a clear confirmation from me as explicit approval to proceed.', - `If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`, + `If I confirm later, load the \`live-note\` skill first, check whether a matching note already exists under knowledge/${folder}/, and extend its existing live objective instead of creating a duplicate.`, `If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`, - 'Add a track to the note (a `track:` entry in its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.', + 'Make the new note live (add a `live:` block to its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.', 'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.', ].join('\n') } -const buildBackgroundAgentSetupPrompt = () => [ - 'Help me set up a background agent.', - 'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.', - 'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.', - 'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.', - 'Start with a short, plain-English explanation of what a background agent is.', - 'Do not make the explanation too terse.', - 'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.', - 'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.', - 'In the first reply, tell me that you will create this in my Tasks folder by default.', - 'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.', - 'Then ask only what I want it to monitor or update and how often I want it to run.', - 'Keep it concise and friendly, but not abrupt.', - 'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.', - 'Do not create or modify anything yet.', - 'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.', -].join('\n') +const buildLiveNoteSetupPrompt = () => + 'I want to set up a Live note / task.' const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null @@ -562,7 +547,7 @@ type ViewState = | { type: 'graph' } | { type: 'task'; name: string } | { type: 'suggested-topics' } - | { type: 'background-agents' } + | { type: 'live-notes' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -576,13 +561,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=&... + * Shape: rowboat://open?type=&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph * task: ?type=task&name=daily-brief * suggested-topics: ?type=suggested-topics - * background-agents: ?type=background-agents + * live-notes: ?type=live-notes */ function parseDeepLink(input: string): ViewState | null { const SCHEME = 'rowboat://' @@ -607,8 +592,8 @@ function parseDeepLink(input: string): ViewState | null { } case 'suggested-topics': return { type: 'suggested-topics' } - case 'background-agents': - return { type: 'background-agents' } + case 'live-notes': + return { type: 'live-notes' } default: return null } @@ -714,12 +699,12 @@ function App() { const [isGraphOpen, setIsGraphOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) - const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false) + const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null graph: boolean suggestedTopics: boolean - backgroundAgents: boolean + liveNotes: boolean } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ @@ -730,6 +715,10 @@ function App() { const [graphError, setGraphError] = useState(null) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false) + // Live-note panel: bound to a single note path. Mounted as a sibling of the + // markdown editor so it shares the layout (no overlap with chat) and + // auto-closes when the active note changes. + const [liveNotePanelPath, setLiveNotePanelPath] = useState(null) const [activeShortcutPane, setActiveShortcutPane] = useState('left') const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') const collapsedLeftPaddingPx = @@ -1040,7 +1029,7 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' - if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents' + if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2753,7 +2742,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setSelectedPath(path) return } @@ -2762,7 +2751,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2781,26 +2770,26 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) return } - if (isBackgroundAgentsTabPath(tab.path)) { + if (isLiveNotesTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(true) + setIsLiveNotesOpen(true) return } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) @@ -2829,7 +2818,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2843,21 +2832,21 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsBackgroundAgentsOpen(false) - } else if (isBackgroundAgentsTabPath(newActiveTab.path)) { + setIsLiveNotesOpen(false) + } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(true) + setIsLiveNotesOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2888,12 +2877,12 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen, - backgroundAgents: isBackgroundAgentsOpen, + liveNotes: isLiveNotesOpen, }) } else { setExpandedFrom(null) @@ -2902,8 +2891,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen]) + setIsLiveNotesOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -2946,26 +2935,44 @@ function App() { setPendingPaletteSubmit(null) }, [pendingPaletteSubmit]) - // Listener for "Edit with Copilot" events from the track sidebar. + // Listener for "Edit with Copilot" events from the live-note panel. useEffect(() => { const handler = (e: Event) => { const ev = e as CustomEvent<{ - trackId?: string filePath?: string }> - const trackId = ev.detail?.trackId const filePath = ev.detail?.filePath - if (!trackId || !filePath) return + if (!filePath) return const displayName = filePath.split('/').pop() ?? filePath submitFromPalette( - `Let's work on the \`${trackId}\` track in this note. Please load the \`tracks\` skill first, then ask me what I want to change.`, + `Let's tweak the live note objective in this note. Please load the \`live-note\` skill first, then ask me what I want to change.`, { path: filePath, displayName }, ) } - window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener) - return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener) + window.addEventListener('rowboat:open-copilot-edit-live-note', handler as EventListener) + return () => window.removeEventListener('rowboat:open-copilot-edit-live-note', handler as EventListener) }, [submitFromPalette]) + // Listener for the toolbar "Live note" button — opens the panel for a path. + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent<{ filePath?: string }> + const filePath = ev.detail?.filePath + if (!filePath) return + setLiveNotePanelPath(filePath) + } + window.addEventListener('rowboat:open-live-note-panel', handler as EventListener) + return () => window.removeEventListener('rowboat:open-live-note-panel', handler as EventListener) + }, []) + + // Auto-close the live-note panel when the active note changes — the panel is + // bound to a specific path, so switching notes invalidates it. + useEffect(() => { + if (liveNotePanelPath && liveNotePanelPath !== selectedPath) { + setLiveNotePanelPath(null) + } + }, [selectedPath, liveNotePanelPath]) + // Listener for prompt-block "Run" events // (dispatched by apps/renderer/src/extensions/prompt-block.tsx) useEffect(() => { @@ -3017,12 +3024,12 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen, - backgroundAgents: isBackgroundAgentsOpen, + liveNotes: isLiveNotesOpen, }) } dismissBrowserOverlay() @@ -3030,27 +3037,27 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay]) + setIsLiveNotesOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsBackgroundAgentsOpen(false) - } else if (expandedFrom.backgroundAgents) { + setIsLiveNotesOpen(false) + } else if (expandedFrom.liveNotes) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(true) + setIsLiveNotesOpen(true) } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3060,12 +3067,12 @@ function App() { const currentViewState = React.useMemo(() => { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } - if (isBackgroundAgentsOpen) return { type: 'background-agents' } + if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isLiveNotesOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3122,14 +3129,14 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) - const ensureBackgroundAgentsFileTab = useCallback(() => { - const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path)) + const ensureLiveNotesFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isLiveNotesTabPath(tab.path)) if (existing) { setActiveFileTabId(existing.id) return } const id = newFileTabId() - setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }]) + setFileTabs((prev) => [...prev, { id, path: LIVE_NOTES_TAB_PATH }]) setActiveFileTabId(id) }, [fileTabs]) @@ -3142,7 +3149,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3157,7 +3164,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3170,7 +3177,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3183,10 +3190,10 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) ensureSuggestedTopicsFileTab() return - case 'background-agents': + case 'live-notes': setSelectedPath(null) setIsGraphOpen(false) setIsBrowserOpen(false) @@ -3194,8 +3201,8 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(true) - ensureBackgroundAgentsFileTab() + setIsLiveNotesOpen(true) + ensureLiveNotesFileTab() return case 'chat': setSelectedPath(null) @@ -3205,7 +3212,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsBackgroundAgentsOpen(false) + setIsLiveNotesOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3213,7 +3220,7 @@ function App() { } return } - }, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3535,7 +3542,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3608,17 +3615,17 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen ? SUGGESTED_TOPICS_TAB_PATH - : isBackgroundAgentsOpen - ? BACKGROUND_AGENTS_TAB_PATH + : isLiveNotesOpen + ? LIVE_NOTES_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3673,7 +3680,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3698,7 +3705,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3824,14 +3831,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4421,7 +4428,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4438,7 +4445,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4471,7 +4478,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4482,7 +4489,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4506,14 +4513,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4543,8 +4550,8 @@ function App() { onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} - isBackgroundAgentsOpen={isBackgroundAgentsOpen} - onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })} + isLiveNotesOpen={isLiveNotesOpen} + onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedTask && !isBrowserOpen && ( - -

- Notes that contain tracks. Toggle a note inactive to pause every background agent in it. -

- -
- {loading ? ( -
- -
- ) : error ? ( -
-
- -
-

{error}

-
- ) : notes.length === 0 ? ( -
-
- -
-

- No notes with background agents yet. -

-
- ) : ( -
- - - - - - - - - - - {notes.map((note) => { - const isUpdating = updatingPaths.has(note.path) - return ( - - - - - - - ) - })} - -
NoteCreated dateLast ranState
-
-
- - - {note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'} - -
-
- {stripKnowledgePrefix(note.path)} -
-
-
- {formatDateLabel(note.createdAt)} - - {formatDateTimeLabel(note.lastRunAt)} - -
- {isUpdating ? ( - - ) : ( -
-
-
- )} -
- - ) -} diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index c87ff068..b48cee49 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -43,7 +43,21 @@ interface EditorToolbarProps { onSelectionHighlight?: (range: { from: number; to: number } | null) => void onImageUpload?: (file: File) => Promise | void onExport?: (format: 'md' | 'pdf' | 'docx') => void - onOpenTracks?: () => void + onOpenLiveNote?: () => void + liveState?: LivePillState +} + +export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error' +export interface LivePillState { + variant: LivePillVariant + label: string +} + +const LIVE_PILL_VARIANT_CLASS: Record = { + passive: 'text-muted-foreground hover:bg-accent', + idle: 'text-foreground hover:bg-accent', + running: 'text-foreground bg-primary/10 hover:bg-primary/15 animate-pulse', + error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15', } export function EditorToolbar({ @@ -51,7 +65,8 @@ export function EditorToolbar({ onSelectionHighlight, onImageUpload, onExport, - onOpenTracks, + onOpenLiveNote, + liveState, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -389,17 +404,17 @@ export function EditorToolbar({ )} - {/* Tracks — pushed to far right */} - {onOpenTracks && ( - + + {liveState.label} + )} ) diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx index cc7aec0b..61a8b3cb 100644 --- a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -46,7 +46,7 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro const commit = useCallback((updated: FieldEntry[]) => { // Use the latest raw seen as the preserve-source so structured keys - // (like `track:`) survive a round-trip through this UI. + // (like `live:`) survive a round-trip through this UI. const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current) lastCommittedRaw.current = newRaw onRawChange(newRaw) diff --git a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx new file mode 100644 index 00000000..5ad03873 --- /dev/null +++ b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx @@ -0,0 +1,682 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import '@/styles/live-note-panel.css' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Input } from '@/components/ui/input' +import { + Radio, Clock, Play, Square, Loader2, Sparkles, CalendarClock, Zap, + Trash2, AlertCircle, ChevronDown, ChevronUp, Plus, X, Save, +} from 'lucide-react' +import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js' +import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status' +import { formatRelativeTime } from '@/lib/relative-time' + +export type OpenLiveNotePanelDetail = { + filePath: string +} + +const CRON_PHRASES: Record = { + '* * * * *': 'Every minute', + '*/5 * * * *': 'Every 5 minutes', + '*/15 * * * *': 'Every 15 minutes', + '*/30 * * * *': 'Every 30 minutes', + '0 * * * *': 'Hourly', + '0 */2 * * *': 'Every 2 hours', + '0 */6 * * *': 'Every 6 hours', + '0 */12 * * *': 'Every 12 hours', + '0 0 * * *': 'Daily at midnight', + '0 8 * * *': 'Daily at 8 AM', + '0 9 * * *': 'Daily at 9 AM', + '0 12 * * *': 'Daily at noon', + '0 18 * * *': 'Daily at 6 PM', + '0 9 * * 1-5': 'Weekdays at 9 AM', + '0 17 * * 1-5': 'Weekdays at 5 PM', +} + +function describeCron(expr: string): string { + return CRON_PHRASES[expr.trim()] ?? expr +} + +function summarizeTriggers(live: LiveNote): { icon: 'timer' | 'calendar' | 'bolt'; text: string } { + const t = live.triggers + if (!t) return { icon: 'bolt', text: 'Manual only' } + const parts: string[] = [] + if (t.cronExpr) parts.push(describeCron(t.cronExpr)) + if (t.windows && t.windows.length > 0) { + parts.push(t.windows.length === 1 + ? `${t.windows[0].startTime}–${t.windows[0].endTime}` + : `${t.windows.length} windows`) + } + if (t.eventMatchCriteria) parts.push('event-driven') + if (parts.length === 0) return { icon: 'bolt', text: 'Manual only' } + const icon = t.cronExpr ? 'timer' : t.windows?.length ? 'calendar' : 'bolt' + return { icon, text: parts.join(' · ') } +} + +function ScheduleIcon({ icon, size = 14 }: { icon: 'timer' | 'calendar' | 'bolt'; size?: number }) { + if (icon === 'timer') return + if (icon === 'calendar') return + return +} + +function stripKnowledgePrefix(p: string): string { + return p.replace(/^knowledge\//, '') +} + +function formatDateTime(iso: string | null | undefined): string { + if (!iso) return '' + const d = new Date(iso) + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/ + +export interface LiveNoteSidebarProps { + /** + * Note path the panel should bind to. Workspace-relative (`knowledge/Foo.md`) + * or full — both forms are accepted; the prefix is stripped internally. + * `null` (or empty) hides the panel entirely. + */ + filePath: string | null + /** Called when the user clicks the close button or hands off to Copilot. */ + onClose: () => void +} + +export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { + const [live, setLive] = useState(null) + const [draft, setDraft] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [error, setError] = useState(null) + const [showAdvanced, setShowAdvanced] = useState(false) + + const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath ?? ''), [filePath]) + const agentStatus = useLiveNoteAgentStatus() + const runState = agentStatus.get(knowledgeRelPath) ?? { status: 'idle' as const } + const isRunning = runState.status === 'running' + + const refresh = useCallback(async (relPath: string) => { + if (!relPath) { setLive(null); setDraft(null); return } + setLoading(true) + setError(null) + try { + const res = await window.ipc.invoke('live-note:get', { filePath: relPath }) + if (!res.success) { + setError(res.error ?? 'Failed to load') + setLive(null) + setDraft(null) + return + } + setLive(res.live ?? null) + setDraft(res.live ? structuredClone(res.live) as LiveNote : null) + setConfirmingDelete(false) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLive(null) + setDraft(null) + } finally { + setLoading(false) + } + }, []) + + // Reset transient panel state and reload data whenever the bound path changes. + useEffect(() => { + setShowAdvanced(false) + setConfirmingDelete(false) + setError(null) + if (knowledgeRelPath) { + void refresh(knowledgeRelPath) + } else { + setLive(null) + setDraft(null) + } + }, [knowledgeRelPath, refresh]) + + // Re-fetch when a run completes for this file. + useEffect(() => { + if (!knowledgeRelPath) return + const state = agentStatus.get(knowledgeRelPath) + if (state && (state.status === 'done' || state.status === 'error')) { + void refresh(knowledgeRelPath) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentStatus, knowledgeRelPath]) + + const isDirty = useMemo(() => { + if (!live || !draft) return false + return JSON.stringify(live) !== JSON.stringify(draft) + }, [live, draft]) + + const handleSave = useCallback(async () => { + if (!knowledgeRelPath || !draft) return + const parsed = LiveNoteSchema.safeParse(draft) + if (!parsed.success) { + setError(parsed.error.issues.map(i => i.message).join('; ')) + return + } + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('live-note:set', { filePath: knowledgeRelPath, live: parsed.data }) + if (!res.success) { + setError(res.error ?? 'Save failed') + return + } + setLive(res.live ?? null) + setDraft(res.live ? structuredClone(res.live) as LiveNote : null) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [knowledgeRelPath, draft]) + + const handleToggleActive = useCallback(async () => { + if (!knowledgeRelPath || !live) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('live-note:setActive', { + filePath: knowledgeRelPath, + active: live.active === false, + }) + if (!res.success) { + setError(res.error ?? 'Failed') + return + } + setLive(res.live ?? null) + setDraft(res.live ? structuredClone(res.live) as LiveNote : null) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [knowledgeRelPath, live]) + + const handleRun = useCallback(async () => { + if (!knowledgeRelPath) return + setError(null) + try { + await window.ipc.invoke('live-note:run', { filePath: knowledgeRelPath }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [knowledgeRelPath]) + + const handleStop = useCallback(async () => { + if (!knowledgeRelPath) return + setError(null) + try { + const res = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelPath }) + if (!res.success && res.error) setError(res.error) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, [knowledgeRelPath]) + + const handleDelete = useCallback(async () => { + if (!knowledgeRelPath) return + setSaving(true) + setError(null) + try { + const res = await window.ipc.invoke('live-note:delete', { filePath: knowledgeRelPath }) + if (!res.success) { + setError(res.error ?? 'Delete failed') + return + } + setLive(null) + setDraft(null) + setConfirmingDelete(false) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + }, [knowledgeRelPath]) + + const handleEditWithCopilot = useCallback(() => { + if (!filePath) return + window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-live-note', { + detail: { filePath }, + })) + onClose() + }, [filePath, onClose]) + + const handleMakeLive = useCallback(() => { + // Empty-state CTA: hand off to Copilot for the natural-language flow. + handleEditWithCopilot() + }, [handleEditWithCopilot]) + + if (!filePath) return null + + const noteTitle = filePath + ? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '') + : 'Live note' + const sched = live ? summarizeTriggers(live) : null + const paused = live?.active === false + + return ( +