Merge pull request #541 from rowboatlabs/dev

Dev
This commit is contained in:
Ramnique Singh 2026-05-09 00:34:29 +05:30 committed by GitHub
commit b7b84e94e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 4524 additions and 3228 deletions

View file

@ -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

1
apps/x/.gitignore vendored
View file

@ -1 +1,2 @@
node_modules/
test-fixtures/

View file

@ -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`

410
apps/x/LIVE_NOTE.md Normal file
View file

@ -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:0012:00 + a 12:0015: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: <thread markdown> })`.
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`.
**Storage** — `packages/core/src/knowledge/live-note/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
2. `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/<id>.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<string>` 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.<ISO-stamp>` (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<filePath, LiveNoteAgentState>`.
- **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:<Component>` 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 `<path> — firing (matched cron)` and `<path> — skip (matched window, backoff 4m remaining)`. Quiet when no live notes or none due. |
| Agent (runner) | `LiveNote:Agent` | `<path> — start trigger=cron runId=…`, `<path> — done action=replace summary="…"` (truncated to 120 chars), `<path> — failed: <msg>`, `<path> — skip: already running`. |
| Routing | `LiveNote:Routing` | `event:<id> — routing against N live notes`, `event:<id> — Pass1 → K candidates: a.md, b.md`, `event:<id> — Pass1 batch X failed: …`. |
| Events | `LiveNote:Events` | `event:<id> — received source=gmail type=email.synced`, `event:<id> — dispatching to K candidates: …`, `event:<id> — 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` |

View file

@ -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:0012:00 + a 12:0015: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: <thread markdown> })`.
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`.
**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
2. `listAllTracks()` scans every `.md` under `knowledge/`. Only tracks with at least one `event`-type trigger appear in the routing list; their `eventMatchCriteria` is the joined `matchCriteria` from all event triggers (`'; '`-separated).
3. `findCandidates(event, allTracks)` runs Pass 1 LLM routing (below).
4. For each candidate, `triggerTrackUpdate(id, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then move to `events/done/<id>.json`.
**Pass 1 routing** (`routing.ts`):
- **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<string>` keyed by `${id}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
2. **Fetch track** via `fetchAll(filePath)`, locate by `id`.
3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff.
4. **Create agent run**`createRun({ agentId: 'track-run' })`.
5. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger; for `once` tracks the "done" marker is already set.
6. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
7. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly.
8. **Wait for completion**`waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`.
10. **Patch `lastRunSummary`** via `updateTrack(filePath, id, { lastRunSummary })`.
11. **Emit `track_run_complete`** with `summary` or `error`.
12. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`.
### 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.<ISO-stamp>` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template with the body byte-preserved via `splitFrontmatter` from `application/lib/parse-frontmatter.ts`.
Any change to the canonical TRACKS list, instructions, default body, or trigger config should bump the constant. Existing users will get the new template on next launch with their body sections preserved; their `lastRunAt` and any custom additions to the tracks list are dropped (the .bkp file is the recovery path).
---
## Renderer UI
The chip-in-editor model is gone. Replacements:
- **Toolbar button**`apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon ghost button at the top-right of the editor toolbar. `markdown-editor.tsx` passes `onOpenTracks` (only when a `notePath` is available) which dispatches `rowboat:open-track-sidebar` with `{ filePath }`.
- **Sidebar**`apps/renderer/src/components/track-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-track-sidebar`; on open, calls `workspace:readFile` and parses tracks from the frontmatter on the renderer side (uses the same `TrackSchema` from `@x/shared`). All mutations go through IPC.
- Constant top header: Radio icon, "Tracks" title, note name subtitle, X close. Uses the `bg-sidebar` design tokens to match the app's left sidebar.
- List view: one row per track. Title is `id`; subtitle is the trigger summary (with `Paused ·` prefix); third line is the instruction's first line, truncated. Run button always visible while running, otherwise fades in on hover.
- Detail view: back arrow + track id; status row (trigger summary + Active/Paused toggle); tabs (`What` / `Schedule` / `Events` / `Details`); advanced raw-YAML editor; danger-zone delete; footer (Edit with Copilot + Run now).
- **Status hook**`apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` IPC and maintains a `Map<"${id}:${filePath}", RunState>` keyed by composite key.
- **Edit-with-Copilot flow** — sidebar dispatches `rowboat:open-copilot-edit-track` (App.tsx listener handles it via `submitFromPalette`).
- **FrontmatterProperties safety**`apps/renderer/src/lib/frontmatter.ts` adds `STRUCTURED_KEYS = new Set(['track'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `track:` block back from `preserveRaw` on save. This means the property panel can edit `tags` / `status` / etc. without ever clobbering the tracks frontmatter.
---
## Prompts Catalog
Every LLM-facing prompt in the feature, with file 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` |

View file

@ -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<void> {
});
}
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<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track vanished after update' };
return { success: true, yaml };
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

View file

@ -4,7 +4,7 @@ import {
setupIpcHandlers,
startRunsWatcher,
startServicesWatcher,
startTracksWatcher,
startLiveNoteAgentWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
@ -24,13 +24,14 @@ 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";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
@ -133,16 +134,29 @@ const rendererPath = app.isPackaged
: path.join(__dirname, "../../../renderer/dist"); // Development
console.log("rendererPath", rendererPath);
// Register custom protocol for serving built renderer files in production.
// This keeps SPA routes working when users deep link into the packaged app.
// Register custom protocol for serving built renderer files in production
// AND for serving local workspace files to the renderer (images, PDFs, video).
//
// app://workspace/<rel-path> → workspace file (path-traversal guarded)
// app://<anything-else>/... → renderer SPA (existing behavior)
function registerAppProtocol() {
protocol.handle("app", (request) => {
const url = new URL(request.url);
// url.pathname starts with "/"
let urlPath = url.pathname;
// Workspace files: app://workspace/<rel-path>
if (url.host === "workspace") {
try {
const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, "");
if (!relPath) return new Response("Not Found", { status: 404 });
const absPath = resolveWorkspacePath(relPath);
return net.fetch(pathToFileURL(absPath).toString());
} catch {
return new Response("Forbidden", { status: 403 });
}
}
// If it's "/" or a SPA route (no extension), serve index.html
// Renderer SPA — existing logic
let urlPath = url.pathname;
if (urlPath === "/" || !path.extname(urlPath)) {
urlPath = "/index.html";
}
@ -161,8 +175,8 @@ protocol.registerSchemesAsPrivileged([
supportFetchAPI: true,
corsEnabled: true,
allowServiceWorkers: true,
// optional but often helpful:
// stream: true,
// Required for byte-range requests so <video> seeking works.
stream: true,
},
},
]);
@ -207,6 +221,9 @@ function createWindow() {
contextIsolation: true,
sandbox: true,
preload: preloadPath,
// Enable Chromium's built-in PDFium plugin so <iframe src="*.pdf">
// renders PDFs natively (zoom/scroll/print toolbar included).
plugins: true,
},
});
@ -251,10 +268,10 @@ function createWindow() {
}
app.whenReady().then(async () => {
// Register custom protocol before creating window (for production builds)
if (app.isPackaged) {
registerAppProtocol();
}
// Register custom protocol before creating window.
// In production this serves the renderer SPA; in dev (and prod) it also
// serves workspace files via app://workspace/<rel-path> for media previews.
registerAppProtocol();
// Initialize auto-updater (only in production)
if (app.isPackaged) {
@ -311,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();

View file

@ -13,10 +13,16 @@ import { ChatInputWithMentions, type StagedAttachment } from './components/chat-
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
import { ImageFileViewer } from '@/components/image-file-viewer';
import { VideoFileViewer } from '@/components/video-file-viewer';
import { AudioFileViewer } from '@/components/audio-file-viewer';
import { PersistentViewerCache } from '@/components/persistent-viewer-cache';
import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer';
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,
@ -60,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'
@ -169,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) =>
@ -299,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) => {
@ -345,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<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
@ -556,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
@ -570,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=<file|chat|graph|task|suggested-topics|background-agents>&...
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|live-notes>&...
* 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://'
@ -601,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
}
@ -708,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<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -724,6 +715,10 @@ function App() {
const [graphError, setGraphError] = useState<string | null>(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<string | null>(null)
const [activeShortcutPane, setActiveShortcutPane] = useState<ShortcutPane>('left')
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
const collapsedLeftPaddingPx =
@ -1034,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
@ -1425,6 +1420,13 @@ function App() {
}
const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath
// Only the markdown editor still consumes fileContent. Every other viewer
// (media + UnsupportedFileViewer) self-loads, so skip the generic UTF-8
// loader to avoid double-fetching and to avoid slurping binary bytes.
if (!pathToLoad.endsWith('.md')) {
setFileContent('')
return
}
let cancelled = false
;(async () => {
try {
@ -2740,7 +2742,7 @@ function App() {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(path)
return
}
@ -2749,7 +2751,7 @@ function App() {
setActiveFileTabId(id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay])
@ -2768,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])
@ -2816,7 +2818,7 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@ -2830,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)
}
}
@ -2875,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)
@ -2889,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(() => {
@ -2933,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(() => {
@ -3004,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()
@ -3017,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)
@ -3047,12 +3067,12 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => {
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]
@ -3109,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])
@ -3129,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.
@ -3144,7 +3164,7 @@ function App() {
setSelectedPath(null)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -3157,7 +3177,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -3170,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)
@ -3181,8 +3201,8 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
ensureBackgroundAgentsFileTab()
setIsLiveNotesOpen(true)
ensureLiveNotesFileTab()
return
case 'chat':
setSelectedPath(null)
@ -3192,7 +3212,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setIsLiveNotesOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
@ -3200,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
@ -3522,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') {
@ -3595,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
@ -3660,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') {
@ -3685,7 +3705,7 @@ function App() {
}),
},
}))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -3811,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)
}
@ -4408,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(() => {
@ -4425,7 +4445,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
}}>
@ -4458,7 +4478,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4469,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
@ -4493,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 {
@ -4530,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' })}
/>
<SidebarInset
className={cn(
@ -4551,7 +4571,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -4559,7 +4579,7 @@ function App() {
getTabId={(t) => 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)))}
/>
) : (
<TabBar
@ -4612,7 +4632,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4627,7 +4647,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4642,7 +4662,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4675,12 +4695,12 @@ function App() {
}}
/>
</div>
) : isBackgroundAgentsOpen ? (
) : isLiveNotesOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BackgroundAgentsView
<LiveNotesView
onOpenNote={(path) => navigateToFile(path)}
onAddNewBackgroundAgent={() => {
submitFromPalette(buildBackgroundAgentSetupPrompt(), null)
onAddNewLiveNote={() => {
submitFromPalette(buildLiveNoteSetupPrompt(), null)
}}
/>
</div>
@ -4715,6 +4735,15 @@ function App() {
/>
</div>
) : selectedPath ? (
<>
{/* Always-mounted persistent cache for HTML/PDF — hidden when active file is something else, so iframes preserve scroll/page/zoom across switches. */}
<div
className="flex-1 min-h-0 overflow-hidden"
style={{ display: isCacheableViewerPath(selectedPath) ? 'block' : 'none' }}
>
<PersistentViewerCache activePath={selectedPath} />
</div>
{!isCacheableViewerPath(selectedPath) && (
selectedPath.endsWith('.md') ? (
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
@ -4790,6 +4819,10 @@ function App() {
)
})}
</div>
<LiveNoteSidebar
filePath={liveNotePanelPath}
onClose={() => setLiveNotePanelPath(null)}
/>
{versionHistoryPath && (
<VersionHistoryPanel
path={versionHistoryPath}
@ -4824,13 +4857,25 @@ function App() {
/>
)}
</div>
) : selectedPath && getViewerType(selectedPath) === 'image' ? (
<div className="flex-1 min-h-0 overflow-hidden">
<ImageFileViewer path={selectedPath} />
</div>
) : selectedPath && getViewerType(selectedPath) === 'video' ? (
<div className="flex-1 min-h-0 overflow-hidden">
<VideoFileViewer path={selectedPath} />
</div>
) : selectedPath && getViewerType(selectedPath) === 'audio' ? (
<div className="flex-1 min-h-0 overflow-hidden">
<AudioFileViewer path={selectedPath} />
</div>
) : (
<div className="flex-1 overflow-auto p-4">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
{fileContent || 'Loading...'}
</pre>
<div className="flex-1 min-h-0 overflow-hidden">
<UnsupportedFileViewer path={selectedPath} />
</div>
)
)}
</>
) : selectedTask ? (
<div className="flex-1 min-h-0 overflow-hidden">
<BackgroundTaskDetail
@ -5091,7 +5136,6 @@ function App() {
/>
</SidebarSectionProvider>
<Toaster />
<TrackSidebar />
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}

View file

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { ExternalLinkIcon, FileAudioIcon } from 'lucide-react'
interface AudioFileViewerProps {
path: string
}
type State = 'loading' | 'ready' | 'error'
function basename(path: string): string {
const idx = path.lastIndexOf('/')
return idx >= 0 ? path.slice(idx + 1) : path
}
export function AudioFileViewer({ path }: AudioFileViewerProps) {
const [state, setState] = useState<State>('loading')
useEffect(() => {
setState('loading')
}, [path])
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
if (state === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileAudioIcon className="size-6" />
<p className="text-sm font-medium text-foreground">Cannot play this audio file</p>
<p className="max-w-md text-xs">The codec or container format isn&apos;t supported.</p>
<button
type="button"
onClick={() => {
void window.ipc.invoke('shell:openPath', { path })
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
</div>
)
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/30 px-6">
<FileAudioIcon className="size-10 text-muted-foreground" />
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
{basename(path)}
</p>
<audio
key={path}
src={src}
controls
className="w-full max-w-lg"
onLoadedMetadata={() => setState('ready')}
onError={() => setState('error')}
/>
</div>
)
}

View file

@ -1,250 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Bot, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
import { toast } from '@/lib/toast'
type BackgroundAgentNote = {
path: string
trackCount: number
createdAt: string | null
lastRunAt: string | null
isActive: boolean
}
type BackgroundAgentsViewProps = {
onOpenNote: (path: string) => void
onAddNewBackgroundAgent: () => void
}
function formatDateLabel(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatDateTimeLabel(iso: string | null): string {
if (!iso) return 'Never'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return 'Never'
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
}
export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: BackgroundAgentsViewProps) {
const [notes, setNotes] = useState<BackgroundAgentNote[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const result = await window.ipc.invoke('track:listNotes', null)
setNotes(result.notes)
setError(null)
} catch (err) {
console.error('Failed to load background agent notes:', err)
setError('Could not load background agents.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
break
case 'moved':
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
scheduleReload()
}
break
}
})
const cleanupTracks = window.ipc.on('tracks:events', () => {
scheduleReload()
})
return () => {
cleanupWorkspace()
cleanupTracks()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const handleToggleState = useCallback(async (note: BackgroundAgentNote, active: boolean) => {
setUpdatingPaths((prev) => new Set(prev).add(note.path))
try {
const result = await window.ipc.invoke('track:setNoteActive', {
path: note.path,
active,
})
if (!result.success || !result.note) {
throw new Error(result.error ?? 'Failed to update background agent state')
}
const updatedNote = result.note
setNotes((prev) => prev.map((entry) => (
entry.path === note.path ? updatedNote : entry
)))
} catch (err) {
console.error('Failed to update background agent note state:', err)
toast(err instanceof Error ? err.message : 'Failed to update background agent state', 'error')
} finally {
setUpdatingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Background agents</h2>
</div>
<Button type="button" size="sm" onClick={onAddNewBackgroundAgent}>
Add new background agent
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Notes that contain tracks. Toggle a note inactive to pause every background agent in it.
</p>
</div>
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Bot className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : notes.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Bot className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No notes with background agents yet.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created date</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
</tr>
</thead>
<tbody>
{notes.map((note) => {
const isUpdating = updatingPaths.has(note.path)
return (
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
title={note.path}
>
{wikiLabel(note.path)}
</button>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'}
</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{stripKnowledgePrefix(note.path)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateLabel(note.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateTimeLabel(note.lastRunAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{isUpdating ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<span className="size-4 shrink-0" aria-hidden="true" />
)}
<Switch
checked={note.isActive}
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
disabled={isUpdating}
/>
<span className="min-w-16 text-xs font-medium text-foreground/80">
{note.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View file

@ -43,7 +43,21 @@ interface EditorToolbarProps {
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | 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<LivePillVariant, string> = {
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 && (
<Button
variant="ghost"
size="icon-sm"
onClick={onOpenTracks}
title="Tracks"
className="ml-auto"
{/* Live Note pill — pushed to far right */}
{onOpenLiveNote && liveState && (
<button
type="button"
onClick={onOpenLiveNote}
title={liveState.variant === 'passive' ? 'Make this note live' : 'Live note'}
className={`ml-auto inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors ${LIVE_PILL_VARIANT_CLASS[liveState.variant]}`}
>
<Radio className="size-4" />
</Button>
<Radio className="size-3.5" />
<span className="truncate max-w-[160px]">{liveState.label}</span>
</button>
)}
</div>
)

View file

@ -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)

View file

@ -0,0 +1,155 @@
import { useEffect, useState } from 'react'
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
const MAX_SIZE_BYTES = 5 * 1024 * 1024
const CACHE_MAX_ENTRIES = 20
type CacheEntry = { html: string; mtimeMs: number; size: number }
const htmlCache = new Map<string, CacheEntry>()
function getCached(path: string, mtimeMs: number, size: number): string | null {
const entry = htmlCache.get(path)
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
// Refresh LRU position
htmlCache.delete(path)
htmlCache.set(path, entry)
return entry.html
}
function setCached(path: string, html: string, mtimeMs: number, size: number) {
htmlCache.set(path, { html, mtimeMs, size })
while (htmlCache.size > CACHE_MAX_ENTRIES) {
const oldest = htmlCache.keys().next().value
if (oldest === undefined) break
htmlCache.delete(oldest)
}
}
type ViewerState =
| { kind: 'loading' }
| { kind: 'loaded'; html: string }
| { kind: 'empty' }
| { kind: 'tooLarge'; sizeMB: number }
| { kind: 'error'; message: string }
interface HtmlFileViewerProps {
path: string
}
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
const [iframeLoaded, setIframeLoaded] = useState(false)
useEffect(() => {
let cancelled = false
setState({ kind: 'loading' })
setIframeLoaded(false)
;(async () => {
try {
const stat = await window.ipc.invoke('workspace:stat', { path })
if (cancelled) return
if (stat.kind !== 'file') {
setState({ kind: 'error', message: 'Selected path is not a file.' })
return
}
if (stat.size > MAX_SIZE_BYTES) {
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
return
}
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
if (cachedHtml !== null) {
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
return
}
const result = await window.ipc.invoke('workspace:readFile', { path })
if (cancelled) return
setCached(path, result.data, stat.mtimeMs, stat.size)
if (!result.data || result.data.trim() === '') {
setState({ kind: 'empty' })
return
}
setState({ kind: 'loaded', html: result.data })
} catch (err) {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
setState({ kind: 'error', message })
}
})()
return () => {
cancelled = true
}
}, [path])
if (state.kind === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground">
<AlertCircleIcon className="size-6 text-destructive" />
<p className="text-sm font-medium text-foreground">Could not load preview</p>
<p className="max-w-md text-xs">{state.message}</p>
<p className="text-xs opacity-60">{path}</p>
</div>
)
}
if (state.kind === 'empty') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-muted-foreground">
<FileTextIcon className="size-6" />
<p className="text-sm">This file is empty</p>
</div>
)
}
if (state.kind === 'tooLarge') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileTextIcon className="size-6" />
<p className="text-sm font-medium text-foreground">File too large to preview</p>
<p className="text-xs">
{state.sizeMB.toFixed(1)} MB preview limit is {(MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB.
</p>
<button
type="button"
onClick={() => {
void window.ipc.invoke('shell:openPath', { path })
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
</div>
)
}
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
// gets a null origin with no base URL. Trade-off: relative assets inside
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
// works fine; HTML that ships next to sibling assets will look broken.
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
// support; that path also resolves through the existing path-traversal
// guard in resolveWorkspacePath.
return (
<div className="relative h-full w-full">
{state.kind === 'loaded' && (
<iframe
key={path}
srcDoc={state.html}
sandbox="allow-scripts"
className="h-full w-full border-0 bg-white"
title="HTML preview"
onLoad={() => setIframeLoaded(true)}
/>
)}
{(state.kind === 'loading' || !iframeLoaded) && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Rendering preview</p>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { ExternalLinkIcon, FileImageIcon, Loader2Icon } from 'lucide-react'
interface ImageFileViewerProps {
path: string
}
type State = 'loading' | 'loaded' | 'error'
export function ImageFileViewer({ path }: ImageFileViewerProps) {
const [state, setState] = useState<State>('loading')
useEffect(() => {
setState('loading')
}, [path])
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
if (state === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileImageIcon className="size-6" />
<p className="text-sm font-medium text-foreground">Cannot preview this image</p>
<p className="max-w-md text-xs">The format may be unsupported (e.g. HEIC on Windows).</p>
<button
type="button"
onClick={() => {
void window.ipc.invoke('shell:openPath', { path })
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
</div>
)
}
return (
<div className="relative flex h-full w-full items-center justify-center bg-muted/30">
<img
key={path}
src={src}
alt={path}
className="max-h-full max-w-full object-contain"
onLoad={() => setState('loaded')}
onError={() => setState('error')}
style={state === 'loading' ? { opacity: 0 } : undefined}
/>
{state === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading image</p>
</div>
)}
</div>
)
}

View file

@ -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<string, string> = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Hourly',
'0 */2 * * *': 'Every 2 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 8 * * *': 'Daily at 8 AM',
'0 9 * * *': 'Daily at 9 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6 PM',
'0 9 * * 1-5': 'Weekdays at 9 AM',
'0 17 * * 1-5': 'Weekdays at 5 PM',
}
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 <Clock size={size} />
if (icon === 'calendar') return <CalendarClock size={size} />
return <Zap size={size} />
}
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<LiveNote | null>(null)
const [draft, setDraft] = useState<LiveNote | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [error, setError] = useState<string | null>(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 (
<aside className="flex w-[420px] max-w-[40vw] shrink-0 flex-col overflow-hidden border-l border-border bg-background">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground">
<Radio className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">Live note</span>
<span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span>
</div>
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={onClose}
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{error && (
<div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{loading && (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
)}
{!loading && !live && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<Radio className="size-8 text-muted-foreground/50" />
<div className="text-sm font-medium text-foreground">This note is passive</div>
<div className="text-xs text-muted-foreground max-w-[260px]">
Make it live to have an agent keep its body up to date describe what you want it to track and how often.
</div>
<Button size="sm" onClick={handleMakeLive} className="mt-2">
<Sparkles className="size-3" />
Make this note live
</Button>
</div>
)}
{!loading && live && draft && sched && (
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}>
{/* Status row: schedule summary + active toggle. */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2">
<span className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground">
<ScheduleIcon icon={sched.icon} />
<span className="truncate">
{paused ? `Paused · ${sched.text}` : sched.text}
</span>
</span>
<label className="flex shrink-0 items-center gap-2">
<Switch
checked={!paused}
onCheckedChange={handleToggleActive}
disabled={saving}
/>
<span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span>
</label>
</div>
{/* Persistent error banner — shows lastRunError until the next successful run. */}
{!isRunning && live.lastRunError && (
<div className="mx-3 mt-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertCircle className="size-3.5 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="font-medium">
Last run failed{live.lastAttemptAt ? ` · ${formatRelativeTime(live.lastAttemptAt)}` : ''}
</div>
<div className="break-words text-amber-700/90 dark:text-amber-300/90">{live.lastRunError}</div>
</div>
</div>
)}
{/* Body */}
<div className="flex-1 overflow-auto px-3 py-3 space-y-4">
{/* Objective */}
<Section label="Objective" hint="What this note should keep being.">
<Textarea
value={draft.objective}
onChange={(e) => setDraft({ ...draft, objective: e.target.value })}
rows={6}
spellCheck
placeholder="Keep this note updated with…"
className="font-sans text-sm"
/>
</Section>
{/* Triggers */}
<Section label="Triggers" hint="When the agent fires. Mix freely; absent fields just don't fire.">
<TriggersEditor draft={draft} setDraft={setDraft} />
</Section>
{/* Status */}
{(live.lastRunAt || live.lastRunSummary) && (
<Section label="Last run">
<DetailGrid>
{live.lastRunAt && <DetailRow label="At" value={formatDateTime(live.lastRunAt)} />}
{live.lastRunSummary && <DetailRow label="Summary" value={live.lastRunSummary} />}
</DetailGrid>
</Section>
)}
{/* Advanced (model + provider + danger zone) */}
<div className="border-t border-border pt-3">
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced(s => !s)}
>
{showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
Advanced (model · provider · danger zone)
</button>
{showAdvanced && (
<div className="mt-2 space-y-3">
<LabeledField label="Model">
<Input
value={draft.model ?? ''}
onChange={(e) => setDraft({ ...draft, model: e.target.value || undefined })}
placeholder="(use global default)"
className="font-mono text-xs"
/>
</LabeledField>
<LabeledField label="Provider">
<Input
value={draft.provider ?? ''}
onChange={(e) => setDraft({ ...draft, provider: e.target.value || undefined })}
placeholder="(use global default)"
className="font-mono text-xs"
/>
</LabeledField>
<div className="border-t border-border pt-3">
{confirmingDelete ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
<span className="text-destructive">Make this note passive?</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
Make passive
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 className="size-3" />
Make passive
</Button>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer — pulsing "Updating…" pill on the left when running */}
<div className="flex shrink-0 items-center gap-2 border-t border-border bg-muted/20 px-3 py-2.5">
{isRunning && (
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
<Loader2 className="size-3 animate-spin" />
Updating
</span>
)}
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving || isRunning}>
<Sparkles className="size-3" />
Edit with Copilot
</Button>
{isDirty && !isRunning && (
<Button variant="outline" size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Save className="size-3" />}
Save
</Button>
)}
{isRunning ? (
<Button
variant="destructive"
size="sm"
onClick={handleStop}
disabled={saving}
>
<Square className="size-3" />
Stop
</Button>
) : (
<Button
size="sm"
onClick={handleRun}
disabled={saving}
>
<Play className="size-3" />
Run now
</Button>
)}
</div>
</div>
</div>
)}
</aside>
)
}
function Section({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<span className="text-xs font-medium text-foreground">{label}</span>
{hint && <span className="text-[10px] text-muted-foreground">{hint}</span>}
</div>
{children}
</div>
)
}
function LabeledField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<div>{children}</div>
</div>
)
}
function TriggersEditor({
draft,
setDraft,
}: {
draft: LiveNote
setDraft: (next: LiveNote) => void
}) {
const triggers: Triggers = draft.triggers ?? {}
const hasCron = typeof triggers.cronExpr === 'string'
const hasWindows = Array.isArray(triggers.windows)
const hasEvent = typeof triggers.eventMatchCriteria === 'string'
const updateTriggers = (next: Partial<Triggers>) => {
const merged: Triggers = { ...triggers, ...next }
// Strip undefined
;(Object.keys(merged) as (keyof Triggers)[]).forEach(key => {
if (merged[key] === undefined) delete merged[key]
})
if (Object.keys(merged).length === 0) {
const { triggers: _omit, ...rest } = draft
setDraft(rest as LiveNote)
} else {
setDraft({ ...draft, triggers: merged })
}
}
return (
<div className="space-y-3">
{/* cronExpr */}
<TriggerRow
present={hasCron}
label="Cron"
onAdd={() => updateTriggers({ cronExpr: '0 * * * *' })}
onRemove={() => updateTriggers({ cronExpr: undefined })}
>
{hasCron && (
<Input
value={triggers.cronExpr ?? ''}
onChange={(e) => updateTriggers({ cronExpr: e.target.value })}
placeholder='"0 * * * *"'
className="font-mono text-xs"
/>
)}
{hasCron && triggers.cronExpr && (
<div className="text-[10px] text-muted-foreground">{describeCron(triggers.cronExpr)}</div>
)}
</TriggerRow>
{/* windows */}
<TriggerRow
present={hasWindows}
label="Windows"
onAdd={() => updateTriggers({ windows: [{ startTime: '09:00', endTime: '12:00' }] })}
onRemove={() => updateTriggers({ windows: undefined })}
>
{triggers.windows && (
<div className="space-y-1.5">
{triggers.windows.map((w, idx) => (
<div key={idx} className="flex items-center gap-1.5">
<Input
value={w.startTime}
onChange={(e) => {
const next = [...(triggers.windows ?? [])]
next[idx] = { ...next[idx], startTime: e.target.value }
updateTriggers({ windows: next })
}}
placeholder="09:00"
className={`h-7 w-20 font-mono text-xs ${HH_MM.test(w.startTime) ? '' : 'border-destructive'}`}
/>
<span className="text-xs text-muted-foreground"></span>
<Input
value={w.endTime}
onChange={(e) => {
const next = [...(triggers.windows ?? [])]
next[idx] = { ...next[idx], endTime: e.target.value }
updateTriggers({ windows: next })
}}
placeholder="12:00"
className={`h-7 w-20 font-mono text-xs ${HH_MM.test(w.endTime) ? '' : 'border-destructive'}`}
/>
<button
type="button"
onClick={() => {
const next = (triggers.windows ?? []).filter((_, i) => i !== idx)
updateTriggers({ windows: next.length === 0 ? undefined : next })
}}
className="ml-1 inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Remove window"
>
<X className="size-3" />
</button>
</div>
))}
<button
type="button"
onClick={() => updateTriggers({
windows: [...(triggers.windows ?? []), { startTime: '13:00', endTime: '15:00' }],
})}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
>
<Plus className="size-3" /> Add window
</button>
</div>
)}
</TriggerRow>
{/* eventMatchCriteria */}
<TriggerRow
present={hasEvent}
label="Events"
onAdd={() => updateTriggers({ eventMatchCriteria: '' })}
onRemove={() => updateTriggers({ eventMatchCriteria: undefined })}
>
{hasEvent && (
<Textarea
value={triggers.eventMatchCriteria ?? ''}
onChange={(e) => updateTriggers({ eventMatchCriteria: e.target.value })}
rows={3}
placeholder="Emails or calendar events about…"
className="text-xs"
/>
)}
</TriggerRow>
</div>
)
}
function TriggerRow({
present,
label,
onAdd,
onRemove,
children,
}: {
present: boolean
label: string
onAdd: () => void
onRemove: () => void
children?: React.ReactNode
}) {
return (
<div className="rounded-md border border-border bg-muted/20 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{label}</span>
{present ? (
<button
type="button"
onClick={onRemove}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Remove ${label}`}
>
<X className="size-3" />
</button>
) : (
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
>
<Plus className="size-3" /> Add
</button>
)}
</div>
{present && children && <div className="mt-2 space-y-1">{children}</div>}
</div>
)
}
function DetailGrid({ children }: { children: React.ReactNode }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs">
{children}
</dl>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-foreground">{value}</dd>
</>
)
}

View file

@ -0,0 +1,344 @@
import { useCallback, useEffect, useState } from 'react'
import { Radio, Loader2, Square, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
import { toast } from '@/lib/toast'
import { formatRelativeTime } from '@/lib/relative-time'
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
type LiveNoteRow = {
path: string
createdAt: string | null
lastRunAt: string | null
isActive: boolean
objective: string
lastRunError?: string | null
lastAttemptAt?: string | null
}
type LiveNotesViewProps = {
onOpenNote: (path: string) => void
onAddNewLiveNote: () => void
}
function formatDateLabel(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatLastRanLabel(iso: string | null): string {
if (!iso) return 'Never'
return formatRelativeTime(iso) || 'Never'
}
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
}
export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewProps) {
const [notes, setNotes] = useState<LiveNoteRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
const [stoppingPaths, setStoppingPaths] = useState<Set<string>>(new Set())
const agentStatus = useLiveNoteAgentStatus()
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const result = await window.ipc.invoke('live-note:listNotes', null)
// listNotes returns the summary fields; we also want lastRunError +
// lastAttemptAt so the rows can render the error/running state. The
// current IPC summary doesn't include them — fetch those per-note in
// parallel so the rows can render fully.
const enriched = await Promise.all(result.notes.map(async (n) => {
const knowledgeRel = n.path.replace(/^knowledge\//, '')
try {
const detail = await window.ipc.invoke('live-note:get', { filePath: knowledgeRel })
if (detail.success && detail.live) {
return {
...n,
lastRunError: detail.live.lastRunError ?? null,
lastAttemptAt: detail.live.lastAttemptAt ?? null,
} satisfies LiveNoteRow
}
} catch {
// fall through
}
return n satisfies LiveNoteRow
}))
setNotes(enriched)
setError(null)
} catch (err) {
console.error('Failed to load live notes:', err)
setError('Could not load live notes.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
break
case 'moved':
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
scheduleReload()
}
break
}
})
const cleanupAgentEvents = window.ipc.on('live-note-agent:events', () => {
scheduleReload()
})
return () => {
cleanupWorkspace()
cleanupAgentEvents()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const handleToggleState = useCallback(async (note: LiveNoteRow, active: boolean) => {
setUpdatingPaths((prev) => new Set(prev).add(note.path))
try {
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
const result = await window.ipc.invoke('live-note:setActive', {
filePath: knowledgeRelative,
active,
})
if (!result.success || !result.live) {
throw new Error(result.error ?? 'Failed to update live-note state')
}
setNotes((prev) => prev.map((entry) => (
entry.path === note.path
? {
...entry,
isActive: result.live!.active !== false,
lastRunAt: result.live!.lastRunAt ?? entry.lastRunAt,
lastRunError: result.live!.lastRunError ?? null,
lastAttemptAt: result.live!.lastAttemptAt ?? entry.lastAttemptAt,
}
: entry
)))
} catch (err) {
console.error('Failed to update live-note state:', err)
toast(err instanceof Error ? err.message : 'Failed to update live-note state', 'error')
} finally {
setUpdatingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
const handleStop = useCallback(async (note: LiveNoteRow) => {
setStoppingPaths((prev) => new Set(prev).add(note.path))
try {
const knowledgeRelative = note.path.replace(/^knowledge\//, '')
const result = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelative })
if (!result.success && result.error) {
toast(result.error, 'error')
}
} catch (err) {
toast(err instanceof Error ? err.message : 'Failed to stop run', 'error')
} finally {
setStoppingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Radio className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Live notes</h2>
</div>
<Button type="button" size="sm" onClick={onAddNewLiveNote}>
New live note
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Notes whose body is kept current by an agent. Toggle a note inactive to pause its agent.
</p>
</div>
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Radio className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : notes.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Radio className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No live notes yet.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[50%]" />
<col className="w-[15%]" />
<col className="w-[15%]" />
<col className="w-[20%]" />
</colgroup>
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
</tr>
</thead>
<tbody>
{notes.map((note) => {
const isUpdating = updatingPaths.has(note.path)
const isStopping = stoppingPaths.has(note.path)
const knowledgeRel = note.path.replace(/^knowledge\//, '')
const runState = agentStatus.get(knowledgeRel)
const isRunning = runState?.status === 'running'
const objectivePreview = note.objective.split('\n')[0].trim()
const hasError = !isRunning && !!note.lastRunError
return (
<tr
key={note.path}
className={`border-b border-border/50 last:border-b-0 transition-colors ${isRunning ? 'bg-primary/5' : 'hover:bg-muted/20'}`}
>
<td className="px-4 py-3 align-top">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-1.5">
{hasError && (
<AlertCircle
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
aria-label="Last run failed"
>
<title>Last run failed: {note.lastRunError}</title>
</AlertCircle>
)}
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
title={note.path}
>
{wikiLabel(note.path)}
</button>
</div>
<div className="truncate text-xs text-muted-foreground">
{stripKnowledgePrefix(note.path)}
</div>
{objectivePreview && (
<div className="truncate text-xs text-muted-foreground/80" title={note.objective}>
{objectivePreview}
</div>
)}
{hasError && note.lastRunError && (
<div className="truncate text-xs text-amber-600 dark:text-amber-400" title={note.lastRunError}>
{note.lastRunError}
</div>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateLabel(note.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatLastRanLabel(note.lastRunAt)}
</td>
<td className="px-4 py-3">
{isRunning ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground animate-pulse">
<Loader2 className="size-3 animate-spin" />
Updating
</span>
<Button
variant="destructive"
size="sm"
onClick={() => handleStop(note)}
disabled={isStopping}
>
{isStopping ? <Loader2 className="size-3 animate-spin" /> : <Square className="size-3" />}
Stop
</Button>
</div>
) : (
<div className="flex items-center gap-3">
{isUpdating ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<span className="size-4 shrink-0" aria-hidden="true" />
)}
<Switch
checked={note.isActive}
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
disabled={isUpdating}
/>
<span className="min-w-16 text-xs font-medium text-foreground/80">
{note.isActive ? 'Active' : 'Inactive'}
</span>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View file

@ -290,7 +290,9 @@ function computeWithinBlockOffset(
return 0
}
}
import { EditorToolbar } from './editor-toolbar'
import { EditorToolbar, type LivePillState } from './editor-toolbar'
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
import { formatRelativeTime } from '@/lib/relative-time'
import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
@ -1422,6 +1424,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
return createImageUploadHandler(editor, onImageUpload)
}, [editor, onImageUpload])
// Live-note pill state for the toolbar — derived from the on-disk `live:`
// block plus the agent-status bus. The `tick` dependency keeps the relative
// time label fresh as minutes roll over.
const { live: currentLive, isRunning: liveIsRunning, tick: liveTick } = useLiveNoteForPath(notePath)
const livePillStateForCurrentNote: LivePillState = useMemo(() => {
void liveTick // re-run on tick to refresh relative-time label
if (!currentLive) return { variant: 'passive', label: 'Make live' }
if (liveIsRunning) return { variant: 'running', label: 'Updating…' }
if (currentLive.lastRunError) {
const when = currentLive.lastAttemptAt ? formatRelativeTime(currentLive.lastAttemptAt) : ''
return { variant: 'error', label: when ? `Live · failed ${when}` : 'Live · failed' }
}
if (currentLive.active === false) return { variant: 'passive', label: 'Live · paused' }
if (currentLive.lastRunAt) {
const when = formatRelativeTime(currentLive.lastRunAt)
return { variant: 'idle', label: when ? `Live · ${when}` : 'Live' }
}
return { variant: 'idle', label: 'Live · never run' }
}, [currentLive, liveIsRunning, liveTick])
return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar
@ -1429,11 +1451,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onExport={onExport}
onOpenTracks={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', {
onOpenLiveNote={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
detail: { filePath: notePath },
}))
} : undefined}
liveState={notePath ? livePillStateForCurrentNote : undefined}
/>
{(frontmatter !== undefined) && onFrontmatterChange && (
<FrontmatterProperties

View file

@ -59,14 +59,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
@ -109,7 +109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@ -442,7 +442,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = {
provider: {
flavor: llmProvider,
@ -452,7 +452,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
model,
knowledgeGraphModel,
meetingNotesModel,
trackBlockModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -1195,14 +1195,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
) : showModelInput ? (
<Input
value={activeConfig.trackBlockModel}
onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })}
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.trackBlockModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })}
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />

View file

@ -104,7 +104,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Tip:</span> Sign in with Rowboat for instant access to leading models. No API keys needed.
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
</p>
<button
onClick={handleSwitchToRowboat}
@ -268,14 +268,14 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
</div>
) : showModelInput ? (
<Input
value={activeConfig.trackBlockModel}
onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })}
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.trackBlockModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })}
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger className="w-full truncate">
<SelectValue placeholder="Select a model" />

View file

@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
@ -81,7 +81,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@ -419,7 +419,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
const providerConfig = {
provider: {
flavor: llmProvider,
@ -429,7 +429,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
model,
knowledgeGraphModel,
meetingNotesModel,
trackBlockModel,
liveNoteAgentModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -446,7 +446,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.trackBlockModel, canTest, llmProvider, handleNext])
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {

View file

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
interface PdfFileViewerProps {
path: string
}
type State = 'loading' | 'ready' | 'error'
export function PdfFileViewer({ path }: PdfFileViewerProps) {
const [state, setState] = useState<State>('loading')
useEffect(() => {
setState('loading')
}, [path])
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
if (state === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileTextIcon className="size-6" />
<p className="text-sm font-medium text-foreground">Cannot preview this PDF</p>
<button
type="button"
onClick={() => {
void window.ipc.invoke('shell:openPath', { path })
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
</div>
)
}
return (
<div className="relative h-full w-full">
<iframe
key={path}
src={src}
className="h-full w-full border-0 bg-white"
title="PDF preview"
onLoad={() => setState('ready')}
onError={() => setState('error')}
/>
{state === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading PDF</p>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { HtmlFileViewer } from './html-file-viewer'
import { PdfFileViewer } from './pdf-file-viewer'
import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'
const CACHE_LIMIT = 3
function renderViewer(path: string): JSX.Element | null {
const type = getViewerType(path)
if (type === 'html') return <HtmlFileViewer path={path} />
if (type === 'pdf') return <PdfFileViewer path={path} />
return null
}
interface PersistentViewerCacheProps {
activePath: string
}
/**
* Keeps recently-opened HTML and PDF viewers mounted in the DOM,
* toggling visibility instead of unmounting. This preserves iframe
* state (PDF page/zoom, HTML scroll/JS state) across file switches.
*/
export function PersistentViewerCache({ activePath }: PersistentViewerCacheProps) {
const [mountedPaths, setMountedPaths] = useState<string[]>(() =>
isCacheableViewerPath(activePath) ? [activePath] : []
)
useEffect(() => {
if (!isCacheableViewerPath(activePath)) return
setMountedPaths((prev) => {
// Never reorder existing entries — moving a keyed iframe in the DOM
// detaches it, which causes the browser to re-navigate (state lost).
if (prev.includes(activePath)) return prev
const next = [...prev, activePath]
return next.length > CACHE_LIMIT ? next.slice(-CACHE_LIMIT) : next
})
}, [activePath])
return (
<div className="relative h-full w-full">
{mountedPaths.map((p) => (
<div
key={p}
className="absolute inset-0"
style={{ display: p === activePath ? 'block' : 'none' }}
>
{renderViewer(p)}
</div>
))}
</div>
)
}

View file

@ -196,14 +196,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
@ -229,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
@ -303,7 +303,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "",
trackBlockModel: e.trackBlockModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "",
};
}
}
@ -321,7 +321,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "",
trackBlockModel: parsed.trackBlockModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
};
}
return next;
@ -396,7 +396,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
trackBlockModel: activeConfig.trackBlockModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -430,7 +430,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
models: allModels,
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
trackBlockModel: config.trackBlockModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
@ -461,7 +461,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.trackBlockModel = defConfig.trackBlockModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -469,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
}))
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
@ -704,14 +704,14 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</div>
) : showModelInput ? (
<Input
value={activeConfig.trackBlockModel}
onChange={(e) => updateConfig(provider, { trackBlockModel: e.target.value })}
value={activeConfig.liveNoteAgentModel}
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.trackBlockModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { trackBlockModel: value === "__same__" ? "" : value })}
value={activeConfig.liveNoteAgentModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />

View file

@ -94,6 +94,7 @@ import { ConnectorsPopover } from "@/components/connectors-popover"
import { HelpPopover } from "@/components/help-popover"
import { SettingsDialog } from "@/components/settings-dialog"
import { toast } from "@/lib/toast"
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
import { useBilling } from "@/hooks/useBilling"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
@ -214,8 +215,8 @@ type SidebarContentPanelProps = {
onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void
isBackgroundAgentsOpen?: boolean
onOpenBackgroundAgents?: () => void
isLiveNotesOpen?: boolean
onOpenLiveNotes?: () => void
} & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -229,25 +230,6 @@ function formatEventTime(ts: string): string {
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
}
function formatRunTime(ts: string): string {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return ""
const now = Date.now()
const diffMs = Math.max(0, now - date.getTime())
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
const diffMonths = Math.floor(diffDays / 30)
if (diffMinutes < 1) return "just now"
if (diffMinutes < 60) return `${diffMinutes} m`
if (diffHours < 24) return `${diffHours} h`
if (diffDays < 7) return `${diffDays} d`
if (diffWeeks < 4) return `${diffWeeks} w`
return `${Math.max(1, diffMonths)} m`
}
function SyncStatusBar() {
const { state } = useSidebar()
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
@ -493,8 +475,8 @@ export function SidebarContentPanel({
onToggleBrowser,
isSuggestedTopicsOpen = false,
onOpenSuggestedTopics,
isBackgroundAgentsOpen = false,
onOpenBackgroundAgents,
isLiveNotesOpen = false,
onOpenLiveNotes,
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
@ -510,7 +492,7 @@ export function SidebarContentPanel({
const isMeetingQuickActionSelected = isMeetingActionActive
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => {
try {
@ -684,19 +666,19 @@ export function SidebarContentPanel({
<span>Suggested Topics</span>
</button>
)}
{onOpenBackgroundAgents && (
{onOpenLiveNotes && (
<button
type="button"
onClick={onOpenBackgroundAgents}
onClick={onOpenLiveNotes}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isBackgroundAgentsQuickActionSelected
isLiveNotesQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Bot className="size-4" />
<span>Background agents</span>
<Radio className="size-4" />
<span>Live notes</span>
</button>
)}
</div>

View file

@ -1,627 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { z } from 'zod'
import '@/styles/track-modal.css'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
Trash2, ChevronDown, ChevronUp, ChevronLeft, X,
} from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackSchema, type Trigger } from '@x/shared/dist/track.js'
import { useTrackStatus } from '@/hooks/use-track-status'
export type OpenTrackSidebarDetail = {
filePath: string
selectId?: string
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
const CRON_PHRASES: Record<string, string> = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Hourly',
'0 */2 * * *': 'Every 2 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 8 * * *': 'Daily at 8 AM',
'0 9 * * *': 'Daily at 9 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6 PM',
'0 9 * * 1-5': 'Weekdays at 9 AM',
'0 17 * * 1-5': 'Weekdays at 5 PM',
'0 0 * * 0': 'Sundays at midnight',
'0 0 * * 1': 'Mondays at midnight',
'0 0 1 * *': 'First of each month',
}
function describeCron(expr: string): string {
return CRON_PHRASES[expr.trim()] ?? expr
}
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
function describeTrigger(t: Trigger): ScheduleSummary {
if (t.type === 'once') return { icon: 'target', text: `Once at ${formatDateTime(t.runAt)}` }
if (t.type === 'cron') return { icon: 'timer', text: describeCron(t.expression) }
if (t.type === 'window') return { icon: 'calendar', text: `${t.startTime}${t.endTime}` }
return { icon: 'bolt', text: 'Event-driven' }
}
function summarizeTriggers(triggers: Trigger[] | undefined): ScheduleSummary {
if (!triggers || triggers.length === 0) return { icon: 'bolt', text: 'Manual only' }
const timed = triggers.filter(t => t.type !== 'event')
const events = triggers.filter(t => t.type === 'event')
if (timed.length === 0) {
return { icon: 'bolt', text: events.length > 1 ? `${events.length} event triggers` : 'Event-driven' }
}
const first = describeTrigger(timed[0])
let text = first.text
if (timed.length > 1) text += ` (+${timed.length - 1})`
if (events.length > 0) text += ' · also event-driven'
return { icon: first.icon, text }
}
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
if (icon === 'timer') return <Clock size={size} />
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
return <Zap size={size} />
}
function stripKnowledgePrefix(p: string): string {
return p.replace(/^knowledge\//, '')
}
type Track = z.infer<typeof TrackSchema>
function parseTracksFromFile(content: string): Track[] {
if (!content.startsWith('---')) return []
const close = /\r?\n---\r?\n/.exec(content)
if (!close) return []
const yamlText = content.slice(3, close.index).trim()
if (!yamlText) return []
let fm: unknown
try { fm = parseYaml(yamlText) } catch { return [] }
if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return []
const raw = (fm as Record<string, unknown>).track
if (!Array.isArray(raw)) return []
const tracks: Track[] = []
for (const entry of raw) {
const result = TrackSchema.safeParse(entry)
if (result.success) tracks.push(result.data)
}
return tracks
}
type Tab = 'what' | 'when' | 'event' | 'details'
export function TrackSidebar() {
const [open, setOpen] = useState(false)
const [filePath, setFilePath] = useState<string>('')
const [tracks, setTracks] = useState<Track[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Detail-view state (per-track local UI)
const [activeTab, setActiveTab] = useState<Tab>('what')
const [editingRaw, setEditingRaw] = useState(false)
const [rawDraft, setRawDraft] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [saving, setSaving] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath), [filePath])
const allTrackStatus = useTrackStatus()
const refresh = useCallback(async (relPath: string) => {
if (!relPath) { setTracks([]); return }
setLoading(true)
setError(null)
try {
const res = await window.ipc.invoke('workspace:readFile', { path: `knowledge/${relPath}` })
if (res?.data) {
setTracks(parseTracksFromFile(res.data))
} else {
setTracks([])
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setTracks([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<OpenTrackSidebarDetail>
const d = ev.detail
if (!d?.filePath) return
setFilePath(d.filePath)
setSelectedId(d.selectId ?? null)
setActiveTab('what')
setEditingRaw(false)
setRawDraft('')
setShowAdvanced(false)
setConfirmingDelete(false)
setError(null)
setOpen(true)
void refresh(stripKnowledgePrefix(d.filePath))
}
window.addEventListener('rowboat:open-track-sidebar', handler as EventListener)
return () => window.removeEventListener('rowboat:open-track-sidebar', handler as EventListener)
}, [refresh])
// Re-fetch when a run completes for a track in this file.
useEffect(() => {
if (!open || !knowledgeRelPath) return
let stale = false
for (const [, state] of allTrackStatus) {
if (state.status === 'done' || state.status === 'error') {
stale = true
break
}
}
if (stale) void refresh(knowledgeRelPath)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allTrackStatus, open, knowledgeRelPath])
const selected = useMemo(
() => (selectedId ? tracks.find(t => t.id === selectedId) ?? null : null),
[selectedId, tracks],
)
// Seed raw editor draft when entering advanced mode.
useEffect(() => {
if (showAdvanced && selected) {
try {
// Lazy import yaml stringify only when needed; avoid top-level dep cycle.
import('yaml').then(({ stringify }) => {
setRawDraft(stringify(selected).trimEnd())
})
} catch {
setRawDraft('')
}
}
}, [showAdvanced, selected])
useEffect(() => {
if (editingRaw && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [editingRaw])
const runUpdate = useCallback(async (id: string, updates: Record<string, unknown>) => {
if (!knowledgeRelPath) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:update', { id, filePath: knowledgeRelPath, updates })
if (!res?.success && res?.error) setError(res.error)
await refresh(knowledgeRelPath)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, refresh])
const handleToggleActive = useCallback((id: string, currentlyActive: boolean) => {
void runUpdate(id, { active: !currentlyActive })
}, [runUpdate])
const handleRun = useCallback(async (id: string) => {
if (!knowledgeRelPath) return
try {
await window.ipc.invoke('track:run', { id, filePath: knowledgeRelPath })
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [knowledgeRelPath])
const handleSaveRaw = useCallback(async () => {
if (!knowledgeRelPath || !selectedId) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:replaceYaml', { id: selectedId, filePath: knowledgeRelPath, yaml: rawDraft })
if (res?.success) {
setEditingRaw(false)
await refresh(knowledgeRelPath)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, selectedId, rawDraft, refresh])
const handleDelete = useCallback(async () => {
if (!knowledgeRelPath || !selectedId) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:delete', { id: selectedId, filePath: knowledgeRelPath })
if (res?.success) {
setSelectedId(null)
setConfirmingDelete(false)
await refresh(knowledgeRelPath)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [knowledgeRelPath, selectedId, refresh])
const handleEditWithCopilot = useCallback(() => {
if (!filePath || !selectedId) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
detail: { trackId: selectedId, filePath },
}))
setOpen(false)
}, [filePath, selectedId])
if (!open) return null
const noteTitle = filePath
? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '')
: 'Tracks'
return (
<aside className="fixed inset-y-0 right-0 z-60 flex w-[min(420px,calc(100vw-2rem))] flex-col overflow-hidden border-l border-border bg-background shadow-2xl">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground">
<Radio className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">Tracks</span>
<span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span>
</div>
<button
type="button"
className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={() => setOpen(false)}
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{error && (
<div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{!selected && (
<div className="flex-1 overflow-auto">
{loading && (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
)}
{!loading && tracks.length === 0 && (
<div className="flex flex-col items-center gap-1.5 px-6 py-12 text-center">
<Radio className="size-6 text-muted-foreground/50" />
<div className="text-sm text-muted-foreground">No tracks in this note yet.</div>
<div className="text-xs text-muted-foreground/70">
Ask Copilot &ldquo;track Chicago time hourly&rdquo; to add one.
</div>
</div>
)}
<ul className="divide-y divide-border">
{tracks.map(t => {
const sched = summarizeTriggers(t.triggers)
const runState = allTrackStatus.get(`${t.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const paused = t.active === false
const instructionPreview = t.instruction.split('\n')[0].trim()
return (
<li key={t.id}>
<button
type="button"
className={`group flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-accent ${paused ? 'opacity-60' : ''}`}
onClick={() => { setSelectedId(t.id); setActiveTab('what') }}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{t.id}</span>
<span className="truncate text-xs text-muted-foreground">
{paused ? 'Paused · ' : ''}{sched.text}
</span>
{instructionPreview && (
<span className="truncate text-xs text-muted-foreground/70">
{instructionPreview}
</span>
)}
</div>
<button
type="button"
className={`inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-background hover:text-foreground ${isRunning ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
onClick={(ev) => { ev.stopPropagation(); void handleRun(t.id) }}
disabled={isRunning}
aria-label={isRunning ? `Running ${t.id}` : `Run ${t.id}`}
title={isRunning ? `Running…` : `Run ${t.id}`}
>
{isRunning ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
</button>
</button>
</li>
)
})}
</ul>
</div>
)}
{selected && (() => {
const triggers: Trigger[] = selected.triggers ?? []
const timedTriggers = triggers.filter((t): t is Exclude<Trigger, { type: 'event' }> => t.type !== 'event')
const eventTriggers = triggers.filter((t): t is Extract<Trigger, { type: 'event' }> => t.type === 'event')
const sched = summarizeTriggers(triggers)
const runState = allTrackStatus.get(`${selected.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const paused = selected.active === false
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
{ key: 'what', label: 'What', visible: true },
{ key: 'when', label: 'Schedule', visible: timedTriggers.length > 0 },
{ key: 'event', label: 'Events', visible: eventTriggers.length > 0 },
{ key: 'details', label: 'Details', visible: true },
]
const shown = visibleTabs.filter(t => t.visible)
return (
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}>
{/* Subheader: back arrow + track id */}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => {
setSelectedId(null)
setShowAdvanced(false)
setEditingRaw(false)
setConfirmingDelete(false)
}}
aria-label="Back to tracks"
>
<ChevronLeft className="size-4" />
</button>
<span className="truncate text-sm font-medium">{selected.id}</span>
</div>
{/* Status row: schedule summary + active toggle */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2">
<span className="truncate text-xs text-muted-foreground">{sched.text}</span>
<label className="flex shrink-0 items-center gap-2">
<Switch
checked={!paused}
onCheckedChange={() => handleToggleActive(selected.id, !paused)}
disabled={saving}
/>
<span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span>
</label>
</div>
{/* Tabs */}
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
{shown.map(tab => (
<button
key={tab.key}
type="button"
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
activeTab === tab.key
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-auto px-3 py-3">
{activeTab === 'what' && (
<div className="text-sm leading-relaxed">
{selected.instruction ? (
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
{selected.instruction}
</Streamdown>
) : (
<span className="text-muted-foreground">No instruction set.</span>
)}
</div>
)}
{activeTab === 'when' && timedTriggers.length > 0 && (
<div className="flex flex-col gap-2">
{timedTriggers.map((trig, idx) => {
const tSched = describeTrigger(trig)
return (
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ScheduleIcon icon={tSched.icon} size={14} />
<span>{tSched.text}</span>
</div>
<DetailGrid>
<DetailRow label="Type" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.type}</code>} />
{trig.type === 'cron' && (
<DetailRow label="Expression" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.expression}</code>} />
)}
{trig.type === 'window' && (
<DetailRow label="Window" value={`${trig.startTime} ${trig.endTime}`} />
)}
{trig.type === 'once' && (
<DetailRow label="Runs at" value={formatDateTime(trig.runAt)} />
)}
</DetailGrid>
</div>
)
})}
</div>
)}
{activeTab === 'event' && (
<div className="flex flex-col gap-2">
{eventTriggers.length === 0 ? (
<span className="text-sm text-muted-foreground">No event matching set.</span>
) : eventTriggers.map((trig, idx) => (
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
{trig.matchCriteria}
</Streamdown>
</div>
))}
</div>
)}
{activeTab === 'details' && (
<DetailGrid>
<DetailRow label="ID" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.id}</code>} />
<DetailRow label="File" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px] break-all">{filePath}</code>} />
<DetailRow label="Status" value={paused ? 'Paused' : 'Active'} />
{selected.model && <DetailRow label="Model" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.model}</code>} />}
{selected.provider && <DetailRow label="Provider" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.provider}</code>} />}
{selected.lastRunAt && <DetailRow label="Last run" value={formatDateTime(selected.lastRunAt)} />}
{selected.lastRunSummary && <DetailRow label="Summary" value={selected.lastRunSummary} />}
</DetailGrid>
)}
{/* Advanced — raw YAML */}
<div className="mt-6 border-t border-border pt-3">
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const next = !showAdvanced
setShowAdvanced(next)
setEditingRaw(next)
}}
>
{showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
<Code2 className="size-3" />
Advanced (raw YAML)
</button>
{showAdvanced && (
<div className="mt-2 flex flex-col gap-2">
<Textarea
ref={textareaRef}
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={12}
spellCheck={false}
className="font-mono text-xs"
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => { setShowAdvanced(false); setEditingRaw(false) }}
disabled={saving}
>
Cancel
</Button>
<Button size="sm" onClick={handleSaveRaw} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : null}
Save
</Button>
</div>
</div>
)}
</div>
{/* Danger zone — Details tab only */}
{activeTab === 'details' && (
<div className="mt-4 border-t border-border pt-3">
{confirmingDelete ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
<span className="text-destructive">Delete this track?</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
Delete
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 className="size-3" />
Delete track
</Button>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/20 px-3 py-2.5">
<Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving}>
<Sparkles className="size-3" />
Edit with Copilot
</Button>
<Button
size="sm"
onClick={() => handleRun(selected.id)}
disabled={isRunning || saving}
>
{isRunning ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />}
{isRunning ? 'Running…' : 'Run now'}
</Button>
</div>
</div>
)
})()}
</aside>
)
}
function DetailGrid({ children }: { children: React.ReactNode }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs">
{children}
</dl>
)
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-foreground">{value}</dd>
</>
)
}

View file

@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import { ExternalLinkIcon, FileIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
const TEXT_FALLBACK_MAX_BYTES = 1 * 1024 * 1024 // 1 MB
interface UnsupportedFileViewerProps {
path: string
}
type State =
| { kind: 'loading' }
| { kind: 'ready'; sizeBytes: number; canShowAsText: boolean }
| { kind: 'error'; message: string }
function basename(path: string): string {
const idx = path.lastIndexOf('/')
return idx >= 0 ? path.slice(idx + 1) : path
}
function extensionLabel(path: string): string {
const name = basename(path)
const dot = name.lastIndexOf('.')
if (dot < 0) return 'No extension'
return name.slice(dot + 1).toUpperCase()
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function UnsupportedFileViewer({ path }: UnsupportedFileViewerProps) {
const [state, setState] = useState<State>({ kind: 'loading' })
const [textContent, setTextContent] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setState({ kind: 'loading' })
setTextContent(null)
;(async () => {
try {
const stat = await window.ipc.invoke('workspace:stat', { path })
if (cancelled) return
if (stat.kind !== 'file') {
setState({ kind: 'error', message: 'Selected path is not a file.' })
return
}
setState({
kind: 'ready',
sizeBytes: stat.size,
canShowAsText: stat.size <= TEXT_FALLBACK_MAX_BYTES,
})
} catch (err) {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
setState({ kind: 'error', message })
}
})()
return () => {
cancelled = true
}
}, [path])
async function loadAsText() {
try {
const result = await window.ipc.invoke('workspace:readFile', { path })
setTextContent(result.data)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setTextContent(`Failed to read as text: ${message}`)
}
}
if (state.kind === 'loading') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
</div>
)
}
if (state.kind === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground">
<FileIcon className="size-6" />
<p className="text-sm font-medium text-foreground">Could not open</p>
<p className="max-w-md text-xs">{state.message}</p>
</div>
)
}
if (textContent !== null) {
return (
<div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
<span className="truncate">{basename(path)} · plain text view</span>
<button
type="button"
onClick={() => setTextContent(null)}
className="text-foreground hover:underline"
>
Hide
</button>
</div>
<div className="flex-1 overflow-auto p-4">
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">{textContent}</pre>
</div>
</div>
)
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileIcon className="size-10 text-muted-foreground" />
<p className="max-w-md truncate text-sm font-medium text-foreground" title={path}>
{basename(path)}
</p>
<p className="text-xs">
{extensionLabel(path)} · {formatSize(state.sizeBytes)}
</p>
<p className="max-w-md text-xs">No in-app preview for this file type.</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
void window.ipc.invoke('shell:openPath', { path })
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
{state.canShowAsText && (
<button
type="button"
onClick={() => void loadAsText()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<FileTextIcon className="size-3.5" />
Show as plain text
</button>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { ExternalLinkIcon, FileVideoIcon } from 'lucide-react'
interface VideoFileViewerProps {
path: string
}
type State = 'loading' | 'ready' | 'error'
export function VideoFileViewer({ path }: VideoFileViewerProps) {
const [state, setState] = useState<State>('loading')
useEffect(() => {
setState('loading')
}, [path])
const src = `app://workspace/${path.split('/').map(encodeURIComponent).join('/')}`
if (state === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
<FileVideoIcon className="size-6" />
<p className="text-sm font-medium text-foreground">Cannot play this video</p>
<p className="max-w-md text-xs">
The codec or container format isn&apos;t supported by Chromium (e.g. WMV, AVI, or some MKV files).
</p>
<button
type="button"
onClick={() => {
void window.ipc.invoke('shell:openPath', { path })
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent"
>
<ExternalLinkIcon className="size-3.5" />
Open in system
</button>
</div>
)
}
return (
<div className="flex h-full w-full items-center justify-center bg-black">
<video
key={path}
src={src}
controls
className="max-h-full max-w-full"
onLoadedMetadata={() => setState('ready')}
onError={() => setState('error')}
/>
</div>
)
}

View file

@ -1,23 +1,23 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track.js';
import { LiveNoteAgentEvent } from '@x/shared/dist/live-note.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
export type LiveNoteAgentStatus = 'idle' | 'running' | 'done' | 'error';
export interface TrackState {
status: TrackRunStatus;
export interface LiveNoteAgentState {
status: LiveNoteAgentStatus;
runId?: string;
summary?: string | null;
error?: string | null;
}
// Module-level store — shared across all hook consumers, subscribed once
// We replace the Map on every mutation so useSyncExternalStore detects the change
let store = new Map<string, TrackState>();
// Module-level store — shared across all hook consumers, subscribed once.
// We replace the Map on every mutation so useSyncExternalStore detects the change.
let store = new Map<string, LiveNoteAgentState>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
function updateStore(fn: (prev: Map<string, LiveNoteAgentState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
@ -26,12 +26,12 @@ function updateStore(fn: (prev: Map<string, TrackState>) => void) {
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
const key = `${event.trackId}:${event.filePath}`;
window.ipc.on('live-note-agent:events', ((event: z.infer<typeof LiveNoteAgentEvent>) => {
const key = event.filePath;
if (event.type === 'track_run_start') {
if (event.type === 'live_note_agent_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'track_run_complete') {
} else if (event.type === 'live_note_agent_complete') {
updateStore(s => s.set(key, {
status: event.error ? 'error' : 'done',
runId: event.runId,
@ -43,7 +43,7 @@ function ensureSubscription() {
updateStore(s => s.delete(key));
}, 5000);
}
}) as (event: z.infer<typeof TrackEvent>) => void);
}) as (event: z.infer<typeof LiveNoteAgentEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
@ -52,21 +52,21 @@ function subscribe(onStoreChange: () => void): () => void {
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, TrackState> {
function getSnapshot(): Map<string, LiveNoteAgentState> {
return store;
}
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
* Returns a Map of all live-note agent run states, keyed by `filePath`.
*
* Usage in a track-aware component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
* Usage in a panel:
* const status = useLiveNoteAgentStatus();
* const state = status.get(filePath) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const trackStatus = useTrackStatus();
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
* const status = useLiveNoteAgentStatus();
* const anyRunning = [...status.values()].some(s => s.status === 'running');
*/
export function useTrackStatus(): Map<string, TrackState> {
export function useLiveNoteAgentStatus(): Map<string, LiveNoteAgentState> {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from 'react'
import type { LiveNote } from '@x/shared/dist/live-note.js'
import { useLiveNoteAgentStatus, type LiveNoteAgentState } from './use-live-note-agent-status'
export interface UseLiveNoteForPathResult {
/** Parsed `live:` block, or null when the note is passive. */
live: LiveNote | null
/** Knowledge-relative path (no leading "knowledge/"). Empty when no path is provided. */
knowledgeRelPath: string
/** Most recent run state from the agent bus. */
agentState: LiveNoteAgentState | null
/** Whether the agent is currently running. Convenience read off agentState. */
isRunning: boolean
/** Loading flag for the initial fetch. */
loading: boolean
/** Force a refetch — useful after a mutation. */
refresh: () => Promise<void>
/** Tick value that increments once a minute so callers can keep relative-time labels fresh. */
tick: number
}
function stripKnowledgePrefix(p: string | null | undefined): string {
if (!p) return ''
return p.replace(/^knowledge\//, '')
}
function isSamePath(a: string, b: string | undefined): boolean {
if (!b) return false
return a === b.replace(/^knowledge\//, '')
}
/**
* Reactive view of a single note's `live:` block.
*
* - Fetches `live-note:get` on mount and whenever the path changes.
* - Subscribes to `live-note-agent:events` (via `useLiveNoteAgentStatus`) to
* surface the running flag in real time.
* - Listens to `workspace:didChange` so external edits to the file trigger a
* refetch.
* - Refetches one extra time when an agent run completes so callers see fresh
* `lastRunAt` / `lastRunSummary` / `lastRunError` values.
* - Ticks every minute so callers using `formatRelativeTime` get a fresh label
* without the underlying data changing.
*
* `notePath` may be either knowledge-relative (`Today.md`) or workspace-rooted
* (`knowledge/Today.md`); the hook normalises internally.
*/
export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult {
const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null)
const [live, setLive] = useState<LiveNote | null>(null)
const [loading, setLoading] = useState(false)
const [tick, setTick] = useState(0)
const agentStatusMap = useLiveNoteAgentStatus()
const agentState = knowledgeRelPath ? agentStatusMap.get(knowledgeRelPath) ?? null : null
const isRunning = agentState?.status === 'running'
const refresh = useCallback(async () => {
if (!knowledgeRelPath) { setLive(null); return }
setLoading(true)
try {
const res = await window.ipc.invoke('live-note:get', { filePath: knowledgeRelPath })
if (res.success) {
setLive(res.live ?? null)
}
} catch {
// Swallow — passive notes / missing files are fine; the next refresh retries.
} finally {
setLoading(false)
}
}, [knowledgeRelPath])
// Initial fetch + on path change.
useEffect(() => {
void refresh()
}, [refresh])
// Refetch when the agent run completes (status flips to done/error) so
// lastRunAt / lastRunError values picked up off disk are fresh.
const agentStatus = agentState?.status
useEffect(() => {
if (agentStatus === 'done' || agentStatus === 'error') {
void refresh()
}
}, [agentStatus, refresh])
// Refetch on external file changes — covers the case where the runner
// patched lastRunSummary on the same file we're viewing.
useEffect(() => {
if (!knowledgeRelPath) return
const fullPath = `knowledge/${knowledgeRelPath}`
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (event.path === fullPath) void refresh()
break
case 'moved':
if (event.from === fullPath || event.to === fullPath) void refresh()
break
case 'bulkChanged':
if (event.paths?.some(p => isSamePath(knowledgeRelPath, p))) void refresh()
break
}
})
return cleanup
}, [knowledgeRelPath, refresh])
// Minute-by-minute tick to keep relative-time labels fresh.
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 60_000)
return () => clearInterval(id)
}, [])
return {
live,
knowledgeRelPath,
agentState,
isRunning,
loading,
refresh,
tick,
}
}

View file

@ -0,0 +1,56 @@
/**
* Single source of truth for which file types the knowledge viewer renders.
*
* Both the App.tsx loader-skip check and the render-switch consume this so
* adding a new extension is a one-place edit. The persistent-viewer-cache
* also uses it to decide what to keep mounted.
*/
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf'
const VIEWER_BY_EXT: Record<string, ViewerType> = {
html: 'html',
htm: 'html',
png: 'image',
jpg: 'image',
jpeg: 'image',
webp: 'image',
gif: 'image',
svg: 'image',
avif: 'image',
bmp: 'image',
ico: 'image',
mp4: 'video',
mov: 'video',
webm: 'video',
m4v: 'video',
mp3: 'audio',
wav: 'audio',
m4a: 'audio',
ogg: 'audio',
flac: 'audio',
aac: 'audio',
pdf: 'pdf',
}
function extensionOf(path: string): string {
const lower = path.toLowerCase()
const dot = lower.lastIndexOf('.')
return dot >= 0 ? lower.slice(dot + 1) : ''
}
/** Returns the viewer type for a path, or null if no media viewer handles it. */
export function getViewerType(path: string): ViewerType | null {
return VIEWER_BY_EXT[extensionOf(path)] ?? null
}
/** True if the path is rendered by one of the dedicated media viewers. */
export function isMediaPath(path: string): boolean {
return getViewerType(path) !== null
}
/** True if the viewer for this path participates in the persistent mount cache. */
export function isCacheableViewerPath(path: string): boolean {
const t = getViewerType(path)
return t === 'html' || t === 'pdf'
}

View file

@ -139,12 +139,12 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
* re-emitted by buildFrontmatter (callers must splice them back from the
* original raw if they want to preserve them on save see the helpers below).
*/
const STRUCTURED_KEYS = new Set(['track'])
const STRUCTURED_KEYS = new Set(['live'])
/**
* Extract editable top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list-of-string
* values are string[]. Structured keys (e.g. `track:`) and any nested-object
* values are string[]. Structured keys (e.g. `live:`) and any nested-object
* shapes are filtered out they are not editable via this surface.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
@ -189,7 +189,7 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
if (itemMatch) {
const item = itemMatch[1].trim()
// If the list-item line itself contains a `key: value` pair, this is a
// nested-object shape (e.g. `- id: chicago-time` under `track:`). We
// nested-object shape (e.g. `- startTime: "09:00"` under a windows list). We
// can't represent that as a flat string array — drop the whole key.
if (/^\w[\w\s]*\w?:\s*\S/.test(item)) {
delete result[currentKey]
@ -212,7 +212,7 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
/**
* Convert a Record of editable frontmatter fields back to a raw YAML
* frontmatter string. If `preserveRaw` is provided, structured keys (e.g.
* `track:`) are spliced back from the original raw byte-for-byte, so
* `live:`) are spliced back from the original raw byte-for-byte, so
* round-trips through the FrontmatterProperties UI never lose them.
*/
export function buildFrontmatter(
@ -235,7 +235,7 @@ export function buildFrontmatter(
}
}
// Splice preserved structured-key blocks (e.g. track:) back from preserveRaw.
// Splice preserved structured-key blocks (e.g. live:) back from preserveRaw.
const preservedBlocks: string[] = []
if (preserveRaw) {
for (const key of STRUCTURED_KEYS) {

View file

@ -0,0 +1,25 @@
/**
* Compact relative-time formatter "just now", "5 m", "3 h", "2 d", "4 w",
* "5 m" (months). Used by the chat sidebar's run list and the live-note pill.
*
* Returns an empty string for invalid timestamps so callers can fall back to
* a default label.
*/
export function formatRelativeTime(ts: string): string {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return ""
const now = Date.now()
const diffMs = Math.max(0, now - date.getTime())
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
const diffMonths = Math.floor(diffDays / 30)
if (diffMinutes < 1) return "just now"
if (diffMinutes < 60) return `${diffMinutes} m`
if (diffHours < 24) return `${diffHours} h`
if (diffDays < 7) return `${diffDays} d`
if (diffWeeks < 4) return `${diffWeeks} w`
return `${Math.max(1, diffMonths)} m`
}

View file

@ -11,7 +11,7 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { buildCopilotAgent } from "../application/assistant/agent.js";
import { buildTrackRunAgent } from "../knowledge/track/run-agent.js";
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
@ -401,8 +401,8 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return buildCopilotAgent();
}
if (id === "track-run") {
return buildTrackRunAgent();
if (id === "live-note-agent") {
return buildLiveNoteAgent();
}
if (id === 'note_creation') {

View file

@ -1,6 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export type UseCase = 'copilot_chat' | 'track_block' | 'meeting_note' | 'knowledge_sync';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'meeting_note' | 'knowledge_sync';
export interface UseCaseContext {
useCase: UseCase;

View file

@ -78,19 +78,19 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
**Tracks (Auto-Updating Notes):** A note's body can be partially or fully agent-maintained — *living* notes that refresh on a schedule or react to incoming emails / calendar events. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the word "track" load the \`tracks\` skill the moment you spot one.
**Live Notes (Self-Updating Knowledge):** A note's body can be agent-maintained — a *live* note refreshes on a schedule and/or reacts to incoming emails / calendar events to satisfy a single persistent **objective**. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the words "live" or "track" load the \`live-note\` skill the moment you spot one.
*Strong signals (load the skill, act without asking):* "every morning / daily / hourly…", "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "pin live updates of…", "track / follow X", "whenever a relevant email comes in…".
*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard").
*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), **"what's the latest [news/update/situation] on X" / "what's happening with X" / "any updates on X" / "catch me up on X"** about a person, company, project, or topic, note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic for the catch-all case:** if you reach for \`web-search\` or a news tool to answer a topic-following question, the answer is exactly the kind of thing a live note would refresh on a schedule — load the skill and offer at the end.
A track is a directive in a note's frontmatter (\`track:\` array entry) with one or more triggers (cron / window / once / event). Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor). When you set one up, tell them where to find it.
A live note is a single \`live:\` block in a note's frontmatter — one objective, plus an optional \`triggers\` object (\`cronExpr\` / \`windows\` / \`eventMatchCriteria\`, each independently optional). Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor). **If the note is already live**, extend its existing \`objective\` in natural language to absorb the new ask — never create a second objective. When you make a passive note live (or extend an objective), tell the user where to manage it.
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
**Notifications:** When you need to send a desktop notification completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.

View file

@ -1,8 +1,15 @@
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../../lib/knowledge-note-style.js';
export const skill = String.raw`
# Document Collaboration Skill
You are an expert document assistant helping the user create, edit, and refine documents in their knowledge base.
` + KNOWLEDGE_NOTE_STYLE_GUIDE + String.raw`
> The writing style above is non-negotiable for any content you author or edit in the knowledge base even small one-off edits. The user's whole knowledge base is built on it. The rest of this skill covers the *workflow* of collaboration; the style guide above covers the *output*.
## FIRST: Ask About Edit Mode
**Before doing anything else, ask the user:**
@ -237,10 +244,7 @@ Renders a styled table from structured data.
## Best Practices
**Writing style:**
- Match the user's tone and style in the document
- Be concise but complete
- Use markdown formatting (headers, bullets, bold, etc.)
**Writing style:** see "Knowledge-note writing style" at the top of this skill that's the canonical guide. Match the user's tone for prose-shaped content (their own narrative writing); for everything else apply the terse-and-scannable rules.
**Editing:**
- Make surgical edits - change only what's needed

View file

@ -13,13 +13,13 @@ import appNavigationSkill from "./app-navigation/skill.js";
import browserControlSkill from "./browser-control/skill.js";
import codeWithAgentsSkill from "./code-with-agents/skill.js";
import composioIntegrationSkill from "./composio-integration/skill.js";
import tracksSkill from "./tracks/skill.js";
import liveNoteSkill from "./live-note/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
// console.log(tracksSkill);
// console.log(liveNoteSkill);
type SkillDefinition = {
id: string; // Also used as folder name
@ -102,10 +102,10 @@ const definitions: SkillDefinition[] = [
content: codeWithAgentsSkill,
},
{
id: "tracks",
title: "Tracks",
summary: "Create and manage tracks — frontmatter directives that keep a note's body auto-updated on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).",
content: tracksSkill,
id: "live-note",
title: "Live Notes",
summary: "Make notes self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).",
content: liveNoteSkill,
},
{
id: "browser-control",

View file

@ -0,0 +1,639 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
const schemaYaml = stringifyYaml(z.toJSONSchema(LiveNoteSchema)).trimEnd();
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
The live-note agent can emit *rich blocks* special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, mention it in the objective so the agent doesn't fall back to plain markdown:
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render the leaderboard as a \`table\` block with columns Rank, Title, Points, Comments."*
- \`chart\` — time series, breakdowns, share-of-total. *"Plot the rate as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render the dependency map as a \`mermaid\` diagram."*
- \`calendar\` — upcoming events / agenda. *"Show the agenda as a \`calendar\` block."*
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
- \`image\` — single image with caption. *"Render the cover photo as an \`image\` block."*
- \`embed\` — YouTube or Figma. *"Render the demo as an \`embed\` block."*
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Embed the status page as an \`iframe\` block pointing to <url>."*
- \`transcript\` — long meeting transcripts (collapsible). *"Render the transcript as a \`transcript\` block."*
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
You **do not** need to write the block body yourself describe the desired output inside the objective and the live-note agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
- Bad: "Show today's calendar." (vague agent may produce a markdown bullet list when the user wants the rich block)`;
export const skill = String.raw`
# Live Notes Skill
A *live note* is a regular markdown note whose body is kept current by a background agent. The user expresses intent via a single \`live:\` block in the note's YAML frontmatter — one persistent **objective** plus an optional \`triggers\` object that says when the agent should fire (cron, time-of-day windows, and/or matching events). A note with no \`live:\` key is just static; adding one makes it live. Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor).
When this skill is loaded, your job is: make a passive note live (or extend the objective on an already-live note), run the agent once so the user immediately sees content, and tell them where to manage it.
## Mode: act-first (non-negotiable on strong signals)
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
What you must NOT do on a strong-signal ask:
- Don't ask "Should I make edits directly, or show changes first for approval?" that prompt belongs to generic doc editing, not live notes.
- Don't ask "where should this live?" pick a default folder (see below) and proceed.
- Don't say "I'll create knowledge/Notes/X.md" without the action attached. Either say "Done created" or just do it.
- Don't open with an explanation of what a live note is. The user already asked for one.
- **Don't ask "should I do this?" when the request is unambiguous, just do it.** A clarifying question is reserved for *genuine* ambiguity (see "When to ask one short question" below), not as a politeness gate.
If a previous skill or earlier turn was waiting on edit-mode permission, treat the live-note request as implicit "direct mode" and proceed.
The two **panel-driven** flows in "Exceptions" at the bottom of this skill are the only places where a first-turn explanation is wanted. Don't bleed that posture into normal asks.
## Reading the user's intent
You're loaded any time the user might be asking for something dynamic. Three postures, depending on signal strength:
### Strong signals act, then confirm (default behaviour)
The user used unambiguous language asking for something to be tracked. **Just do it** pick a default folder, look for an existing matching note, then either extend its objective or create a new live note. Run it once. Confirm in one line. No "should I?" gate.
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
- **Direct**: "set up a [feed / tracker / dashboard / live note] for X", "track X" / "make this live"
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
### Default folder picker (when no note is named)
When a strong signal lands without a specific note attached, pick the folder by topic shape. Don't ask the user pick.
| Topic shape | Default folder |
|---|---|
| News, headlines, market prices, weather, status pages, reference dashboards | \`knowledge/Notes/\` |
| Tasks, monitors, daily briefings, recurring digests of the user's own data, "background agent"-style work | \`knowledge/Tasks/\` |
| A specific person (e.g. "track everything about Sarah Chen") | \`knowledge/People/\` |
| A specific company / org | \`knowledge/Organizations/\` |
| A specific project or workstream | \`knowledge/Projects/\` |
| A topic / theme | \`knowledge/Topics/\` |
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
### Default cadence picker (when the user didn't specify timing)
When the user names a topic but doesn't say *how often*, **pick a cadence** — don't ask. Use judgment based on the topic shape. The user can tweak it later in the panel.
| Topic shape | Default cadence |
|---|---|
| News / market summary / topic-following / weather / status | One morning **window** \`06:00\`\`12:00\`. Add an \`eventMatchCriteria\` when the topic could also surface in synced Gmail/Calendar. |
| Stock / crypto prices when the user says "real-time" or "throughout the day" | \`cronExpr\` hourly or every 15 min, depending on phrasing. |
| Daily briefings / dashboards | Two or three **windows** spanning the workday (morning, midday, post-lunch). |
| Email / calendar-driven topics (Q3 emails, customer reschedules) | \`eventMatchCriteria\` only — schedule is "when a relevant signal arrives". Add a single morning window if a fallback baseline refresh feels right. |
**When in doubt, default to a single morning window \`06:00\`\`12:00\`.** It's forgiving (fires whenever the user opens the app in the morning) and matches the casual "I'll check this in the morning" expectation.
Reach for a precise \`cronExpr\` only when the user explicitly demands a clock time ("at 9am sharp", "every 15 minutes"). Casual asks ("every morning", "daily") get windows.
### When to ask one short question
Only when the request is **genuinely** ambiguous not as a politeness gate. Examples:
- The user named a specific note that doesn't exist AND your search for similar names returned multiple plausible candidates ask "Did you mean A or B?"
- The new ask in an already-live note conflicts with the existing objective (replace, not extend) ask "Replace the existing objective, or add this on top?"
- The topic is too vague to derive a sensible filename or folder ("track stuff for me") ask one focusing question.
Pick a single question, get to the action on the next turn. Never stack questions.
### Medium signals answer the one-off, then offer
Answer the user's actual question first. Then add a single-line offer to keep it updated. **The offer is not optional on a medium signal — if you don't add it, you're failing the skill.** If the user says yes, make the note live. If they don't engage, leave it don't push twice.
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
- **News / updates on a topic**: "what's the latest news on Coinbase?", "what's happening with the Q3 launch?", "any updates on Project Apollo?", "what's new with [person/company]?"
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" especially when in a note context
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
**Catch-all heuristic:** if you reached for \`web-search\` or a news tool to answer a question about a person, company, project, or topic, the answer is exactly the kind of thing a live note would refresh on a schedule — **always offer** at the end. Same goes for any time-decaying lookup (prices, weather, status).
Offer line shape (one line, concrete):
> "Want me to keep this in a live note that refreshes every morning?"
Or, when there's a sensible default file already implied (e.g. a topic name):
> "I can drop this in \`knowledge/Notes/Coinbase News.md\` and refresh it every morning — want that?"
The offer goes at the **very end** of your response, on its own line, after the answer is fully delivered.
### Anti-signals do NOT make a note live
- Definitional questions ("what is X?")
- One-off lookups ("look up X for me")
- Manual document work ("help me write…", "edit this paragraph…")
- General how-to ("how do I do Y?")
## Already-live notes extend, don't fork
**This is the most important rule of the skill.** When the user asks you to track something *new* in a note that **already has a \`live:\` block**, edit the existing \`objective\` in natural language to absorb the new ask. Do **not** create a second \`live:\` block. Do **not** introduce some other key. There is exactly one objective per note.
- The user says "also keep an eye on Hacker News stories about this" read the current \`objective\`, append/integrate the new ask in natural-language prose, write it back.
- The objective ends up longer over time. That's fine. The agent treats it as one coherent intent.
- If the new ask conflicts with the old (e.g. user wants to *replace* what the note tracks), ask one short question to confirm before overwriting.
## What to say to the user
The user knows the feature as **live notes** and finds them in the **Live notes view**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "objective" in user-facing prose unless the user uses them first.
**Use past tense.** All of these messages are sent *after* the action no future-tense "I'll do this" or "I'm going to set this up". The action already happened.
After making a passive note live (or creating a new live note from scratch):
> Done created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view (Radio icon in the sidebar).
After extending the objective on an already-live note:
> Updated the objective to also cover that. Re-running now so the new output shows up.
When skipping a re-run (because the user said not to or "later"):
> Updated. I'll let it run on its next trigger.
**Anti-patterns** don't write any of these:
- "I'll set up a live note for you. Should I create knowledge/Notes/News Feed.md?" (future tense, asking permission)
- "I need one thing to proceed: which note should this live in?" (asking when default-folder picker tells you the answer)
- "That's a live note use case! Here's what I can set up: ..." (preamble + lecture instead of action)
- "Here's a comprehensive setup..." or "I've prepared the following..." (decorative framing)
## Worked example strong signal, no note named
**User:** "i want to set up a news feed to track news for India and the world."
**Right behaviour** (one turn):
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
3. No match found create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
**Wrong behaviour:** running 2 lookup tools, then surfacing a paragraph saying "That's a live note use case, so the clean setup is a self-updating news note with: India headlines, world headlines, a refresh cadence like every morning. I need one thing to proceed: which note should this live in? If you don't already have one, I'll create knowledge/Notes/News Feed.md and make it live there." The user already gave you everything you need. Act.
## What is a live note (concretely)
**Concrete 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 * * * *"
---
# Chicago time
(empty the agent will fill this in on the first run)
` + "```" + `
After the first run, the body might become:
` + "```" + `markdown
# Chicago time
2:30 PM, Central Time
` + "```" + `
Good use cases:
- Weather / air quality for a location
- News digests or headlines
- Stock or crypto prices
- Sports scores
- Service status pages
- Personal dashboards (today's calendar, steps, focus stats)
- Living summaries fed by incoming events (emails, meeting notes)
- Any recurring content that decays fast
## Anatomy
A live note lives entirely in the note's frontmatter there is no inline marker in the body. The agent owns the entire body below the H1 and writes whatever content the objective demands.
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
` + "```" + `markdown
---
live:
objective: |
<what this note should keep being>
active: true
triggers:
cronExpr: "0 * * * *"
---
# Note body
` + "```" + `
A note has **at most one** \`live:\` block. Each block has exactly one \`objective\`. The objective can be long and cover several sub-topics — the agent reads it holistically. Omit \`triggers\` (or all three trigger fields) for a manual-only live note.
## Canonical Schema
Below is the authoritative schema for a \`live:\` block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
` + "```" + `yaml
${schemaYaml}
` + "```" + `
**Runtime-managed fields never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for live-note runs; setting per-note values bypasses that and is almost always wrong.
The only time these belong on a note:
- The user **explicitly** named a model or provider for *this specific note* in their request ("use Claude Opus for this one", "force this onto OpenAI"). Quote the user's wording back when confirming.
Things that are **not** reasons to set these:
- "It should be fast" / "I want a small model" that's a global preference, not a per-note one. Leave it; the global default exists.
- "This note is complex" write a clearer objective; don't reach for a different model.
- "Just to be safe" / "in case it matters" antipattern. Leave them out.
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
## Writing a Good Objective
### The Frame: This Is a Personal Knowledge Tracker
Live-note output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
### Core Rules
- **Specific and actionable.** State exactly what to keep up to date, what to source from, and what shape the output should take.
- **Multi-faceted is OK.** Unlike the old per-track model, a single objective can cover several related sub-topics list them inside the objective text and let the agent organize the body. Don't fork a second objective.
- **Imperative voice.** "Keep this note updated with…", "Show…", "Maintain a section titled…".
- **Specify output shape when shape matters.** "One line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items", or pick a rich block (see "Rich block render" below).
### Self-Sufficiency (critical)
The objective runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
**Never use phrases that depend on prior conversation or prior runs:**
- "as before", "same style as before", "like last time"
- "keep the format we discussed", "matching the previous output"
- "continue from where you left off" (without stating the state)
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"). The live-note agent only sees the objective not this chat, not what it produced last time.
### Output Patterns Match the Data
Pick a shape that fits what the note is tracking. Five common patterns the first four are plain markdown; the fifth is a rich rendered block:
**1. Single metric / status line.**
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
- Bad: "Give me a nice update about the dollar rate."
**2. Compact table.**
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
- Bad: "Show a polished, table-first world clock with a pleasant layout."
**3. Rolling digest.**
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
- Bad: "Give me the top HN stories with thoughtful takeaways."
**4. Status / threshold watch.**
- Good: "Check https://status.example.com. Return one line: ` + "`" + ` All systems operational` + "`" + ` or ` + "`" + ` <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
- Bad: "Keep an eye on the status page and tell me how it looks."
${richBlockMenu}
### Per-trigger guidance (advanced)
**Default behaviour:** one objective serves all triggers cron, window, event, and manual runs all see the same intent. **Don't reach for per-trigger branching unless the run actually needs to behave differently.**
The agent always receives a \`**Trigger:**\` line in its run message telling it which trigger fired:
- \`Manual run (user-triggered)\` — Run button or Copilot tool.
- \`Scheduled refresh — the cron expression \\\`<expr>\\\` matched\` — exact-time refresh.
- \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh.
- \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive.
**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window pull a full snapshot from local data) and a *reactive* update (event integrate one new signal). The flagship case is the **Today.md emails section**: on a window run it scans \`gmail_sync/\` for everything worth attention; on an event run with an incoming email payload it integrates that one thread into the existing digest without re-listing previously-seen threads. Same objective, two branches.
How to write it use plain conditional language inside the objective:
\`\`\`yaml
live:
objective: |
Maintain a digest of email threads worth attention today, as a single \`emails\` block.
Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the
full digest from scratch.
With an event payload (event run): integrate the new thread into the existing digest
add it if new, update its entry if the threadId is already shown and don't re-list
threads the user has already seen unless their state changed.
\`\`\`
Notice: the objective doesn't mention "cron" or "window" by name, just describes the conditions. The agent reads its \`**Trigger:**\` line and matches the right branch.
**Don't branch for stylistic reasons** ("on cron be terse, on event be verbose"). Branching is for *what data to look at* and *whether to do an incremental vs full update*, not for tone.
### Anti-Patterns
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" they tell the agent nothing concrete.
- **References to past state** without a mechanism to access it ("as before", "same as last time").
- **A second \`live:\` block** when one already exists — extend the existing objective instead.
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
## YAML String Style (critical read before writing the ` + "`" + `objective` + "`" + ` or ` + "`" + `triggers.eventMatchCriteria` + "`" + `)
The two free-form fields \`objective\` and \`triggers.eventMatchCriteria\` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes \`lastRunAt\`, \`lastRunSummary\`, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the field gets truncated.
### The rule: always use a safe scalar style
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `objective` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.**
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
` + "```" + `yaml
live:
objective: |
Show current local time for India, Chicago, and Indianapolis as a
3-column markdown table: Location | Local Time | Offset vs India.
One row per location, 24-hour time (HH:MM), no extra prose.
active: true
triggers:
cronExpr: "0 * * * *"
eventMatchCriteria: |
Emails from the finance team about Q3 budget or OKRs.
` + "```" + `
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs all literal. No escaping needed.
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
- Leave a real newline after ` + "`" + `|` + "`" + ` content starts on the next line.
### Acceptable alternative: double-quoted on a single line
Fine for short single-sentence fields:
` + "```" + `yaml
live:
objective: "Show the current time in Chicago, IL in 12-hour format."
active: true
` + "```" + `
### Do NOT use plain (unquoted) scalars for these two fields
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
### Never-hand-write fields
\`lastRunAt\`, \`lastRunId\`, \`lastRunSummary\` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
## Triggers
The \`triggers\` object has three optional sub-fields. Mix freely; presence of a field is the marker that the note should fire on that channel.
- \`cronExpr\` — fires at an exact recurring time (5-field cron string).
- \`windows\` — list of \`{ startTime, endTime }\` bands; the agent fires once per day per window, anywhere inside the band.
- \`eventMatchCriteria\` — natural-language description of which incoming events (emails, calendar changes) should wake the note.
Omit ` + "`" + `triggers` + "`" + ` entirely (or omit all three sub-fields) for a **manual-only** live note the user runs it from the Run button in the panel.
### \`cronExpr\`
` + "```" + `yaml
triggers:
cronExpr: "0 * * * *"
` + "```" + `
Always quote the cron expression it contains spaces and ` + "`" + `*` + "`" + `.
### \`windows\`
` + "```" + `yaml
triggers:
windows:
- { startTime: "09:00", endTime: "12:00" }
- { startTime: "13:00", endTime: "15:00" }
` + "```" + `
Each window fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at \`startTime\` — once a fire lands at-or-after today's start, that window is done for the day. Use windows when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
### \`eventMatchCriteria\`
` + "```" + `yaml
triggers:
eventMatchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
How event triggering works:
1. When a new event arrives, a fast LLM classifier checks each live note's \`eventMatchCriteria\` (and its objective) against the event content.
2. If it might match, the live-note agent receives both the event payload and the existing note body, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update no fabricated content.
### Combining trigger fields
Mix freely. Example a note that refreshes weekday mornings AND on incoming Q3 emails:
` + "```" + `yaml
live:
objective: |
Maintain a running summary of decisions and open questions about Q3 planning.
active: true
triggers:
cronExpr: "0 9 * * 1-5"
eventMatchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
### Cron cookbook
- ` + "`" + `"*/15 * * * *"` + "`" + ` every 15 minutes
- ` + "`" + `"0 * * * *"` + "`" + ` every hour on the hour
- ` + "`" + `"0 8 * * *"` + "`" + ` daily at 8am
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` weekdays at 9am
- ` + "`" + `"0 0 * * 0"` + "`" + ` Sundays at midnight
- ` + "`" + `"0 0 1 * *"` + "`" + ` first of month at midnight
## Insertion Workflow
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
### Making a passive note live (no \`live:\` block yet)
1. \`workspace-readFile({ path })\` — re-read fresh.
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
3. \`workspace-edit\`:
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
### Extending an already-live note
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`.
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
### Sidebar chat with a specific note
1. If a file is mentioned/attached, read it.
2. If ambiguous, ask one question: "Which note should this be in?"
3. Apply the workflow above (extend if already live, create if passive).
### No note context at all
If the user used a strong signal but didn't name a specific note: **don't ask** "which note?" use the Default folder picker (above) and proceed. Create the file with a sensible filename derived from the topic.
If the user used a medium signal with no note: answer the one-off, then offer to make it live somewhere (and pick the folder when they say yes).
## Exceptions first-turn confirmation only when
The two flows below are the **only** exceptions to the act-first default. They have explicit panel/card context that wants a brief explanation before the user commits. Don't bleed this posture into normal asks outside these flows, strong signals get acted on, not explained.
### Exception 1: Suggested Topics exploration flow
Sometimes the user arrives from the Suggested Topics panel with a prompt like:
- "I am exploring a suggested topic card from the Suggested Topics panel."
- a title, category, description, and target folder such as \`knowledge/Topics/\` or \`knowledge/People/\`
This is a *browse* gesture, not a commit gesture the user might back out. So:
1. On the first turn, **do not create or modify anything yet**. Briefly explain the live note you can set up and ask for confirmation.
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
3. Before creating a new note, search the target folder for an existing matching note and update it (extend objective if already live; make it live otherwise).
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
6. Keep the surrounding note scaffolding minimal but useful. The \`live:\` block should be the core of the note.
### Exception 2: New-live-note panel flow (panel-driven, no note named)
The user clicks the "New live note" button in the **Live notes** view and the opening message is the canned "I want to set up a Live note / task." (no specific topic, no note named). This is the only case where you ask before acting but the ask is minimal.
On the first turn, reply with **just** a one-line prompt and 2-3 concrete examples. **Do not** explain what a live note is. **Do not** ask about cadence, folder, or format you'll pick those yourself once they name a topic. Examples to draw from (pick 2-3 that span different shapes):
- A daily news feed for a topic ("AI coding agents", "India + world news")
- A market summary ("BTC, ETH, SPY each morning")
- A weekly Q3-emails digest from your inbox
- A morning weather + commute-conditions briefing
- A live dashboard for an ongoing project
Shape your reply roughly like:
> What would you like to track? A few examples to spark ideas:
> - A daily news feed for a topic
> - A market summary
> - A digest of relevant emails
Once the user names a topic, **drop into the strong-signal flow**: use the Default folder picker for location, the Default cadence picker for timing, search for an existing match, extend or create, run once, confirm in one line. Don't bounce back with "great — and how often should it refresh?" pick.
**The trigger for Exception 2 is specifically the generic "I want to set up a Live note / task." opening.** A user asking "set up a news feed for India and the world" is *not* in this flow that's a strong signal, act on it.
## The Exact Frontmatter Shape
For a brand-new live note:
` + "```" + `markdown
---
live:
objective: |
<objective, indented 2 spaces, may span multiple lines>
active: true
triggers:
cronExpr: "0 * * * *"
---
# <Note title>
` + "```" + `
**Rules:**
- \`live:\` is at the top level of the frontmatter, never nested under other keys.
- There is **at most one** \`live:\` block per note.
- 2-space YAML indent throughout. No tabs.
- \`triggers:\` is an object, not an array. Each sub-field (\`cronExpr\`, \`windows\`, \`eventMatchCriteria\`) is independently optional. Omit \`triggers\` entirely for manual-only.
- **Always use the literal block scalar (\`|\`)** for \`objective\` and \`eventMatchCriteria\`.
- **Always quote cron expressions** in YAML they contain spaces and \`*\`.
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The live-note agent edits the body on its first run.
## After Creating or Editing a Live Note
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the \`run-live-note-agent\` tool — same as the user clicking Run in the panel.
Why default-on:
- For event-driven live notes (with \`eventMatchCriteria\`), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
- For notes that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill \`context\` (see below) seeds rich initial content.
- After an edit, the user expects to see the updated output without an extra round-trip.
Confirm in one line and tell the user where to find it:
> "Done — this note is live, refreshing hourly. Running it once now so you see content right away. You can manage it from the Live Note panel."
For an objective extension on an already-live note:
> "Updated the objective. Re-running now so you see the new output."
If you skipped the re-run (user said not to):
> "Updated — I'll let it run on its next trigger."
**Do not** write content into the note body yourself that's the live-note agent's job, delegated via \`run-live-note-agent\`.
## Using the \`run-live-note-agent\` tool
\`run-live-note-agent\` triggers a single run right now. You can pass an optional \`context\` string to bias *this run only* without modifying the objective — the difference between a stock refresh and a smart backfill.
### Backfill \`context\` examples
- A newly-live note watching Q3 emails run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
- A new note tracking this week's customer calls run with:
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
- Manual refresh after the user mentions a recent change:
> context: "Focus on changes from the last 7 days only."
- Plain refresh (user said "run it now"): **omit \`context\`**. Don't invent it.
### Reading the result
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
- \`action: 'replace'\` → body changed. Confirm in one line; optionally cite the first line of \`contentAfter\`.
- \`action: 'no_update'\` → agent decided nothing needed to change. Tell the user briefly; \`summary\` usually explains why.
- \`error: 'Already running'\` → another run is in flight; tell the user to retry shortly.
- Other \`error\` → surface concisely.
### Don'ts
- **Don't run more than once** per user-facing action one tool call per turn.
- **Don't pass \`context\`** for a plain refresh — it can mislead the agent.
- **Don't write content into the note body yourself** always delegate via \`run-live-note-agent\`.
## Don'ts
- **Don't create a second \`live:\` block** when one already exists — extend the existing \`objective\`.
- **Don't add \`triggers\`** if the user explicitly wants manual-only.
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor.
## Editing or Removing an Existing Live Note
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar).
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object.
**Pause without removing:** flip \`active: false\`.
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
## Quick Reference
Minimal template (frontmatter only):
` + "```" + `yaml
live:
objective: |
<objective always use \`|\`, indented 2 spaces>
active: true
triggers:
cronExpr: "0 * * * *"
` + "```" + `
Top cron expressions: \`"0 * * * *"\` (hourly), \`"0 8 * * *"\` (daily 8am), \`"0 9 * * 1-5"\` (weekdays 9am), \`"*/15 * * * *"\` (every 15m).
YAML style reminder: \`objective\` and \`eventMatchCriteria\` are **always** \`|\` block scalars. Never plain. Never leave a plain scalar in place when editing.
`;
export default skill;

View file

@ -1,535 +0,0 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { TrackSchema } from '@x/shared/dist/track.js';
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackSchema)).trimEnd();
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
The track agent can emit *rich blocks* special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
- \`image\` — single image with caption. *"Render as an \`image\` block."*
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
You **do not** need to write the block body yourself describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
- Bad: "Show today's calendar." (vague agent may produce a markdown bullet list when the user wants the rich block)`;
export const skill = String.raw`
# Tracks Skill
A track is a directive in a note's YAML frontmatter (under the ` + "`" + `track:` + "`" + ` array) that turns the note's body into a *living* document refreshed on a schedule or reactively when a matching email / calendar event arrives. A note with no ` + "`" + `track:` + "`" + ` key is just static; one or more entries under it make it live. Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor).
When this skill is loaded, your job is: set up (or update) a track, run it once so the user immediately sees content, and tell them where to manage it.
## Mode: act-first
Track creation and editing are action-first. Read the file, update the frontmatter via ` + "`" + `workspace-edit` + "`" + `, run the track once. Do not ask "Should I make edits directly, or show changes first for approval?" that prompt belongs to generic document editing, not to tracks.
- If another skill or earlier turn was waiting on edit-mode permission, treat the track request as implicit "direct mode" and proceed.
- You may ask **one** short clarifying question only when genuinely ambiguous (e.g. *which* note). Never ask about permission to edit.
- The Suggested Topics and Background Agent setup flows below are first-turn-confirmation exceptions leave those intact.
## Reading the user's intent
You're loaded any time the user might be asking for something dynamic. Two postures, depending on signal strength:
### Strong signals act, then confirm
Just build the track. Don't ask permission. Confirm in one line at the end.
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
- **Direct**: "track X" the user used the word; you can too in your reply
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
### Medium signals answer the one-off, then offer
Answer the user's actual question first. Then add a single-line offer to keep it updated. If they say yes, build the track. If they don't engage, leave it don't push twice.
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" especially when in a note context
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
Offer line shape (one line, concrete):
> "I can keep this updated here, refreshing every morning — want that?"
### Anti-signals do NOT track
- Definitional questions ("what is X?")
- One-off lookups ("look up X for me")
- Manual document work ("help me write…", "edit this paragraph…")
- General how-to ("how do I do Y?")
## What to say to the user
The user knows the feature as **tracks** and finds them in the **Track sidebar**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "instruction" in user-facing prose unless the user uses them first.
After creating a track, surface where it lives:
> "Done — I've set up a track here that refreshes every morning. Running it once now so you see content right away. You can manage it from the Track sidebar (Radio icon, top-right of the editor)."
After editing one:
> "Updated. Re-running now so you can see the new output."
When skipping a re-run (because the user said not to or "later"):
> "Updated — I'll let it run on its next trigger."
## What Is a Track (concretely)
**Concrete example** a note that shows the current Chicago time, refreshed hourly:
` + "```" + `markdown
---
track:
- id: chicago-time
instruction: |
Show the current time in Chicago, IL in 12-hour format.
active: true
triggers:
- type: cron
expression: "0 * * * *"
---
# Chicago time
(empty the agent will fill this in on the first run)
` + "```" + `
After the first run, the body might become:
` + "```" + `markdown
# Chicago time
2:30 PM, Central Time
` + "```" + `
Good use cases:
- Weather / air quality for a location
- News digests or headlines
- Stock or crypto prices
- Sports scores
- Service status pages
- Personal dashboards (today's calendar, steps, focus stats)
- Living summaries fed by incoming events (emails, meeting notes)
- Any recurring content that decays fast
## Anatomy
A track lives entirely in the note's frontmatter there is no inline marker in the body. The agent writes whatever content the instruction demands into the body itself, choosing where to place it based on the existing structure.
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
` + "```" + `markdown
---
track:
- id: <kebab-id>
instruction: |
<what the agent should produce>
active: true
triggers:
- type: cron
expression: "0 * * * *"
---
# Note body
` + "```" + `
A note may have multiple entries under ` + "`" + `track:` + "`" + ` they run independently. Each entry can have multiple triggers (e.g. an hourly cron AND an event trigger). Omit ` + "`" + `triggers` + "`" + ` for a manual-only track.
## Canonical Schema
Below is the authoritative schema for a single track entry (generated at build time from the TypeScript source never out of date). Use it to validate every field name, type, and constraint before writing YAML:
` + "```" + `yaml
${schemaYaml}
` + "```" + `
**Runtime-managed fields never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for tracks; setting per-track values bypasses that and is almost always wrong.
The only time these belong on a track:
- The user **explicitly** named a model or provider for *this specific track* in their request ("use Claude Opus for this one", "force this track onto OpenAI"). Quote the user's wording back when confirming.
Things that are **not** reasons to set these:
- "Tracks should be fast" / "I want a small model" that's a global preference, not a per-track one. Leave it; the global default exists.
- "This track is complex" write a clearer instruction; don't reach for a different model.
- "Just to be safe" / "in case it matters" this is the antipattern. Leave them out.
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
## Choosing an ` + "`" + `id` + "`" + `
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
- **Must be unique within the note's ` + "`" + `track:` + "`" + ` array.** Before inserting, read the file and check existing ` + "`" + `id:` + "`" + ` values.
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
- Don't reuse an old ID even if a previous entry was deleted pick a fresh one.
## Writing a Good Instruction
### The Frame: This Is a Personal Knowledge Tracker
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
### Core Rules
- **Specific and actionable.** State exactly what to fetch or compute.
- **Single-focus.** One track = one purpose. Split "weather + news + stocks" into three tracks, don't bundle.
- **Imperative voice, 1-3 sentences.**
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
### Self-Sufficiency (critical)
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
**Never use phrases that depend on prior conversation or prior runs:**
- "as before", "same style as before", "like last time"
- "keep the format we discussed", "matching the previous output"
- "continue from where you left off" (without stating the state)
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction not this chat, not what you produced last time.
### Output Patterns Match the Data
Pick a shape that fits what the user is tracking. Five common patterns the first four are plain markdown; the fifth is a rich rendered block:
**1. Single metric / status line.**
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
- Bad: "Give me a nice update about the dollar rate."
**2. Compact table.**
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
- Bad: "Show a polished, table-first world clock with a pleasant layout."
**3. Rolling digest.**
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
- Bad: "Give me the top HN stories with thoughtful takeaways."
**4. Status / threshold watch.**
- Good: "Check https://status.example.com. Return one line: ` + "`" + ` All systems operational` + "`" + ` or ` + "`" + ` <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
- Bad: "Keep an eye on the status page and tell me how it looks."
${richBlockMenu}
### Anti-Patterns
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" they tell the agent nothing concrete.
- **References to past state** without a mechanism to access it ("as before", "same as last time").
- **Bundling multiple purposes** into one instruction split into separate tracks.
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
## YAML String Style (critical read before writing any ` + "`" + `instruction` + "`" + ` or event-trigger ` + "`" + `matchCriteria` + "`" + `)
The two free-form fields ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
### The rule: always use a safe scalar style
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `, every time.**
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
` + "```" + `yaml
track:
- id: world-clock
instruction: |
Show current local time for India, Chicago, and Indianapolis as a
3-column markdown table: Location | Local Time | Offset vs India.
One row per location, 24-hour time (HH:MM), no extra prose.
active: true
triggers:
- type: cron
expression: "0 * * * *"
- type: event
matchCriteria: |
Emails from the finance team about Q3 budget or OKRs.
` + "```" + `
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs all literal. No escaping needed.
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
- Leave a real newline after ` + "`" + `|` + "`" + ` content starts on the next line.
### Acceptable alternative: double-quoted on a single line
Fine for short single-sentence fields:
` + "```" + `yaml
track:
- id: chicago-time
instruction: "Show the current time in Chicago, IL in 12-hour format."
active: true
` + "```" + `
### Do NOT use plain (unquoted) scalars for these two fields
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
### Never-hand-write fields
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
## Triggers
A track has zero or more **triggers** under a single ` + "`" + `triggers:` + "`" + ` array. Each trigger is one of four types:
- ` + "`" + `cron` + "`" + ` fires at an exact time, recurring
- ` + "`" + `window` + "`" + ` once per day, anywhere inside a time-of-day band
- ` + "`" + `once` + "`" + ` one-shot at a future time
- ` + "`" + `event` + "`" + ` fires when a matching event arrives (emails, calendar, etc.)
A track can carry **multiple triggers** of any mix. Omit ` + "`" + `triggers` + "`" + ` (or use an empty array) for a **manual-only** track the user triggers it via the Run button in the sidebar.
### ` + "`" + `cron` + "`" + ` trigger
` + "```" + `yaml
triggers:
- type: cron
expression: "0 * * * *"
` + "```" + `
### ` + "`" + `window` + "`" + ` trigger
` + "```" + `yaml
triggers:
- type: window
startTime: "09:00"
endTime: "12:00"
` + "```" + `
Fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at ` + "`" + `startTime` + "`" + ` — once a fire lands at-or-after today's start, the trigger is done for the day. Use this when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
### ` + "`" + `once` + "`" + ` trigger
` + "```" + `yaml
triggers:
- type: once
runAt: "2026-04-14T09:00:00"
` + "```" + `
Local time, no ` + "`" + `Z` + "`" + ` suffix.
### ` + "`" + `event` + "`" + ` trigger
` + "```" + `yaml
triggers:
- type: event
matchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
How event triggers work:
1. When a new event arrives, a fast LLM classifier checks each event trigger's ` + "`" + `matchCriteria` + "`" + ` against the event content.
2. If it might match, the track-run agent receives both the event payload and the existing note body, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update no fabricated content.
### Combining multiple triggers
A single track can have any combination e.g. an hourly cron AND an event trigger:
` + "```" + `yaml
track:
- id: q3-emails
instruction: |
Maintain a running summary of decisions and open questions about Q3 planning.
active: true
triggers:
- type: cron
expression: "0 9 * * 1-5"
- type: event
matchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
This track refreshes on schedule (weekdays at 9am) AND on every relevant incoming email.
### Cron cookbook
- ` + "`" + `"*/15 * * * *"` + "`" + ` every 15 minutes
- ` + "`" + `"0 * * * *"` + "`" + ` every hour on the hour
- ` + "`" + `"0 8 * * *"` + "`" + ` daily at 8am
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` weekdays at 9am
- ` + "`" + `"0 0 * * 0"` + "`" + ` Sundays at midnight
- ` + "`" + `"0 0 1 * *"` + "`" + ` first of month at midnight
## Insertion Workflow
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
### Adding a track to an existing note
1. ` + "`" + `workspace-readFile({ path })` + "`" + ` re-read fresh.
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any). Note the existing ` + "`" + `track:` + "`" + ` ids if present.
3. Construct the new track entry as YAML.
4. ` + "`" + `workspace-edit` + "`" + `:
- **If the note has frontmatter and a ` + "`" + `track:` + "`" + ` array already**: anchor on a unique line in/near the array and splice your new entry in.
- **If the note has frontmatter but no ` + "`" + `track:` + "`" + ` array**: anchor on the closing ` + "`" + `---` + "`" + ` of the frontmatter, and insert ` + "`" + `track:\n - id: ...` + "`" + ` etc. just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (` + "`" + `---\n` + "`" + ` ... ` + "`" + `\n---\n` + "`" + ` followed by the original first line).
### Sidebar chat with a specific note
1. If a file is mentioned/attached, read it.
2. If ambiguous, ask one question: "Which note should I add the track to?"
3. Update the note's frontmatter ` + "`" + `track:` + "`" + ` array using the workflow above.
### No note context at all
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
### Suggested Topics exploration flow
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
- "I am exploring a suggested topic card from the Suggested Topics panel."
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
In that flow:
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
6. Keep the surrounding note scaffolding minimal but useful. The track entry should be the core of the note.
### Background agent setup flow
Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet.
In this flow, treat "background agent" and "track" as the same feature. The user-facing term can stay "background agent", but the implementation is a track in a note's frontmatter. Do **not** claim these are different systems.
In that flow:
1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run.
2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder.
3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder.
4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists.
5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup.
6. Keep the surrounding note scaffolding minimal but useful.
## The Exact Frontmatter Shape
For a brand-new note:
` + "```" + `markdown
---
track:
- id: <kebab-id>
instruction: |
<instruction, indented 2 spaces, may span multiple lines>
active: true
triggers:
- type: cron
expression: "0 * * * *"
---
# <Note title>
` + "```" + `
**Rules:**
- ` + "`" + `track:` + "`" + ` is at the top level of the frontmatter, never nested.
- Each entry is a list item starting with ` + "`" + `- id:` + "`" + `. 2-space YAML indent. No tabs.
- ` + "`" + `triggers:` + "`" + ` is an array. Omit it for a manual-only track. Multiple entries are allowed (any mix of cron / window / once / event).
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `.
- **Always quote cron expressions** in YAML they contain spaces and ` + "`" + `*` + "`" + `.
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The track agent will edit the body on its first run.
## After Creating or Editing a Track
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the ` + "`" + `run-track` + "`" + ` tool same as the user clicking Run in the sidebar.
Why default-on:
- For event-driven tracks (with ` + "`" + `event` + "`" + ` triggers), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
- For tracks that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill ` + "`" + `context` + "`" + ` (see below) seeds rich initial content.
- After an edit, the user expects to see the updated output without an extra round-trip.
Confirm in one line and tell the user where to find it:
> "Done — I've set up a track refreshing hourly. Running it once now so you see content right away. You can manage it from the Track sidebar."
For an edit:
> "Updated. Re-running now so you can see the new output."
If you skipped the re-run (user said not to):
> "Updated — I'll let it run on its next trigger."
**Do not** write content into the note body yourself that's the track agent's job, delegated via ` + "`" + `run-track` + "`" + `.
## Using the ` + "`" + `run-track` + "`" + ` tool
` + "`" + `run-track` + "`" + ` triggers a single run right now. You can pass an optional ` + "`" + `context` + "`" + ` string to bias *this run only* without modifying the track's instruction the difference between a stock refresh and a smart backfill.
### Backfill ` + "`" + `context` + "`" + ` examples
- New event-driven track on Q3 emails run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
- New track on this week's customer calls run with:
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
- Manual refresh after the user mentions a recent change:
> context: "Focus on changes from the last 7 days only."
- Plain refresh (user said "run it now"): **omit ` + "`" + `context` + "`" + `**. Don't invent it.
### Reading the result
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
- ` + "`" + `action: 'replace'` + "`" + ` body changed. Confirm in one line; optionally cite the first line of ` + "`" + `contentAfter` + "`" + `.
- ` + "`" + `action: 'no_update'` + "`" + ` agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` usually explains why.
- ` + "`" + `error: 'Already running'` + "`" + ` another run is in flight; tell the user to retry shortly.
- Other ` + "`" + `error` + "`" + ` surface concisely.
### Don'ts
- **Don't run more than once** per user-facing action one tool call per turn.
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh it can mislead the agent.
- **Don't write content into the note body yourself** always delegate via ` + "`" + `run-track` + "`" + `.
## Don'ts
- **Don't reuse** an existing ` + "`" + `id` + "`" + ` in the same note's ` + "`" + `track:` + "`" + ` array.
- **Don't add ` + "`" + `triggers` + "`" + `** if the user explicitly wants a manual-only track.
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` runtime-managed.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` local time only.
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
## Editing or Removing an Existing Track
**Change triggers or instruction:** ` + "`" + `workspace-edit` + "`" + ` the relevant fields inside the ` + "`" + `track:` + "`" + ` array. Anchor on the unique ` + "`" + `id: <id>` + "`" + ` line plus a few surrounding lines.
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full track entry (from its ` + "`" + `- id:` + "`" + ` line down to just before the next ` + "`" + `- id:` + "`" + ` line or the closing ` + "`" + `---` + "`" + ` of the frontmatter), ` + "`" + `newString` + "`" + ` = empty. The note body is left alone if you want to clear leftover agent output, do that as a separate edit.
## Quick Reference
Minimal template (frontmatter only):
` + "```" + `yaml
track:
- id: <kebab-id>
instruction: |
<what to produce always use ` + "`" + `|` + "`" + `, indented 2 spaces>
active: true
triggers:
- type: cron
expression: "0 * * * *"
` + "```" + `
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
YAML style reminder: ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
`;
export default skill;

View file

@ -1550,25 +1550,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
isAvailable: async () => isComposioConfigured(),
},
'run-track': {
description: "Manually trigger a track to run now on its host note. Equivalent to the user clicking the Run button on the track in the sidebar, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking track from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
'run-live-note-agent': {
description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
id: z.string().describe("The track's id (must exist in the note's frontmatter `track:` array)"),
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md'). The note must already have a `live:` block in its frontmatter."),
context: z.string().optional().describe(
"Optional extra context for the track agent to consider for THIS run only — does not modify the track's instruction. " +
"Optional extra context for the live-note agent to consider for THIS run only — does not modify the note's objective. " +
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
"Omit for a plain refresh."
),
}),
execute: async ({ filePath, id, context }: { filePath: string; id: string; context?: string }) => {
execute: async ({ filePath, context }: { filePath: string; context?: string }) => {
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
try {
// Lazy import to break a module-init cycle:
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
const result = await triggerTrackUpdate(id, knowledgeRelativePath, context, 'manual');
// builtin-tools → live-note/runner → runs/runs → agents/runtime → builtin-tools
const { runLiveNoteAgent } = await import("../../knowledge/live-note/runner.js");
const result = await runLiveNoteAgent(knowledgeRelativePath, 'manual', context);
return {
success: !result.error,
runId: result.runId,

View file

@ -0,0 +1,141 @@
/**
* The canonical writing style for content written into the user's knowledge
* base. Imported by both the `doc-collab` skill (so Copilot picks it up on
* note edits) and the live-note run-agent prompt (so background runs use the
* same rules without having to load the skill on every fire). One source of
* truth, two consumers.
*
* If you change this guide, restart the dev server / rebuild both consumers
* inline it at module load.
*/
export const KNOWLEDGE_NOTE_STYLE_GUIDE = `# Knowledge-note writing style — terse and scannable
The user's knowledge base is a place they **scan**, not read. Every note competes for attention against many others. Optimize aggressively for **information density and signal-per-line**. These rules apply whether you're authoring a new note, refreshing a live note, or making a one-off edit they are not optional.
## The frame
- The reader wants the answer to "what's current / what changed?" in the fewest words that carry real information.
- A reader scanning ten notes in a row will give each one ~2 seconds. Format for that.
- Prose is the wrong shape for almost everything. Reach for it only when the content genuinely is a paragraph (user-written analysis, meeting reflection, qualitative narrative). Informational content facts, lists, status, news, prices, weather uses tighter shapes.
## Tightest shape that fits pick from this ladder
**1. Single line** when the answer is one fact.
- Weather: \`24°, Cloudy · NE 8mph · 12% PoP\`
- Price: \`BTC: $67,432 (+1.2% 24h)\`
- Time: \`2:30 PM IST\`
- Status: \`✓ All systems operational\` or \`⚠ db: degraded\`
**2. Compact table** for 2+ parallel items with the same shape.
\`\`\`
| Symbol | Price | Δ24h |
|--------|------:|------:|
| BTC | $67k | +1.2% |
| ETH | $3.2k | 0.8% |
\`\`\`
**3. Short bullets** for digests and lists. One line per item, 80 chars when possible. Lead with the value, push metadata to the end.
- News: \`- <headline> · <source> · <time>\`
- Tasks: \`- [ ] <task> · <due>\`
- HN: \`- <title> · 842 pts · 312 comments\`
**4. Status line + per-component bullets** when there's a top-level state plus details worth surfacing.
\`\`\`
db degraded
- api: 240ms p95 (vs 80ms baseline)
- db: connection pool saturated
\`\`\`
**5. Rich block** (\`table\`, \`chart\`, \`calendar\`, \`email\`, \`mermaid\`, etc.) when the data has a natural visual form. Don't render a calendar or chart in plain markdown when the rich block exists.
## Hard "no" list
- **No prose paragraphs** for informational content. Even if the topic is something a magazine would write 200 words about, the note version is bullets or a table.
- **No decorative adjectives**: "comprehensive", "balanced", "polished", "detailed", "high-quality", "carefully curated". They tell the reader nothing concrete.
- **No framing prose**: skip "Here's the latest update on…", "Below is a summary of…", "I've gathered the following…", "Quick rundown:". Get to the data on the first line.
- **No self-reference**: don't write "I updated this section at X" — the system records timestamps. Don't write "This note refreshes hourly" the user already knows.
- **No caveats unless the data is genuinely uncertain**: "Note: this is approximate", "As of last refresh", "Subject to change" are noise. If freshness matters, encode it inline: \`BTC: $67,432 (as of 14:05 IST)\`.
- **No preamble** no "Sure, here's…", "Got it, will do — here's the result." Just the result.
- **No filler headers** a note whose content is a single fact doesn't need a \`## Summary\` heading. Headings exist to break up content, not announce it.
## Bullet rules
- One line per bullet. No nesting beyond 2 levels if you reach for a third level, it should be a new section or a table.
- **Lead with the value.** "BTC at $67k" not "The current BTC price is approximately $67k".
- Use \`·\` (middle dot) as a separator for related fields when stacking 2+ items inline. \`<headline> · <source> · <time>\` reads better than \`(<source>, <time>)\`.
- Push metadata (time, source, status, score) to the **end** of the bullet, after a separator.
## Table rules
- Use a markdown table (or a \`table\` rich block) for ≥3 parallel items. For 1-2 items, use a single line or two bullets — a 2-row table is overhead with no benefit.
- Aim for 4 columns. More and the reader can't scan it.
- Right-align numeric columns when possible.
- No "Notes" column full of prose; if a row needs annotation, footnote it below the table.
## Sources and links make destinations clickable
Knowledge notes are entry points, not dead ends. **If the user might want to click through and read more, give them the link.** This applies to anything you pulled from outside the user's own data news, papers, blog posts, GitHub issues, status pages, search results, social posts, dashboards.
**Required when you have a URL:**
- Source attribution is non-negotiable for any item pulled from the web. Name the source (CNBC, Reuters, "GitHub", "company blog", "@<author> on X", etc.) **and** give a link to the canonical URL.
- Research / reference bullets that summarize external content.
- HN / front-page lists, paper digests, ranked items.
**Format:** make the **headline** the link that's what the user reaches for first.
- Preferred: \`- [<headline>](<url>) · <source> · <when>\`
- Acceptable: \`- <headline> · [<source>](<url>) · <when>\` when the headline isn't itself an article (e.g. a one-line insight you derived from the source).
If the bullet also carries a short description, the link still goes on the headline:
\`- [<headline>](<url>) · <source> · <when> · <one-line description>\`
**Not required:**
- Items pulled from the user's own data (calendar events, sent emails, meeting notes the user authored) the natural reference (event id, sender name, meeting filename) is enough.
- Pure point-in-time facts the user wouldn't drill into ("BTC: $67,432", "24°, Cloudy", "✓ All systems operational"). No link.
**Internal references:** use \`[[Note Name]]\` to link other knowledge-base notes. The editor renders these as clickable wiki-links — preferable to a flat path string.
**When you don't have a URL but it would be useful:** drop the link, keep the source name. Don't fabricate URLs. Don't write \`(link unavailable)\` — that's noise. If the source is a known publication, the source name alone is still informative.
## Genres cookbook
Common note types and the target shape for each:
- **Weather**: single line \`T°, Conditions · Wind · Precip\`. A 3-day micro-forecast as 3 lines if the user asks for it.
- **News digest**: bulleted list. Source attribution + link **required** when you have a URL see "Sources and links" above. Shape: \`- [<headline>](<url>) · <source> · <date>\` (optionally append \` · <one-line takeaway>\` when the headline alone isn't enough). Group by topic only when >10 items.
- **Stock / crypto prices**: table with \`Symbol | Price | Δ24h | Δ7d\`. Add a \`chart\` block for time series only when the user asks for trends. No links — these are point-in-time facts.
- **Service status**: a single status line; per-component bullets *only* when something is degraded. Link the status page when surfacing the top-level status (\`[✓ All systems operational](<status_url>)\`).
- **Calendar / agenda**: \`calendar\` rich block. Never plain markdown.
- **Email digest**: \`emails\` rich block (multi-thread) or \`email\` block (single thread). Plain markdown only for one-line summaries when there are >20 threads.
- **HN / front-page lists**: bullets \`- [<title>](<url>) · <points> pts · <comments> comments\`. Title is always the link.
- **Tasks / priorities**: ranked bullets with priority tag \`- [P0] <task> · <due>\`. \`[[wiki-link]]\` to a source note when one exists (e.g. the task came from a meeting note).
- **Research notes / search results**: bullets with **link**, source, 1-line gist \`- [<title>](<url>) · <source> · <gist>\`. Link is required when you found this via search. Don't synthesize into prose.
- **GitHub / issue digests**: \`- [<title>](<issue_url>) · <repo> · <state> · <updated>\`.
- **Tweets / social digests**: \`- [<truncated text or topic>](<post_url>) · @<author> · <when>\`.
## When prose IS appropriate
- A **1-3 sentence opening summary** at the top of a complex note (a "lede") concise enough to scan.
- A section the user explicitly authored as narrative (a journal entry, meeting reflection, qualitative analysis).
- The **user's own writing** never restructure it into bullets unless they ask.
For everything else: bullets, tables, single lines.
## A worked example
**Bad** wall of prose, decorative adjectives, framing, caveats:
> Here's a comprehensive update on today's most important news from India and around the world. The geopolitical landscape continues to evolve rapidly, with several significant developments worth highlighting. In India, the markets had a notable session today, with the Sensex closing higher on positive sentiment around the upcoming budget. Meanwhile, in global news, there have been important shifts in technology and finance.
**Good** bullets, lead with value, metadata at the end, no framing, **headline is a link to the source article**:
> ## India
> - [Sensex closes +0.6% at 73,420](https://www.livemint.com/...) · Mint · 4 PM
> - [Budget speech draft sets fiscal-deficit target at 4.5%](https://www.reuters.com/...) · Reuters · 2 PM
> - [Cabinet clears semiconductor mission Phase 2](https://economictimes.indiatimes.com/...) · ET · 11 AM
>
> ## World
> - [OpenAI launches GPT-5 mini for free tier](https://techcrunch.com/...) · TechCrunch · 9 AM PT
> - [Fed minutes signal one more cut this year](https://www.bloomberg.com/...) · Bloomberg · 2 PM ET
> - [EU passes AI Act amendment on training data](https://www.politico.eu/...) · Politico · 3 PM CET
Same information, ~80% fewer words, scannable in 5 seconds.
`;

View file

@ -1,7 +1,7 @@
import path from 'path';
import fs from 'fs';
import { stringify as stringifyYaml } from 'yaml';
import { TrackSchema } from '@x/shared/dist/track.js';
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
import { WorkDir } from '../config/config.js';
import { splitFrontmatter } from '../application/lib/parse-frontmatter.js';
import z from 'zod';
@ -9,108 +9,47 @@ import z from 'zod';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
// Bump this whenever the canonical Today.md template changes (TRACKS list,
// instructions, default body, etc.). On app start, ensureDailyNote() compares
// the on-disk `templateVersion` against this constant — if older or missing,
// the existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with
// the new template, preserving the body byte-for-byte.
const CANONICAL_DAILY_NOTE_VERSION = 1;
// Bump this whenever the canonical Today.md template changes (objective,
// triggers, default body, etc.). On app start, ensureDailyNote() compares the
// on-disk `templateVersion` against this constant — if older or missing, the
// existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with the
// new template. v2 is the live-note rewrite (single objective, no `track:`).
const CANONICAL_DAILY_NOTE_VERSION = 2;
// Window triggers below fire once per day, anywhere inside their time-of-day
// band — so the user opening the app late in the morning still gets the
// morning run. See schedule-utils.ts for the exact semantics.
const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
objective:
`Keep Today.md current as a living dashboard for the day. Maintain these H2 sections in this order:
const TRACKS: z.infer<typeof TrackSchema>[] = [
{
id: 'overview',
instruction:
`In a section titled "Overview" at the top of the note: 23 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from calendar_sync/ and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Skip the update if the prior content is still suitable and less than 24h old. VERY IMPORTANT: Ensure that image is wide / low-height!`,
active: true,
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
1. **Overview** 2-3 prose sentences greeting the user and reading the day (warm, confident tone use today's calendar density from \`calendar_sync/\` and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Keep the image **wide / low-height**. Skip refreshing this section if its content is still suitable and less than 24h old.
2. **Calendar** today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\`\`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`.
3. **Emails** a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
4. **What you missed** a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings/<source>/<yesterday>/\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."
5. **Priorities** a ranked markdown list of actionable items the user should focus on today. Sources: yesterday's meeting action items (\`knowledge/Meetings/<source>/<yesterday>/\`), open follow-ups across \`knowledge/\` (\`workspace-grep\` for "- [ ]"), the **What you missed** section above. Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline. With an event payload (gmail or calendar), only re-emit the full list if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."
Treat the note as a coherent artifact. Make small, incremental edits one section at a time rather than rewriting the whole body each run.`,
active: true,
triggers: {
// Three windows give the user a fresh dashboard morning, midday, and
// post-lunch even with no calendar/email events landing in between.
windows: [
{ startTime: '08:00', endTime: '12:00' },
{ startTime: '12:00', endTime: '15:00' },
{ startTime: '15:00', endTime: '18:00' },
],
// Event-driven updates handle in-day shifts (new email threads worth
// attention, calendar reshuffles, urgent escalations).
eventMatchCriteria:
`Email or calendar events that may shift today's dashboard: new or updated email threads needing the user's attention, urgent reply requests, deadline-bearing items, escalations from people the user cares about, calendar additions/cancellations/reschedules affecting today, or anything that changes the user's near-term priorities. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
},
{
id: 'calendar',
instruction:
`In a section titled "Calendar", emit today's meetings as a \`calendar\` block titled "Today's Meetings". Read calendar_sync/ via workspace-readdir → workspace-readFile each .json. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a conferenceLink.`,
active: true,
triggers: [{
type: 'event',
matchCriteria:
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
}],
},
{
id: 'emails',
instruction:
`In a section titled "Emails", maintain a digest of email threads worth attention today. Output everything as a **single** fenced code block with language \`emails\` (plural — never individual \`email\` blocks per thread). The body must be JSON shaped \`{"title":"Today's Emails","emails":[...]}\`.
Each entry in the array: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads that need a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`.
Skip marketing, auto-notifications, and closed threads. Without an event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/), prioritizing threads with frontmatter action = "reply" or "respond". With an event payload, integrate any qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if the threadId is already shown). Do not re-list threads the user has already seen unless their state changed.
If nothing qualifies: "No new emails."`,
active: true,
triggers: [{
type: 'event',
matchCriteria:
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
}],
},
{
id: 'what-you-missed',
instruction:
`In a section titled "What you missed", write a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from knowledge/Meetings/<source>/<yesterday>/ (workspace-readdir recursive on knowledge/Meetings, filter folders matching yesterday's date, read each file). Skim gmail_sync/ for threads that went unresolved. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."`,
active: true,
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
],
},
{
id: 'priorities',
instruction:
`In a section titled "Priorities", a ranked markdown list of actionable items the user should focus on today.
Sources: yesterday's meeting action items (knowledge/Meetings/<source>/<yesterday>/), open follow-ups across knowledge/ (workspace-grep for "- [ ]"), the "What you missed" section.
Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline.
With an event payload (gmail or calendar): re-emit the full list only if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). Otherwise skip the update.
If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."`,
active: true,
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
{
type: 'event',
matchCriteria:
`New or updated email threads that may shift today's priorities — urgent reply requests, deadline-bearing items, escalations from people the user cares about.`,
},
{
type: 'event',
matchCriteria:
`Calendar changes today that may shift priorities — a meeting moved to clash with a deadline, an unexpected event added, a key meeting cancelled freeing up time.`,
},
],
},
];
};
function buildDailyNoteContent(body: string = '# Today\n'): string {
const fm = stringifyYaml(
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, track: TRACKS },
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, live: TODAY_LIVE_NOTE },
{ lineWidth: 0, blockQuote: 'literal' },
).trimEnd();
return `---\n${fm}\n---\n${body}`;
@ -138,11 +77,11 @@ export function ensureDailyNote(): void {
// Migrate aggressively: rename existing → backup, write a fresh canonical
// template (no body carried over). Today.md is a flagship demo whose
// content is meant to be regenerated by the tracks anyway — preserving the
// old body just leaves orphan sections behind on rename/restructure. The
// .bkp file is the recovery path; its name doesn't end in `.md`, so the
// scheduler and event router naturally skip it. Pre-rewrite inline-fence
// notes are caught by this same path.
// content is meant to be regenerated by the live-note agent anyway —
// preserving the old body just leaves orphan sections behind on
// restructure. The .bkp file is the recovery path; its name doesn't end
// in `.md`, so the scheduler and event router naturally skip it. Pre-v2
// notes (with the old `track:` array) are caught by this same path.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`;
fs.renameSync(DAILY_NOTE_PATH, backupPath);

View file

@ -1,16 +1,17 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../application/lib/knowledge-note-style.js';
import { WorkDir } from '../../config/config.js';
const TRACK_RUN_INSTRUCTIONS = `You are a track runner — a background agent that keeps a live note in the user's personal knowledge base up to date.
export const LIVE_NOTE_AGENT_INSTRUCTIONS = `You are the live-note agent — a background agent that keeps a *live note* in the user's personal knowledge base current with its objective.
Your goal on each run: update the body of the note so that, given the track's instruction, the content is the most useful and up-to-date version it can be. The user is maintaining a personal knowledge base and will scan this note alongside many others optimize for **information density and scannability**, not conversational prose.
Your goal on each run: bring the body of the note in line with the user's persistent **objective** for that note. The user is maintaining a personal knowledge base and will scan this note alongside many others optimize for **information density and scannability**, not conversational prose.
# Background Mode
You are running as a scheduled or event-triggered background task **there is no user present** to clarify, approve, or watch.
- Do NOT ask clarifying questions make the most reasonable interpretation of the instruction and proceed.
- Do NOT ask clarifying questions make the most reasonable interpretation of the objective and proceed.
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
- Do NOT produce chat-style output. The user sees only the changes you make to the note plus your final summary line.
@ -18,75 +19,72 @@ You are running as a scheduled or event-triggered background task — **there is
Every run message has this shape:
Update track **<id>** in \`<filePath>\`.
Update the live note at \`<filePath>\`.
**Time:** <localized datetime> (<timezone>)
**Instruction:**
<the user-authored track instruction usually 1-3 sentences describing what to produce>
**Objective:**
<the user-authored objective usually 1-3 sentences describing what the note should keep being>
Start by calling \`workspace-readFile\` on \`<filePath>\` to read the current note (frontmatter + body). Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file.
Start by calling \`workspace-readFile\` on \`<filePath>\` ... patch-style edits ...
For **manual** runs, an optional trailing block may appear:
**Context:**
<extra one-run-only guidance a backfill hint, a focus window, extra data>
Apply context for this run only it is not a permanent edit to the instruction.
Apply context for this run only it is not a permanent edit to the objective.
For **event-triggered** runs, a trailing block appears instead:
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
**Event match criteria for this track:** <from the track's frontmatter>
**Trigger:** Event match (Pass 1 routing flagged this note)
**Event match criteria for this note:** <from the note's frontmatter>
**Event payload:** <the event body e.g., an email>
**Decision:** ... skip if not relevant ...
On event runs you are the Pass 2 judge see "The No-Update Decision" below.
# Editing the Note
# Editing the Note (patch-style)
You have full read/write access to the note body via the standard workspace tools:
- \`workspace-readFile\` — read the current state of the note (frontmatter included; you can ignore the frontmatter).
- \`workspace-edit\` — apply patches.
- \`workspace-writeFile\` — replace the entire file (use sparingly; prefer \`workspace-edit\`).
You own the **entire body below the H1** you may freely add, edit, reorganize, dedupe, and trim its content to satisfy the objective. The frontmatter (the \`---\`-delimited block at the top) is owned by the user and the runtime — **never modify it**.
**Do NOT modify the YAML frontmatter at the top of the file** (the \`---\`-delimited block). It contains the track configuration and runtime state owned by the user and the runtime. Editing it can corrupt the track's schedule, history, or the note's metadata.
**Make incremental, patch-style edits not one-shot rewrites.**
# Section Placement
The right pattern on every run:
1. \`workspace-readFile\` to fetch the current note.
2. Decide on the *first* change you need to make (add a section, replace a stale figure, dedupe entries, fix an out-of-date paragraph).
3. \`workspace-edit\` to make that one change.
4. \`workspace-readFile\` again to confirm the result.
5. Decide the *next* change. Repeat.
Each track's instruction may name a **section** in the note where its content lives — e.g. *"in a section titled 'Overview' at the top"* or *"in a section titled 'Photo' right after Overview"*. You own that section and only that section.
Why patch-style:
- It preserves user-added content you didn't account for. The user may have written prose between your sections; whole-body rewrites destroy it.
- It makes diffs reviewable the user can scan a few small changes far more easily than a wall-of-replacement.
- It lets you abort partway if a tool call fails, leaving the note in a consistent partial state instead of a clobbered one.
How to handle sections:
Avoid:
- Calling \`workspace-writeFile\` to replace the entire body. That's the no-go path.
- Building up the entire new body in your head and emitting it in a single \`workspace-edit\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps.
- Sections are H2 headings (\`## Section Name\`). Match by exact heading text.
- **If the named section exists**: replace its content (everything between that heading and the next H2 or end of file) with your new output. Heading itself stays intact.
- **If the section is missing**: create it. Use the placement hint to decide where:
- "at the top" just below the H1 title (or first line if there's none).
- "after X" immediately after section X. If X doesn't exist either, fall back to natural reading order.
- no hint append to the end of the body.
- **Never modify another track's section content.** Other agents own those.
- **Never duplicate a section.** If two H2 headings match yours, consolidate into the first.
- The user may rename your section's heading. If you can't find it by exact name on a later run, recreate it per the placement hint.
# Body Structure (defaults)
After writing your section, **re-check its position**. The first time tracks run on a fresh note, sections land in firing order rather than reading order, so the file ends up out of sequence. If your section is now in the wrong place relative to your placement hint (e.g. your "Photo" section is meant to sit right after "Overview" but ended up at the bottom), **move your own section block** (your H2 heading + its content, no surrounding blank lines lost) to the correct position. Cut-and-paste only never rewrite or reorder *other* tracks' sections; they will self-correct on their own next runs.
Unless the objective explicitly specifies a different structure, follow this default shape:
A section can hold prose, lists, or rich blocks (calendar/email/image/etc.) per the instruction. You always write a **complete** replacement for the section you own not a diff.
- **H1** stays the note title (the first \`# ...\` line). Don't touch it.
- **Top:** a short rolling summary (1-3 sentences) capturing the current state of whatever the note is tracking. Update or replace this on each run.
- **Below:** content organized by sub-topic under H2 headings (\`## ...\`), with the freshest / most-important sections first.
- **Tightness over decoration.** Tables, bullets, one-line statuses. Not paragraphs. No "Here's your update" prose.
- **Dedupe** as you go if you're adding a new item that's already present in another section, consolidate rather than duplicate.
# What Good Output Looks Like
If the objective says something specific about layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly and ignore the defaults.
This is a personal knowledge tracker. The user scans many such notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
${KNOWLEDGE_NOTE_STYLE_GUIDE}
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape.
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
- **No commentary or caveats** unless the data itself is genuinely uncertain.
- **No self-reference.** Do not write "I updated this at X" the system records timestamps separately.
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
The style guide above is the canonical writing style for everything you emit into the body. The objective may specify a particular shape ("3-column markdown table: Location | Local Time | Offset") when it does, follow it exactly. When it doesn't, walk the ladder above and pick the tightest shape that fits the data.
# Output Block Types
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the objective asks for a multi-section layout and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
@ -243,15 +241,15 @@ instruction: |
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
# Interpreting the Instruction
# Interpreting the Objective
The instruction was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
The objective was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
- "Top 5" is a target fewer is acceptable if that's all that exists.
- "Current" means as of now (use the **Time** block).
- Unspecified units standard for the domain (USD for US markets, metric for scientific, the user's locale if inferable from the timezone).
- Unspecified sources your best reliable source (web-search for public data, workspace for user data).
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") these are decoration.
Do **not** invent parts of the objective the user did not write ("also include a fun fact", "summarize trends") these are decoration.
# The No-Update Decision
@ -266,11 +264,11 @@ When skipping, still end with a summary line (see "Final Summary" below) so the
You have the full workspace toolkit. Quick reference for common cases:
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off.
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off. Prefer many small \`workspace-edit\` calls over one giant \`workspace-writeFile\`.
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the objective needs information beyond the workspace.
- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data.
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if the objective references attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when the objective needs structured data from a connected service the user has authorized.
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes.
@ -282,7 +280,7 @@ The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organ
- **Projects/** initiatives
- **Topics/** recurring themes
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when an instruction references emails, meetings, or calendar events.
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when the objective references emails, meetings, or calendar events.
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
@ -291,7 +289,7 @@ Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync
# Failure & Fallback
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
If you cannot complete the objective (network failure, missing data source, unparseable response, disconnected integration):
- Do **not** fabricate or speculate.
- Do **not** write partial or placeholder content leave the existing body intact by skipping the edit.
- Explain the failure in the summary line.
@ -307,10 +305,10 @@ State the action and the substance. Good examples:
- "Skipped — event was a calendar invite unrelated to Q3 planning."
- "Failed — web-search returned no results for the query."
Avoid: "I updated the track.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
`;
export function buildTrackRunAgent(): z.infer<typeof Agent> {
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
@ -318,9 +316,9 @@ export function buildTrackRunAgent(): z.infer<typeof Agent> {
}
return {
name: 'track-run',
description: 'Background agent that keeps a track-driven note up to date',
instructions: TRACK_RUN_INSTRUCTIONS,
name: 'live-note-agent',
description: 'Background agent that keeps a live note up to date with its objective',
instructions: LIVE_NOTE_AGENT_INSTRUCTIONS,
tools,
};
}

View file

@ -1,11 +1,11 @@
import type { TrackEventType } from '@x/shared/dist/track.js';
import type { LiveNoteAgentEventType } from '@x/shared/dist/live-note.js';
type Handler = (event: TrackEventType) => void;
type Handler = (event: LiveNoteAgentEventType) => void;
class TrackBus {
class LiveNoteBus {
private subs: Handler[] = [];
publish(event: TrackEventType): void {
publish(event: LiveNoteAgentEventType): void {
for (const handler of this.subs) {
handler(event);
}
@ -20,4 +20,4 @@ class TrackBus {
}
}
export const trackBus = new TrackBus();
export const liveNoteBus = new LiveNoteBus();

View file

@ -1,12 +1,12 @@
import fs from 'fs';
import path from 'path';
import { PrefixLogger, track } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
import { PrefixLogger, liveNote } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/live-note.js';
import { WorkDir } from '../../config/config.js';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
import { triggerTrackUpdate } from './runner.js';
import { findCandidates, type ParsedTrack } from './routing.js';
import { fetchLiveNote } from './fileops.js';
import { runLiveNoteAgent } from './runner.js';
import { findCandidates, type ParsedLiveNote } from './routing.js';
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import container from '../../di/container.js';
@ -15,7 +15,7 @@ const EVENTS_DIR = path.join(WorkDir, 'events');
const PENDING_DIR = path.join(EVENTS_DIR, 'pending');
const DONE_DIR = path.join(EVENTS_DIR, 'done');
const log = new PrefixLogger('EventProcessor');
const log = new PrefixLogger('LiveNote:Events');
/**
* Write a KnowledgeEvent to the events/pending/ directory.
@ -39,43 +39,38 @@ function ensureDirs(): void {
fs.mkdirSync(DONE_DIR, { recursive: true });
}
async function listAllTracks(): Promise<ParsedTrack[]> {
const tracks: ParsedTrack[] = [];
async function listEventEligibleLiveNotes(): Promise<ParsedLiveNote[]> {
const out: ParsedLiveNote[] = [];
let entries;
try {
entries = await workspace.readdir('knowledge', { recursive: true });
} catch {
return tracks;
return out;
}
const mdFiles = entries
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
.map(e => e.path.replace(/^knowledge\//, ''));
for (const filePath of mdFiles) {
let parsedTracks;
let live;
try {
parsedTracks = await fetchAll(filePath);
live = await fetchLiveNote(filePath);
} catch {
continue;
}
for (const t of parsedTracks) {
const eventCriteria = (t.track.triggers ?? [])
.filter(trig => trig.type === 'event')
.map(trig => trig.matchCriteria)
.filter(Boolean)
.join('; ');
// Skip tracks with no event triggers — they're not event-eligible.
if (!eventCriteria) continue;
tracks.push({
trackId: t.track.id,
filePath,
eventMatchCriteria: eventCriteria,
instruction: t.track.instruction,
active: t.track.active,
});
}
if (!live) continue;
if (live.active === false) continue;
const eventMatchCriteria = live.triggers?.eventMatchCriteria;
if (!eventMatchCriteria) continue; // not event-eligible
out.push({
filePath,
objective: live.objective,
eventMatchCriteria,
});
}
return tracks;
return out;
}
function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
@ -85,7 +80,7 @@ function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
try {
fs.unlinkSync(pendingPath);
} catch (err) {
log.log(`Failed to remove pending event ${filename}:`, err);
log.log(`failed to remove pending event ${filename}: ${err instanceof Error ? err.message : String(err)}`);
}
}
@ -96,10 +91,10 @@ async function processOneEvent(filename: string): Promise<void> {
try {
const raw = fs.readFileSync(pendingPath, 'utf-8');
const parsed = JSON.parse(raw);
event = track.KnowledgeEventSchema.parse(parsed);
event = liveNote.KnowledgeEventSchema.parse(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.log(`Malformed event ${filename}, moving to done with error:`, msg);
log.log(`event:${filename} — malformed, moving to done with error: ${msg}`);
const stub: KnowledgeEvent = {
id: filename.replace(/\.json$/, ''),
source: 'unknown',
@ -113,36 +108,48 @@ async function processOneEvent(filename: string): Promise<void> {
return;
}
log.log(`Processing event ${event.id} (source=${event.source}, type=${event.type})`);
log.log(`event:${event.id} — received source=${event.source} type=${event.type}`);
const allTracks = await listAllTracks();
const candidates = await findCandidates(event, allTracks);
const eligible = await listEventEligibleLiveNotes();
const candidates = await findCandidates(event, eligible);
if (candidates.length === 0) {
log.log(`event:${event.id} — no candidates (${eligible.length} eligible note${eligible.length === 1 ? '' : 's'})`);
} else {
log.log(`event:${event.id} — dispatching to ${candidates.length} candidate${candidates.length === 1 ? '' : 's'}: ${candidates.map(c => c.filePath).join(', ')}`);
}
const runIds: string[] = [];
let processingError: string | undefined;
let okCount = 0;
let errCount = 0;
// Sequential — preserves total ordering
for (const candidate of candidates) {
try {
const result = await triggerTrackUpdate(
candidate.trackId,
candidate.filePath,
event.payload,
'event',
);
const result = await runLiveNoteAgent(candidate.filePath, 'event', event.payload);
if (result.runId) runIds.push(result.runId);
log.log(`Candidate ${candidate.trackId}: ${result.action}${result.error ? ` (${result.error})` : ''}`);
if (result.error) {
errCount++;
} else {
okCount++;
}
} catch (err) {
errCount++;
const msg = err instanceof Error ? err.message : String(err);
log.log(`Error triggering candidate ${candidate.trackId}:`, msg);
processingError = (processingError ? processingError + '; ' : '') + `${candidate.trackId}: ${msg}`;
log.log(`event:${event.id} — candidate ${candidate.filePath} threw: ${msg}`);
processingError = (processingError ? processingError + '; ' : '') + `${candidate.filePath}: ${msg}`;
}
}
if (candidates.length > 0) {
log.log(`event:${event.id} — processed ok=${okCount} errors=${errCount}`);
}
const enriched: KnowledgeEvent = {
...event,
processedAt: new Date().toISOString(),
candidates: candidates.map(c => ({ trackId: c.trackId, filePath: c.filePath })),
candidateFilePaths: candidates.map(c => c.filePath),
runIds,
...(processingError ? { error: processingError } : {}),
};
@ -157,7 +164,7 @@ async function processPendingEvents(): Promise<void> {
try {
filenames = fs.readdirSync(PENDING_DIR).filter(f => f.endsWith('.json'));
} catch (err) {
log.log('Failed to read pending dir:', err);
log.log(`failed to read pending dir: ${err instanceof Error ? err.message : String(err)}`);
return;
}
@ -166,23 +173,24 @@ async function processPendingEvents(): Promise<void> {
// FIFO: monotonic IDs are lexicographically sortable
filenames.sort();
log.log(`Processing ${filenames.length} pending event(s)`);
if (filenames.length > 1) {
log.log(`tick — ${filenames.length} pending events`);
}
for (const filename of filenames) {
try {
await processOneEvent(filename);
} catch (err) {
log.log(`Unhandled error processing ${filename}:`, err);
log.log(`event:${filename} — unhandled error: ${err instanceof Error ? err.message : String(err)}`);
// Keep the loop alive — don't move file, will retry on next tick
}
}
}
export async function init(): Promise<void> {
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
log.log(`starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
ensureDirs();
// Initial run
await processPendingEvents();
while (true) {
@ -190,7 +198,7 @@ export async function init(): Promise<void> {
try {
await processPendingEvents();
} catch (err) {
log.log('Error in main loop:', err);
log.log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
}
}
}

View file

@ -0,0 +1,202 @@
import fs from 'fs/promises';
import path from 'path';
import { LiveNoteSchema, type LiveNote } from '@x/shared/dist/live-note.js';
import { WorkDir } from '../../config/config.js';
import { withFileLock } from '../file-lock.js';
import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
function getLiveBlock(fm: Record<string, unknown>): unknown {
return fm.live ?? null;
}
function setLiveBlock(fm: Record<string, unknown>, live: unknown): Record<string, unknown> {
const next = { ...fm };
if (live === null || live === undefined) {
delete next.live;
} else {
next.live = live;
}
return next;
}
// ---------------------------------------------------------------------------
// Read
// ---------------------------------------------------------------------------
export async function fetchLiveNote(filePath: string): Promise<LiveNote | null> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return null;
}
const { frontmatter } = splitFrontmatter(content);
const raw = getLiveBlock(frontmatter);
if (!raw) return null;
const parsed = LiveNoteSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
export async function readNoteBody(filePath: string): Promise<string> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return '';
}
return splitFrontmatter(content).body;
}
// ---------------------------------------------------------------------------
// Write
// ---------------------------------------------------------------------------
/**
* Replace (or create) the entire `live:` block. The renderer's structured
* editor calls this with the complete object; runtime patches go through
* {@link patchLiveNote}.
*/
export async function setLiveNote(filePath: string, live: LiveNote): Promise<void> {
const validated = LiveNoteSchema.parse(live);
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const nextFm = setLiveBlock(frontmatter, validated);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
/**
* Merge a partial update into the `live:` block. Used by the runner to
* write `lastRunAt` / `lastRunId` / `lastRunSummary` without round-tripping
* the rest of the user-authored config through schema validation.
*/
export async function patchLiveNote(
filePath: string,
updates: Partial<LiveNote>,
): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const existing = getLiveBlock(frontmatter);
if (!existing || typeof existing !== 'object') {
throw new Error(`No live: block in ${filePath}`);
}
const merged = { ...(existing as Record<string, unknown>), ...updates };
const nextFm = setLiveBlock(frontmatter, merged);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
export async function deleteLiveNote(filePath: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
if (!getLiveBlock(frontmatter)) return; // already passive
const nextFm = setLiveBlock(frontmatter, null);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
export async function setLiveNoteActive(
filePath: string,
active: boolean,
): Promise<LiveNoteSummary | null> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const existing = getLiveBlock(frontmatter);
if (!existing || typeof existing !== 'object') return null;
const current = existing as Record<string, unknown>;
const currentlyActive = current.active !== false;
if (currentlyActive !== active) {
const merged = { ...current, active };
const nextFm = setLiveBlock(frontmatter, merged);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
}
const validated = await fetchLiveNote(filePath);
return validated ? buildSummary(filePath, validated) : null;
});
}
// ---------------------------------------------------------------------------
// Note-level summaries (background-agents view)
// ---------------------------------------------------------------------------
export type LiveNoteSummary = {
path: string;
createdAt: string | null;
lastRunAt: string | null;
isActive: boolean;
objective: string;
};
function buildSummaryFromStat(filePath: string, live: LiveNote, createdMs: number): LiveNoteSummary {
return {
path: `knowledge/${filePath}`,
createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null,
lastRunAt: live.lastRunAt ?? null,
isActive: live.active !== false,
objective: live.objective,
};
}
async function buildSummary(filePath: string, live: LiveNote): Promise<LiveNoteSummary> {
const stats = await fs.stat(absPath(filePath));
const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs;
return buildSummaryFromStat(filePath, live, createdMs);
}
export async function listLiveNotes(): Promise<LiveNoteSummary[]> {
async function walk(relativeDir = ''): Promise<string[]> {
const dirPath = absPath(relativeDir);
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const childRelPath = relativeDir
? path.posix.join(relativeDir, entry.name)
: entry.name;
if (entry.isDirectory()) {
files.push(...await walk(childRelPath));
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
files.push(childRelPath);
}
}
return files;
} catch {
return [];
}
}
const markdownFiles = await walk();
const summaries = await Promise.all(markdownFiles.map(async (relativePath) => {
try {
const live = await fetchLiveNote(relativePath);
if (!live) return null;
return await buildSummary(relativePath, live);
} catch {
return null;
}
}));
return summaries
.filter((note): note is LiveNoteSummary => note !== null)
.sort((a, b) => {
const aName = path.basename(a.path, '.md').toLowerCase();
const bName = path.basename(b.path, '.md').toLowerCase();
if (aName !== bName) return aName.localeCompare(bName);
return a.path.localeCompare(b.path);
});
}

View file

@ -0,0 +1,111 @@
import { generateObject } from 'ai';
import { liveNote, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/live-note.js';
import { createProvider } from '../../models/models.js';
import { getDefaultModelAndProvider, getLiveNoteAgentModel, resolveProviderConfig } from '../../models/defaults.js';
import { captureLlmUsage } from '../../analytics/usage.js';
const log = new PrefixLogger('LiveNote:Routing');
const BATCH_SIZE = 20;
export interface ParsedLiveNote {
filePath: string;
objective: string;
eventMatchCriteria: string;
}
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a personal knowledge base.
You will receive an event (something that happened an email, meeting, message, etc.) and a list of *live notes*. Each live note has:
- filePath: the path of the note file
- objective: the persistent intent of the note (what it should keep being / containing)
- matchCriteria: an explicit description of which kinds of incoming signals should wake this note
Your job is to identify which live notes MIGHT be relevant to this event.
Rules:
- Be LIBERAL in your selections. Include any note that is even moderately relevant.
- Prefer false positives over false negatives it is much better to include a note that turns out to be irrelevant than to miss one that was relevant.
- Only exclude notes that are CLEARLY and OBVIOUSLY irrelevant to the event.
- Do not attempt to judge whether the event contains enough information to act on. That is handled by the live-note agent in a later stage.
- Return an empty list only if no notes are relevant at all.
- Return each candidate's filePath exactly as given.`;
async function resolveModel() {
const modelId = await getLiveNoteAgentModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
return {
model: createProvider(config).languageModel(modelId),
modelId,
providerName: provider,
};
}
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedLiveNote[]): string {
const noteList = batch
.map((n, i) => `${i + 1}. filePath: ${n.filePath}\n objective: ${n.objective}\n matchCriteria: ${n.eventMatchCriteria}`)
.join('\n\n');
return `## Event
Source: ${event.source}
Type: ${event.type}
Time: ${event.createdAt}
${event.payload}
## Live notes
${noteList}`;
}
export async function findCandidates(
event: KnowledgeEvent,
allLiveNotes: ParsedLiveNote[],
): Promise<ParsedLiveNote[]> {
// Short-circuit for targeted re-runs — skip LLM routing entirely
if (event.targetFilePath) {
const target = allLiveNotes.find(n => n.filePath === event.targetFilePath);
return target ? [target] : [];
}
if (allLiveNotes.length === 0) {
log.log(`event:${event.id} — no event-eligible live notes`);
return [];
}
log.log(`event:${event.id} — routing against ${allLiveNotes.length} live note${allLiveNotes.length === 1 ? '' : 's'}`);
const { model, modelId, providerName } = await resolveModel();
const candidatePaths = new Set<string>();
for (let i = 0; i < allLiveNotes.length; i += BATCH_SIZE) {
const batch = allLiveNotes.slice(i, i + BATCH_SIZE);
try {
const result = await generateObject({
model,
system: ROUTING_SYSTEM_PROMPT,
prompt: buildRoutingPrompt(event, batch),
schema: liveNote.Pass1OutputSchema,
});
captureLlmUsage({
useCase: 'live_note_agent',
subUseCase: 'routing',
model: modelId,
provider: providerName,
usage: result.usage,
});
for (const fp of result.object.filePaths) {
candidatePaths.add(fp);
}
} catch (err) {
log.log(`event:${event.id} — Pass1 batch ${Math.floor(i / BATCH_SIZE)} failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
const candidates = allLiveNotes.filter(n => candidatePaths.has(n.filePath));
log.log(`event:${event.id} — Pass1 → ${candidates.length} candidate${candidates.length === 1 ? '' : 's'}${candidates.length > 0 ? `: ${candidates.map(c => c.filePath).join(', ')}` : ''}`);
return candidates;
}

View file

@ -0,0 +1,235 @@
import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js';
import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { getLiveNoteAgentModel } from '../../models/defaults.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
import { liveNoteBus } from './bus.js';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
const log = new PrefixLogger('LiveNote:Agent');
export interface LiveNoteAgentResult {
filePath: string;
runId: string | null;
action: 'replace' | 'no_update';
contentBefore: string | null;
contentAfter: string | null;
summary: string | null;
error?: string;
}
const SUMMARY_LOG_LIMIT = 120;
function truncate(s: string | null | undefined, n = SUMMARY_LOG_LIMIT): string {
if (!s) return '';
return s.length <= n ? s : `${s.slice(0, n - 1)}`;
}
// ---------------------------------------------------------------------------
// Agent run message
// ---------------------------------------------------------------------------
function describeWindow(triggers: LiveNote['triggers']): string {
const ws = triggers?.windows;
if (!ws || ws.length === 0) return 'a configured window';
return ws.map(w => `${w.startTime}${w.endTime}`).join(', ');
}
function buildTriggerBlock(
live: LiveNote,
trigger: LiveNoteTriggerType,
context: string | undefined,
): string {
if (trigger === 'event') {
const criteria = live.triggers?.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)';
return `
**Trigger:** Event match Pass 1 routing flagged this note as potentially relevant to the event below.
**Event match criteria for this note:**
${criteria}
**Event payload:**
${context ?? '(no payload)'}
**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that the objective implies should be reflected.`;
}
if (trigger === 'cron') {
const expr = live.triggers?.cronExpr ?? '(unknown)';
return `
**Trigger:** Scheduled refresh the cron expression \`${expr}\` matched. This is a baseline refresh; if your objective specifies different behavior for cron vs window vs event runs, follow the cron branch.${context ? `\n\n**Context:**\n${context}` : ''}`;
}
if (trigger === 'window') {
return `
**Trigger:** Scheduled refresh fired inside the configured window (${describeWindow(live.triggers)}). This is a forgiving baseline refresh that runs once per day per window; reactive updates are handled by event triggers (when configured). If your objective specifies different behavior for cron vs window vs event runs, follow the window branch.${context ? `\n\n**Context:**\n${context}` : ''}`;
}
// manual
return `
**Trigger:** Manual run (user-triggered either the Run button in the Live Note panel or the \`run-live-note-agent\` tool).${context ? `\n\n**Context:**\n${context}` : ''}`;
}
function buildMessage(
filePath: string,
live: LiveNote,
trigger: LiveNoteTriggerType,
context?: string,
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Workspace-relative path the agent's tools (workspace-readFile,
// workspace-edit) expect. Internal storage is knowledge/-relative.
const wsPath = `knowledge/${filePath}`;
const baseMessage = `Update the live note at \`${wsPath}\`.
**Time:** ${localNow} (${tz})
**Objective:**
${live.objective}
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then make small, incremental edits with \`workspace-edit\` to bring the body in line with the objective: edit one region, re-read to verify, then edit the next region. Avoid one-shot rewrites of the whole body. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
return baseMessage + buildTriggerBlock(live, trigger, context);
}
// ---------------------------------------------------------------------------
// Concurrency guard — keyed by filePath
// ---------------------------------------------------------------------------
const runningLiveNotes = new Set<string>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Run the live-note agent on a specific note.
* Called by the scheduler ('cron' | 'window'), the event processor ('event'),
* the renderer panel Run button ('manual'), or the `run-live-note-agent`
* builtin tool ('manual').
*/
export async function runLiveNoteAgent(
filePath: string,
trigger: LiveNoteTriggerType = 'manual',
context?: string,
): Promise<LiveNoteAgentResult> {
if (runningLiveNotes.has(filePath)) {
log.log(`${filePath} — skip: already running`);
return { filePath, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Already running' };
}
runningLiveNotes.add(filePath);
try {
const live = await fetchLiveNote(filePath);
if (!live) {
log.log(`${filePath} — skip: note is not live (no \`live:\` block)`);
return { filePath, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Note is not live' };
}
const bodyBefore = await readNoteBody(filePath);
const model = live.model ?? await getLiveNoteAgentModel();
const agentRun = await createRun({
agentId: 'live-note-agent',
model,
...(live.provider ? { provider: live.provider } : {}),
useCase: 'live_note_agent',
// Use the granular trigger as the analytics sub-use-case so
// dashboards can break down agent runs by what woke them up
// (manual / cron / window / event). Pass 1 routing emits the
// separate `routing` sub-use-case from routing.ts.
subUseCase: trigger,
});
log.log(`${filePath} — start trigger=${trigger} runId=${agentRun.id}`);
// Bump `lastAttemptAt` immediately (before the agent executes) so the
// scheduler's next poll suppresses duplicate firings during a slow run
// and applies a backoff after a failure. `lastRunAt` is only bumped on
// *success* below — that way failures don't lock the cycle anchor for
// cron / window triggers.
await patchLiveNote(filePath, {
lastAttemptAt: new Date().toISOString(),
lastRunId: agentRun.id,
});
await liveNoteBus.publish({
type: 'live_note_agent_start',
filePath,
trigger,
runId: agentRun.id,
});
try {
await createMessage(agentRun.id, buildMessage(filePath, live, trigger, context));
// throwOnError: surface any error event in the run's log (LLM API
// failures, tool errors, billing/credit issues) as a rejection so
// the failure branch records lastRunError. Without this the run
// can "complete" with errors silently and we'd hit the success
// branch with an empty summary, clobbering any prior lastRunError.
await waitForRunCompletion(agentRun.id, { throwOnError: true });
const summary = await extractAgentResponse(agentRun.id);
const bodyAfter = await readNoteBody(filePath);
const didUpdate = bodyAfter !== bodyBefore;
// Success — bump the cycle anchor, refresh the summary, clear any
// prior error.
await patchLiveNote(filePath, {
lastRunAt: new Date().toISOString(),
lastRunSummary: summary ?? undefined,
lastRunError: undefined,
});
log.log(`${filePath} — done action=${didUpdate ? 'replace' : 'no_update'} summary="${truncate(summary)}"`);
await liveNoteBus.publish({
type: 'live_note_agent_complete',
filePath,
runId: agentRun.id,
summary: summary ?? undefined,
});
return {
filePath,
runId: agentRun.id,
action: didUpdate ? 'replace' : 'no_update',
contentBefore: bodyBefore,
contentAfter: bodyAfter,
summary,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Failure — keep `lastRunAt` and `lastRunSummary` intact so the
// user keeps seeing the last good state. Just record the error;
// the scheduler's backoff (lastAttemptAt + 5min) prevents storming.
try {
await patchLiveNote(filePath, { lastRunError: msg });
} catch {
// Don't mask the original error if the patch itself fails.
}
log.log(`${filePath} — failed: ${truncate(msg)}`);
await liveNoteBus.publish({
type: 'live_note_agent_complete',
filePath,
runId: agentRun.id,
error: msg,
});
return { filePath, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg };
}
} finally {
runningLiveNotes.delete(filePath);
}
}

View file

@ -0,0 +1,92 @@
import { CronExpressionParser } from 'cron-parser';
import type { Triggers } from '@x/shared/dist/live-note.js';
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
export const RETRY_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
/**
* Decide whether a live note's `triggers` block has any timed sub-trigger
* (cron or window) whose cycle is currently ready to fire. Pure cycle check
* does NOT consider backoff.
*
* - Cycle accounting (cron prev-occurrence, window once-per-day) is anchored
* on `lastRunAt` which is bumped only on *successful* completions. So a
* failed run leaves the cycle unfired and this returns the matched trigger
* again on the next tick (caller is expected to gate on backoff separately).
* - `cronExpr` enforces a 2-minute grace window if the scheduled time was
* more than 2 minutes ago, it's a miss and skipped (avoids replay storms
* after the app was offline).
* - `windows` are forgiving: each window fires at most once per day per
* successful run, anywhere inside its time-of-day band. Cycles anchored at
* `startTime`. Adjacent windows sharing an endpoint (e.g. 0812 and 1215)
* each still fire on the same day.
*
* Returns the source ('cron' | 'window') or null if no cycle is ready.
*/
export function dueTimedTrigger(
triggers: Triggers | undefined,
lastRunAt: string | null,
): 'cron' | 'window' | null {
if (!triggers) return null;
if (triggers.cronExpr && isCronDue(triggers.cronExpr, lastRunAt)) return 'cron';
if (triggers.windows) {
for (const w of triggers.windows) {
if (isWindowDue(w.startTime, w.endTime, lastRunAt)) return 'window';
}
}
return null;
}
/**
* Backoff check has there been an attempt within `RETRY_BACKOFF_MS`?
* Returns the milliseconds remaining until the backoff lifts (positive) or 0
* if not in backoff. Caller logs the remaining time in human form.
*/
export function backoffRemainingMs(lastAttemptAt: string | null): number {
if (!lastAttemptAt) return 0;
const sinceAttempt = Date.now() - new Date(lastAttemptAt).getTime();
if (sinceAttempt < 0 || sinceAttempt >= RETRY_BACKOFF_MS) return 0;
return RETRY_BACKOFF_MS - sinceAttempt;
}
function isCronDue(expression: string, lastRunAt: string | null): boolean {
const now = new Date();
if (!lastRunAt) return true; // never ran — immediately due
try {
// Find the most recent occurrence at-or-before `now`, not the
// occurrence right after lastRunAt — if lastRunAt is old, that
// occurrence would be ancient too and always fall outside the
// grace window, blocking every future fire.
const interval = CronExpressionParser.parse(expression, { currentDate: now });
const prevRun = interval.prev().toDate();
// Already ran at-or-after this occurrence → skip.
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
// Within grace → fire. Outside grace → missed, skip.
return now.getTime() <= prevRun.getTime() + GRACE_MS;
} catch {
return false;
}
}
function isWindowDue(startTime: string, endTime: string, lastRunAt: string | null): boolean {
const now = new Date();
const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
if (!lastRunAt) return true;
const cycleStart = new Date(now);
cycleStart.setHours(startHour, startMin, 0, 0);
if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false;
return true;
}

View file

@ -0,0 +1,96 @@
import { PrefixLogger } from '@x/shared';
import * as workspace from '../../workspace/workspace.js';
import { fetchLiveNote } from './fileops.js';
import { runLiveNoteAgent } from './runner.js';
import { backoffRemainingMs, dueTimedTrigger } from './schedule-utils.js';
const log = new PrefixLogger('LiveNote:Scheduler');
const POLL_INTERVAL_MS = 15_000; // 15 seconds
async function listKnowledgeMarkdownFiles(): Promise<string[]> {
try {
const entries = await workspace.readdir('knowledge', { recursive: true });
return entries
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
.map(e => e.path.replace(/^knowledge\//, ''));
} catch {
return [];
}
}
function humanMs(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.round(s / 60);
return `${m}m`;
}
async function processScheduledLiveNotes(): Promise<void> {
const relativePaths = await listKnowledgeMarkdownFiles();
let liveCount = 0;
let pausedCount = 0;
let firedCount = 0;
let backoffCount = 0;
for (const relativePath of relativePaths) {
let live;
try {
live = await fetchLiveNote(relativePath);
} catch {
continue;
}
if (!live) continue;
liveCount++;
if (live.active === false) {
pausedCount++;
continue;
}
const source = dueTimedTrigger(live.triggers, live.lastRunAt ?? null);
if (!source) continue;
// Cycle is ready to fire — but check backoff before triggering. This is
// the disk-persistent backstop; the runner's in-memory concurrency
// guard covers the common in-flight case.
const backoffMs = backoffRemainingMs(live.lastAttemptAt ?? null);
if (backoffMs > 0) {
backoffCount++;
log.log(`${relativePath} — skip (matched ${source}, backoff ${humanMs(backoffMs)} remaining)`);
continue;
}
firedCount++;
log.log(`${relativePath} — firing (matched ${source})`);
runLiveNoteAgent(relativePath, source).catch(err => {
log.log(`${relativePath} — fire error: ${err instanceof Error ? err.message : String(err)}`);
});
}
// One summary line per tick — keeps logs scannable without spamming a row
// per inactive note.
if (liveCount > 0 || firedCount > 0 || backoffCount > 0) {
log.log(
`tick — scanned ${relativePaths.length} md, ${liveCount} live` +
(pausedCount > 0 ? `, ${pausedCount} paused` : '') +
(firedCount > 0 ? `, fired ${firedCount}` : '') +
(backoffCount > 0 ? `, backoff ${backoffCount}` : ''),
);
}
}
export async function init(): Promise<void> {
log.log(`starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
await processScheduledLiveNotes();
while (true) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
await processScheduledLiveNotes();
} catch (error) {
log.log(`tick error: ${error instanceof Error ? error.message : String(error)}`);
}
}
}

View file

@ -7,7 +7,7 @@ import { WorkDir } from '../config/config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { createEvent } from './track/events.js';
import { createEvent } from './live-note/events.js';
const MAX_EVENTS_IN_DIGEST = 50;
const MAX_DESCRIPTION_CHARS = 500;

View file

@ -7,7 +7,7 @@ import { WorkDir } from '../config/config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { createEvent } from './track/events.js';
import { createEvent } from './live-note/events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');

View file

@ -1,267 +0,0 @@
import z from 'zod';
import fs from 'fs/promises';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { WorkDir } from '../../config/config.js';
import { TrackSchema } from '@x/shared/dist/track.js';
import { TrackStateSchema } from './types.js';
import { withFileLock } from '../file-lock.js';
import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
// ---------------------------------------------------------------------------
// Track-array helpers (read/write the `track:` key in a parsed frontmatter)
// ---------------------------------------------------------------------------
function getTrackArray(fm: Record<string, unknown>): unknown[] {
const raw = fm.track;
return Array.isArray(raw) ? raw : [];
}
function setTrackArray(fm: Record<string, unknown>, tracks: unknown[]): Record<string, unknown> {
const next = { ...fm };
if (tracks.length === 0) {
delete next.track;
} else {
next.track = tracks;
}
return next;
}
// ---------------------------------------------------------------------------
// Read
// ---------------------------------------------------------------------------
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return [];
}
const { frontmatter } = splitFrontmatter(content);
const tracks: z.infer<typeof TrackStateSchema>[] = [];
for (const raw of getTrackArray(frontmatter)) {
const result = TrackSchema.safeParse(raw);
if (result.success) tracks.push({ track: result.data });
}
return tracks;
}
export async function fetch(filePath: string, id: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const all = await fetchAll(filePath);
return all.find(t => t.track.id === id) ?? null;
}
export async function fetchYaml(filePath: string, id: string): Promise<string | null> {
const t = await fetch(filePath, id);
if (!t) return null;
return stringifyYaml(t.track).trimEnd();
}
export async function readNoteBody(filePath: string): Promise<string> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return '';
}
return splitFrontmatter(content).body;
}
// ---------------------------------------------------------------------------
// Write
// ---------------------------------------------------------------------------
function findRawIndex(rawTracks: unknown[], id: string): number {
return rawTracks.findIndex(
(raw) => raw && typeof raw === 'object' && (raw as Record<string, unknown>).id === id,
);
}
export async function updateTrack(
filePath: string,
id: string,
updates: Partial<z.infer<typeof TrackSchema>>,
): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
const idx = findRawIndex(rawTracks, id);
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
const next = [...rawTracks];
next[idx] = { ...(rawTracks[idx] as Record<string, unknown>), ...updates };
const nextFm = setTrackArray(frontmatter, next);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
export async function replaceTrackYaml(
filePath: string,
id: string,
newYaml: string,
): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const parsed = TrackSchema.safeParse(parseYaml(newYaml));
if (!parsed.success) throw new Error(`Invalid track YAML: ${parsed.error.message}`);
if (parsed.data.id !== id) {
throw new Error(`id cannot be changed (was "${id}", got "${parsed.data.id}")`);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
const idx = findRawIndex(rawTracks, id);
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
const next = [...rawTracks];
next[idx] = parsed.data;
const nextFm = setTrackArray(frontmatter, next);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
export async function deleteTrack(filePath: string, id: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
const idx = findRawIndex(rawTracks, id);
if (idx === -1) return; // already gone
const next = [...rawTracks];
next.splice(idx, 1);
const nextFm = setTrackArray(frontmatter, next);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
/**
* Replace the note's body. Frontmatter is preserved (including the `track:`
* array). Used by the runner to commit the agent's body edits without granting
* the agent write access to its own runtime state.
*/
export async function writeNoteBody(filePath: string, newBody: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter } = splitFrontmatter(content);
await fs.writeFile(absPath(filePath), joinFrontmatter(frontmatter, newBody), 'utf-8');
});
}
// ---------------------------------------------------------------------------
// Note-level summaries (tracks-list view)
// ---------------------------------------------------------------------------
type TrackNoteSummary = {
path: string;
trackCount: number;
createdAt: string | null;
lastRunAt: string | null;
isActive: boolean;
};
async function summarizeTrackNote(
filePath: string,
tracks: z.infer<typeof TrackStateSchema>[],
): Promise<TrackNoteSummary | null> {
if (tracks.length === 0) return null;
const stats = await fs.stat(absPath(filePath));
const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs;
let latestRunAt: string | null = null;
let latestRunMs = -1;
for (const { track } of tracks) {
if (!track.lastRunAt) continue;
const candidateMs = Date.parse(track.lastRunAt);
if (Number.isNaN(candidateMs) || candidateMs <= latestRunMs) continue;
latestRunMs = candidateMs;
latestRunAt = track.lastRunAt;
}
return {
path: `knowledge/${filePath}`,
trackCount: tracks.length,
createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null,
lastRunAt: latestRunAt,
isActive: tracks.every(({ track }) => track.active !== false),
};
}
export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> {
async function walk(relativeDir = ''): Promise<string[]> {
const dirPath = absPath(relativeDir);
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const childRelPath = relativeDir
? path.posix.join(relativeDir, entry.name)
: entry.name;
if (entry.isDirectory()) {
files.push(...await walk(childRelPath));
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
files.push(childRelPath);
}
}
return files;
} catch {
return [];
}
}
const markdownFiles = await walk();
const notes = await Promise.all(markdownFiles.map(async (relativePath) => {
try {
const tracks = await fetchAll(relativePath);
return await summarizeTrackNote(relativePath, tracks);
} catch {
return null;
}
}));
return notes
.filter((note): note is TrackNoteSummary => note !== null)
.sort((a, b) => {
const aName = path.basename(a.path, '.md').toLowerCase();
const bName = path.basename(b.path, '.md').toLowerCase();
if (aName !== bName) return aName.localeCompare(bName);
return a.path.localeCompare(b.path);
});
}
export async function setNoteTracksActive(
filePath: string,
active: boolean,
): Promise<TrackNoteSummary | null> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
if (rawTracks.length === 0) return null;
const allMatch = rawTracks.every(
(raw) => raw && typeof raw === 'object'
&& ((raw as Record<string, unknown>).active !== false) === active,
);
if (!allMatch) {
const updated = rawTracks.map((raw) =>
raw && typeof raw === 'object'
? { ...(raw as Record<string, unknown>), active }
: raw,
);
const nextFm = setTrackArray(frontmatter, updated);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
}
const validated = await fetchAll(filePath);
return summarizeTrackNote(filePath, validated);
});
}

View file

@ -1,122 +0,0 @@
import { generateObject } from 'ai';
import { track, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
import { createProvider } from '../../models/models.js';
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
import { captureLlmUsage } from '../../analytics/usage.js';
const log = new PrefixLogger('TrackRouting');
const BATCH_SIZE = 20;
export interface ParsedTrack {
trackId: string;
filePath: string;
eventMatchCriteria: string;
instruction: string;
active: boolean;
}
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
You will receive an event (something that happened an email, meeting, message, etc.) and a list of tracks. Each track has:
- trackId: an identifier (only unique within its file)
- filePath: the note file the track lives in
- matchCriteria: a description of what kinds of signals are relevant to this track (collected from the track's event triggers)
Your job is to identify which tracks MIGHT be relevant to this event.
Rules:
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
- Prefer false positives over false negatives. It is much better to include a track that turns out to be irrelevant than to miss one that was relevant.
- Only exclude tracks that are CLEARLY and OBVIOUSLY irrelevant to the event.
- Do not attempt to judge whether the event contains enough information to update the track. That is handled by a later stage.
- Return an empty list only if no tracks are relevant at all.
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
async function resolveModel() {
const modelId = await getTrackBlockModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
return {
model: createProvider(config).languageModel(modelId),
modelId,
providerName: provider,
};
}
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
const trackList = batch
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n matchCriteria: ${t.eventMatchCriteria}`)
.join('\n\n');
return `## Event
Source: ${event.source}
Type: ${event.type}
Time: ${event.createdAt}
${event.payload}
## Tracks
${trackList}`;
}
function trackKey(trackId: string, filePath: string): string {
return `${filePath}::${trackId}`;
}
export async function findCandidates(
event: KnowledgeEvent,
allTracks: ParsedTrack[],
): Promise<ParsedTrack[]> {
// Short-circuit for targeted re-runs — skip LLM routing entirely
if (event.targetTrackId && event.targetFilePath) {
const target = allTracks.find(t =>
t.trackId === event.targetTrackId && t.filePath === event.targetFilePath
);
return target ? [target] : [];
}
const filtered = allTracks.filter(t =>
t.active && t.instruction && t.eventMatchCriteria
);
if (filtered.length === 0) {
log.log(`No event-eligible tracks (none with eventMatchCriteria)`);
return [];
}
log.log(`Routing event ${event.id} against ${filtered.length} track(s)`);
const { model, modelId, providerName } = await resolveModel();
const candidateKeys = new Set<string>();
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
const batch = filtered.slice(i, i + BATCH_SIZE);
try {
const result = await generateObject({
model,
system: ROUTING_SYSTEM_PROMPT,
prompt: buildRoutingPrompt(event, batch),
schema: track.Pass1OutputSchema,
});
captureLlmUsage({
useCase: 'track_block',
subUseCase: 'routing',
model: modelId,
provider: providerName,
usage: result.usage,
});
for (const c of result.object.candidates) {
candidateKeys.add(trackKey(c.trackId, c.filePath));
}
} catch (err) {
log.log(`Routing batch ${i / BATCH_SIZE} failed:`, err);
}
}
const candidates = filtered.filter(t => candidateKeys.has(trackKey(t.trackId, t.filePath)));
log.log(`Event ${event.id}: ${candidates.length} candidate(s) — ${candidates.map(c => `${c.trackId}@${c.filePath}`).join(', ') || '(none)'}`);
return candidates;
}

View file

@ -1,185 +0,0 @@
import z from 'zod';
import { fetchAll, updateTrack, readNoteBody } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { getTrackBlockModel } from '../../models/defaults.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
import { trackBus } from './bus.js';
import type { TrackStateSchema } from './types.js';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
export interface TrackUpdateResult {
trackId: string;
runId: string | null;
action: 'replace' | 'no_update';
contentBefore: string | null;
contentAfter: string | null;
summary: string | null;
error?: string;
}
// ---------------------------------------------------------------------------
// Agent run
// ---------------------------------------------------------------------------
function buildMessage(
filePath: string,
track: z.infer<typeof TrackStateSchema>,
trigger: 'manual' | 'timed' | 'event',
context?: string,
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Workspace-relative path the agent's tools (workspace-readFile,
// workspace-edit) expect. Internal fileops storage is knowledge/-relative,
// so always prefix here when handing it to the agent.
const wsPath = `knowledge/${filePath}`;
let msg = `Update track **${track.track.id}** in \`${wsPath}\`.
**Time:** ${localNow} (${tz})
**Instruction:**
${track.track.instruction}
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
if (trigger === 'event') {
const eventCriteria = (track.track.triggers ?? [])
.filter(t => t.type === 'event')
.map(t => t.matchCriteria)
.filter(Boolean);
const criteriaText = eventCriteria.length === 0
? '(none — should not happen for event-triggered runs)'
: eventCriteria.length === 1
? eventCriteria[0]
: eventCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
msg += `
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
**Event match criteria for this track:**
${criteriaText}
**Event payload:**
${context ?? '(no payload)'}
**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that should be reflected in the note.`;
} else if (context) {
msg += `\n\n**Context:**\n${context}`;
}
return msg;
}
// ---------------------------------------------------------------------------
// Concurrency guard
// ---------------------------------------------------------------------------
const runningTracks = new Set<string>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Trigger an update for a specific track.
* Can be called by any trigger system (manual, cron, event matching).
*/
export async function triggerTrackUpdate(
trackId: string,
filePath: string,
context?: string,
trigger: 'manual' | 'timed' | 'event' = 'manual',
): Promise<TrackUpdateResult> {
const key = `${trackId}:${filePath}`;
const logger = new PrefixLogger('track:runner');
logger.log('triggering track update', trackId, filePath, trigger, context);
if (runningTracks.has(key)) {
logger.log('skipping, already running');
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Already running' };
}
runningTracks.add(key);
try {
const tracks = await fetchAll(filePath);
logger.log('fetched tracks from file', tracks);
const track = tracks.find(t => t.track.id === trackId);
if (!track) {
logger.log('track not found', trackId, filePath, trigger, context);
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
}
const bodyBefore = await readNoteBody(filePath);
const model = track.track.model ?? await getTrackBlockModel();
const agentRun = await createRun({
agentId: 'track-run',
model,
...(track.track.provider ? { provider: track.track.provider } : {}),
useCase: 'track_block',
subUseCase: 'run',
});
// Set lastRunAt and lastRunId immediately (before agent executes) so
// the scheduler's next poll won't re-trigger this track.
await updateTrack(filePath, trackId, {
lastRunAt: new Date().toISOString(),
lastRunId: agentRun.id,
});
await trackBus.publish({
type: 'track_run_start',
trackId,
filePath,
trigger,
runId: agentRun.id,
});
try {
await createMessage(agentRun.id, buildMessage(filePath, track, trigger, context));
await waitForRunCompletion(agentRun.id);
const summary = await extractAgentResponse(agentRun.id);
const bodyAfter = await readNoteBody(filePath);
const didUpdate = bodyAfter !== bodyBefore;
// Patch summary into frontmatter on completion.
await updateTrack(filePath, trackId, {
lastRunSummary: summary ?? undefined,
});
await trackBus.publish({
type: 'track_run_complete',
trackId,
filePath,
runId: agentRun.id,
summary: summary ?? undefined,
});
return {
trackId,
runId: agentRun.id,
action: didUpdate ? 'replace' : 'no_update',
contentBefore: bodyBefore,
contentAfter: bodyAfter,
summary,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await trackBus.publish({
type: 'track_run_complete',
trackId,
filePath,
runId: agentRun.id,
error: msg,
});
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg };
}
} finally {
runningTracks.delete(key);
}
}

View file

@ -1,74 +0,0 @@
import { CronExpressionParser } from 'cron-parser';
import type { Trigger } from '@x/shared/dist/track.js';
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
/** Subset of Trigger that fires on a clock — the schedulable types. */
export type TimedTrigger = Extract<Trigger, { type: 'cron' | 'window' | 'once' }>;
/**
* Determine if a timed trigger is due to fire.
*
* - `cron` and `once` enforce a 2-minute grace window if the scheduled time
* was more than 2 minutes ago, it's considered a miss and skipped (avoids
* replay storms after the app was offline at the trigger time).
* - `window` is forgiving: it fires at most once per day, anywhere inside the
* configured time-of-day band. The day's cycle is anchored at `startTime`
* once a fire lands at-or-after today's startTime, the trigger is done for
* the day. Use this for tracks that should "happen sometime in the morning"
* rather than "at exactly 8:00am."
*/
export function isTriggerDue(schedule: TimedTrigger, lastRunAt: string | null): boolean {
const now = new Date();
switch (schedule.type) {
case 'cron': {
if (!lastRunAt) return true; // Never ran — immediately due
try {
// Find the MOST RECENT occurrence at-or-before `now`, not the
// occurrence right after lastRunAt. If lastRunAt is old, that
// occurrence would be ancient too and always fall outside the
// grace window, blocking every future fire.
const interval = CronExpressionParser.parse(schedule.expression, {
currentDate: now,
});
const prevRun = interval.prev().toDate();
// Already ran at-or-after this occurrence → skip.
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
// Within grace → fire. Outside grace → missed, skip.
return now.getTime() <= prevRun.getTime() + GRACE_MS;
} catch {
return false;
}
}
case 'window': {
// Must be inside the time-of-day band.
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
if (!lastRunAt) return true;
// Daily cycle anchored at startTime. If we've already fired
// strictly after today's startTime, skip until tomorrow. The
// strict comparison (>, not >=) means a fire happening exactly
// at a window boundary belongs to the earlier window — so two
// adjacent windows sharing an endpoint (e.g. 0812 and 1215)
// each still get their own fire on the same day.
const cycleStart = new Date(now);
cycleStart.setHours(startHour, startMin, 0, 0);
if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false;
return true;
}
case 'once': {
if (lastRunAt) return false; // Already ran
const runAt = new Date(schedule.runAt);
return now >= runAt && now.getTime() <= runAt.getTime() + GRACE_MS;
}
}
}

View file

@ -1,72 +0,0 @@
import { PrefixLogger } from '@x/shared';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
import { triggerTrackUpdate } from './runner.js';
import { isTriggerDue, type TimedTrigger } from './schedule-utils.js';
const log = new PrefixLogger('TrackScheduler');
const POLL_INTERVAL_MS = 15_000; // 15 seconds
async function listKnowledgeMarkdownFiles(): Promise<string[]> {
try {
const entries = await workspace.readdir('knowledge', { recursive: true });
return entries
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
.map(e => e.path.replace(/^knowledge\//, ''));
} catch {
return [];
}
}
async function processScheduledTracks(): Promise<void> {
const relativePaths = await listKnowledgeMarkdownFiles();
log.log(`Scanning ${relativePaths.length} markdown files`);
for (const relativePath of relativePaths) {
let tracks;
try {
tracks = await fetchAll(relativePath);
} catch {
continue;
}
for (const trackState of tracks) {
const { track } = trackState;
if (!track.active) continue;
if (!track.triggers || track.triggers.length === 0) continue;
const timed: TimedTrigger[] = track.triggers.filter(
(t): t is TimedTrigger => t.type !== 'event',
);
if (timed.length === 0) continue;
const dueTrigger = timed.find(t => isTriggerDue(t, track.lastRunAt ?? null));
if (!dueTrigger) {
log.log(`Track "${track.id}" in ${relativePath}: ${timed.length} timed trigger(s), none due`);
continue;
}
log.log(`Triggering "${track.id}" in ${relativePath} (matched ${dueTrigger.type})`);
triggerTrackUpdate(track.id, relativePath, undefined, 'timed').catch(err => {
log.log(`Error running ${track.id}:`, err);
});
}
}
}
export async function init(): Promise<void> {
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
// Initial run
await processScheduledTracks();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
await processScheduledTracks();
} catch (error) {
log.log('Error in main loop:', error);
}
}
}

View file

@ -1,6 +0,0 @@
import z from "zod";
import { TrackSchema } from "@x/shared/dist/track.js";
export const TrackStateSchema = z.object({
track: TrackSchema,
});

View file

@ -7,7 +7,7 @@ import container from "../di/container.js";
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview";
const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "anthropic/claude-haiku-4.5";
/**
* The single source of truth for "what model+provider should we use when
@ -66,14 +66,14 @@ export async function getKgModel(): Promise<string> {
}
/**
* Model used by track-block runner + routing classifier.
* Signed-in: curated default. BYOK: user override (`trackBlockModel`) or
* Model used by the live-note agent + routing classifier.
* Signed-in: curated default. BYOK: user override (`liveNoteAgentModel`) or
* assistant model.
*/
export async function getTrackBlockModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL;
export async function getLiveNoteAgentModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_LIVE_NOTE_AGENT_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.trackBlockModel ?? cfg.model;
return cfg.liveNoteAgentModel ?? cfg.model;
}
/**

View file

@ -52,7 +52,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
models: config.models,
knowledgeGraphModel: config.knowledgeGraphModel,
meetingNotesModel: config.meetingNotesModel,
trackBlockModel: config.trackBlockModel,
liveNoteAgentModel: config.liveNoteAgentModel,
};
const toWrite = { ...config, providers: existingProviders };

View file

@ -21,6 +21,13 @@ import { getDefaultModelAndProvider } from "../models/defaults.js";
const LegacyStartEvent = StartEvent.extend({
model: z.string().optional(),
provider: z.string().optional(),
// Pre-rename run files carry `useCase: "track_block"`. Map it to its
// canonical successor on read so the strict downstream types never see
// the old value. Read-only — writes always use the current enum.
useCase: z.preprocess(
(v) => (v === 'track_block' ? 'live_note_agent' : v),
StartEvent.shape.useCase,
),
});
const ReadRunEvent = RunEvent.or(LegacyStartEvent);

View file

@ -9,7 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
export * as serviceEvents from './service-events.js'
export * as inlineTask from './inline-task.js';
export * as blocks from './blocks.js';
export * as track from './track.js';
export * as liveNote from './live-note.js';
export * as promptBlock from './prompt-block.js';
export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';

View file

@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { TrackEvent } from './track.js';
import { LiveNoteAgentEvent, LiveNoteSchema } from './live-note.js';
import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
@ -214,8 +214,8 @@ const ipcSchemas = {
req: ServiceEvent,
res: z.null(),
},
'tracks:events': {
req: TrackEvent,
'live-note-agent:events': {
req: LiveNoteAgentEvent,
res: z.null(),
},
'models:list': {
@ -611,93 +611,83 @@ const ipcSchemas = {
response: z.string().nullable(),
}),
},
// Track channels
'track:run': {
// Live-note channels
'live-note:run': {
req: z.object({
filePath: z.string(),
context: z.string().optional(),
}),
res: z.object({
success: z.boolean(),
runId: z.string().nullable().optional(),
action: z.enum(['replace', 'no_update']).optional(),
summary: z.string().nullable().optional(),
contentAfter: z.string().nullable().optional(),
error: z.string().optional(),
}),
},
'live-note:get': {
req: z.object({
id: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
summary: z.string().optional(),
// Fresh, authoritative live-note object from frontmatter, or null when
// the note is passive. Renderer should use this for display/edit —
// never a stale cached copy.
live: LiveNoteSchema.nullable().optional(),
error: z.string().optional(),
}),
},
'track:get': {
'live-note:set': {
req: z.object({
id: z.string(),
filePath: z.string(),
live: LiveNoteSchema,
}),
res: z.object({
success: z.boolean(),
// Fresh, authoritative YAML of the track from frontmatter.
// Renderer should use this for display/edit — never a stale cached copy.
yaml: z.string().optional(),
live: LiveNoteSchema.nullable().optional(),
error: z.string().optional(),
}),
},
'track:update': {
'live-note:setActive': {
req: z.object({
id: z.string(),
filePath: z.string(),
// Partial Track updates — merged into the entry on disk.
// Backend is the sole writer; avoids races with scheduler/runner writes.
updates: z.record(z.string(), z.unknown()),
}),
res: z.object({
success: z.boolean(),
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:replaceYaml': {
req: z.object({
id: z.string(),
filePath: z.string(),
yaml: z.string(),
}),
res: z.object({
success: z.boolean(),
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:delete': {
req: z.object({
id: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'track:setNoteActive': {
req: z.object({
path: RelPath,
active: z.boolean(),
}),
res: z.object({
success: z.boolean(),
note: z.object({
path: RelPath,
trackCount: z.number().int().positive(),
createdAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
isActive: z.boolean(),
}).optional(),
live: LiveNoteSchema.nullable().optional(),
error: z.string().optional(),
}),
},
'track:listNotes': {
'live-note:delete': {
req: z.object({
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'live-note:stop': {
req: z.object({
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'live-note:listNotes': {
req: z.null(),
res: z.object({
notes: z.array(z.object({
path: RelPath,
trackCount: z.number().int().positive(),
createdAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
isActive: z.boolean(),
objective: z.string(),
})),
}),
},

View file

@ -0,0 +1,133 @@
import z from 'zod';
// ---------------------------------------------------------------------------
// Live notes
// ---------------------------------------------------------------------------
//
// A live note is a markdown file whose body is kept current by a background
// agent. The user expresses intent via the `live:` block in the note's YAML
// frontmatter:
//
// ---
// live:
// objective: |
// Keep this note current with major developments in AI coding agents.
// active: true
// triggers:
// cronExpr: "0 * * * *"
// windows:
// - { startTime: "09:00", endTime: "12:00" }
// eventMatchCriteria: |
// News, tweets, or emails about AI coding agents.
// model: anthropic/claude-haiku-4.5
// provider: anthropic
// ---
//
// A note with no `live:` key is passive. Manual-only is `live:` with no
// `triggers` (or all three trigger fields absent).
// ---------------------------------------------------------------------------
// Hand-written types — single source of truth. Zod schemas below validate at
// runtime *against* these types via `satisfies`. We don't `z.infer` here
// because the resulting types pass through Zod's generic machinery and can
// resolve to `any` once the dist .d.ts is consumed downstream (project-
// references build, mismatched zod resolution, etc.). Plain types are stable.
export type TriggerWindow = {
startTime: string;
endTime: string;
};
export type Triggers = {
cronExpr?: string;
windows?: TriggerWindow[];
eventMatchCriteria?: string;
};
export type LiveNote = {
objective: string;
active: boolean;
triggers?: Triggers;
model?: string;
provider?: string;
lastAttemptAt?: string;
lastRunAt?: string;
lastRunId?: string;
lastRunSummary?: string;
lastRunError?: string;
};
const TriggerWindowSchema = z.object({
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the agent fires after this time, the window is done for the day.'),
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
});
export const TriggersSchema = z.object({
cronExpr: z.string().optional().describe('5-field cron expression (e.g. "0 * * * *"). Always quote when written by hand. Omit to skip cron-driven runs.'),
windows: z.array(TriggerWindowSchema).optional().describe('A list of daily time-of-day bands. The agent fires once per day per window, anywhere inside the band — useful for "sometime in the morning" rather than an exact clock time. Omit to skip window-driven runs.'),
eventMatchCriteria: z.string().optional().describe('Natural-language description of which incoming events (emails, calendar changes, etc.) should wake this note. Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload. Omit to skip event-driven runs.'),
}).describe('When the live-note agent fires. Each field is optional — omit any/all. The whole `triggers` object is also optional; absent (or fully empty) means manual-only.');
export const LiveNoteSchema = z.object({
objective: z.string().min(1).describe('A persistent intent in the user\'s words — what should this note keep being? E.g. "Keep this note updated with important developments in AI coding agents." The agent re-reads the objective on every run and is responsible for maintaining the entire body to satisfy it.'),
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-note LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS note. The global default already picks a tuned model for live-note runs; overriding usually makes things worse, not better.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-note provider name override (e.g. "openai", "anthropic"). Almost always omitted; the global default flows through correctly.'),
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped only when an agent run *succeeds*; used as the cycle anchor for cron / window triggers and as the freshness timestamp shown in the UI.'),
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the live-note:stop handler.'),
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself. Set on success; not overwritten on failure so the user keeps seeing the last good summary.'),
lastRunError: z.string().optional().describe('Runtime-managed — never write this yourself. Set on a failed run; cleared on the next successful run.'),
});
// ---------------------------------------------------------------------------
// Knowledge events (live-note event-driven pipeline)
// ---------------------------------------------------------------------------
export const KnowledgeEventSchema = z.object({
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
type: z.string().describe('Event type (e.g. "email.synced")'),
createdAt: z.string().describe('ISO timestamp when the event was produced'),
payload: z.string().describe('Human-readable event body, usually markdown'),
targetFilePath: z.string().optional().describe('If set, skip routing and target this note directly (used for re-runs)'),
// Enriched on move from pending/ to done/
processedAt: z.string().optional(),
candidateFilePaths: z.array(z.string()).optional(),
runIds: z.array(z.string()).optional(),
error: z.string().optional(),
});
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
export const Pass1OutputSchema = z.object({
filePaths: z.array(z.string()).describe('Note file paths whose objective and event-match criteria suggest the event might be relevant. The agent does Pass 2 on the event payload before editing.'),
});
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
// ---------------------------------------------------------------------------
// Bus events
// ---------------------------------------------------------------------------
export const LiveNoteTrigger = z.enum(['manual', 'cron', 'window', 'event']);
export type LiveNoteTriggerType = z.infer<typeof LiveNoteTrigger>;
export const LiveNoteAgentStartEvent = z.object({
type: z.literal('live_note_agent_start'),
filePath: z.string(),
trigger: LiveNoteTrigger,
runId: z.string(),
});
export const LiveNoteAgentCompleteEvent = z.object({
type: z.literal('live_note_agent_complete'),
filePath: z.string(),
runId: z.string(),
error: z.string().optional(),
summary: z.string().optional(),
});
export const LiveNoteAgentEvent = z.union([LiveNoteAgentStartEvent, LiveNoteAgentCompleteEvent]);
export type LiveNoteAgentEventType = z.infer<typeof LiveNoteAgentEvent>;

View file

@ -22,5 +22,5 @@ export const LlmModelConfig = z.object({
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
trackBlockModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
});

View file

@ -25,7 +25,7 @@ export const StartEvent = BaseRunEvent.extend({
// run files written before these fields existed still parse cleanly.
useCase: z.enum([
"copilot_chat",
"track_block",
"live_note_agent",
"meeting_note",
"knowledge_sync",
]).optional(),
@ -137,7 +137,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
export const UseCase = z.enum([
"copilot_chat",
"track_block",
"live_note_agent",
"meeting_note",
"knowledge_sync",
]);

View file

@ -1,111 +0,0 @@
import z from 'zod';
// ---------------------------------------------------------------------------
// Triggers — when a track fires
// ---------------------------------------------------------------------------
//
// A track can carry zero or more triggers under the `triggers:` key.
// Each trigger is one of:
// - cron: exact time, recurring
// - window: once per day, anywhere inside a time-of-day band
// - once: one-shot at a future time
// - event: driven by incoming signals (emails, calendar events, etc.)
//
// A track can have multiple triggers — e.g. a daily cron trigger AND an event
// trigger. Omit `triggers` (or pass an empty array) for a manual-only track.
// ---------------------------------------------------------------------------
export const TriggerSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('cron').describe('Fires at exact cron times'),
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
}).describe('Recurring at exact times'),
z.object({
type: z.literal('window').describe('Fires once per day, anywhere inside a time-of-day band'),
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the track fires after this time, it won\'t fire again until the next day.'),
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
}).describe('Recurring within a daily time-of-day window'),
z.object({
type: z.literal('once').describe('Fires once and never again'),
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
}).describe('One-shot future run'),
z.object({
type: z.literal('event').describe('Fires when a matching event arrives'),
matchCriteria: z.string().describe('Describe the kinds of events that should consider this track for an update (e.g. "Emails about Q3 planning"). Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload.'),
}).describe('Event-driven'),
]);
export type Trigger = z.infer<typeof TriggerSchema>;
// ---------------------------------------------------------------------------
// Track entity
// ---------------------------------------------------------------------------
export const TrackSchema = z.object({
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
active: z.boolean().default(true).describe('Set false to pause without deleting'),
triggers: z.array(TriggerSchema).optional().describe('When this track fires. A track can have multiple triggers — e.g. an hourly cron AND an event trigger. Omit (or use an empty array) for a manual-only track.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
icon: z.string().optional().describe('Lucide icon name for status display (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'),
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
});
// ---------------------------------------------------------------------------
// Knowledge events (event-driven track triggering pipeline)
// ---------------------------------------------------------------------------
export const KnowledgeEventSchema = z.object({
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
type: z.string().describe('Event type (e.g. "email.synced")'),
createdAt: z.string().describe('ISO timestamp when the event was produced'),
payload: z.string().describe('Human-readable event body, usually markdown'),
targetTrackId: z.string().optional().describe('If set, skip routing and target this track directly (used for re-runs)'),
targetFilePath: z.string().optional(),
// Enriched on move from pending/ to done/
processedAt: z.string().optional(),
candidates: z.array(z.object({
trackId: z.string(),
filePath: z.string(),
})).optional(),
runIds: z.array(z.string()).optional(),
error: z.string().optional(),
});
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
export const Pass1OutputSchema = z.object({
candidates: z.array(z.object({
trackId: z.string().describe('The track identifier'),
filePath: z.string().describe('The note file path the track lives in'),
})).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'),
});
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
// Track bus events
export const TrackRunStartEvent = z.object({
type: z.literal('track_run_start'),
trackId: z.string(),
filePath: z.string(),
trigger: z.enum(['timed', 'manual', 'event']),
runId: z.string(),
});
export const TrackRunCompleteEvent = z.object({
type: z.literal('track_run_complete'),
trackId: z.string(),
filePath: z.string(),
runId: z.string(),
error: z.string().optional(),
summary: z.string().optional(),
});
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
export type Track = z.infer<typeof TrackSchema>;
export type TrackEventType = z.infer<typeof TrackEvent>;