Add tracks — auto-updating note blocks with scheduled and event-driven triggers

Track blocks are YAML-fenced sections embedded in markdown notes whose output
is rewritten by a background agent. Three trigger types: manual (Run button or
Copilot), scheduled (cron / window / once with a 2 min grace window), and
event-driven (Gmail/Calendar sync events routed via an LLM classifier with a
second-pass agent decision). Output lives between <!--track-target:ID-->
comment markers that render as editable content in the Tiptap editor so users
can read and extend AI-generated content inline.

Core:
- Schedule and event pipelines run as independent polling loops (15s / 5s),
  both calling the same triggerTrackUpdate orchestrator. Events are FIFO via
  monotonic IDs; a per-track Set guards against duplicate runs.
- Track-run agent builds three message variants (manual/timed/event) — the
  event variant includes a Pass 2 directive to skip updates on false positives
  flagged by the liberal Pass 1 router.
- IPC surface: track:run/get/update/replaceYaml/delete plus tracks:events
  forward of the pub-sub bus to the renderer.
- Gmail emits per-thread events; Calendar bundles a digest per sync.

Copilot:
- New `tracks` skill (auto-generated canonical schema from Zod via
  z.toJSONSchema) teaches block creation, editing, and proactive suggestion.
- `run-track-block` tool with optional `context` parameter for backfills
  (e.g. seeding a new email-tracking block from existing synced emails).

Renderer:
- Tiptap chip (display-only) opens a rich modal with tabs, toggle, schedule
  details, raw YAML editor, and confirm-to-delete. All mutations go through
  IPC so the backend stays the single writer.
- Target regions use two atom marker nodes (open/close) around real editable
  content — custom blocks render natively, users can add their own notes.
- "Edit with Copilot" seeds a chat session with the note attached.

Docs: apps/x/TRACKS.md covers product flows, technical pipeline, and a
catalog of every LLM prompt involved with file+line pointers.
This commit is contained in:
Ramnique Singh 2026-04-14 13:51:45 +05:30 committed by GitHub
parent ab0147d475
commit e2c13f0f6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 3405 additions and 2 deletions

View file

@ -102,6 +102,14 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca
| Workspace config | `apps/x/pnpm-workspace.yaml` | | Workspace config | `apps/x/pnpm-workspace.yaml` |
| Root scripts | `apps/x/package.json` | | Root scripts | `apps/x/package.json` |
## Feature Deep-Dives
Long-form docs for specific features. Read the relevant file before making changes in that area — it has the full product flow, technical flows, and (where applicable) a catalog of the LLM prompts involved with exact file:line pointers.
| Feature | Doc |
|---------|-----|
| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
## Common Tasks ## Common Tasks
### LLM configuration (single provider) ### LLM configuration (single provider)

343
apps/x/TRACKS.md Normal file
View file

@ -0,0 +1,343 @@
# Track Blocks
> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand.
A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary.
**Example** (a Chicago-time track refreshed hourly):
~~~markdown
```track
trackId: chicago-time
instruction: Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
expression: "0 * * * *"
```
<!--track-target:chicago-time-->
2:30 PM, Central Time
<!--/track-target:chicago-time-->
~~~
## Table of Contents
1. [Product Overview](#product-overview)
2. [Architecture at a Glance](#architecture-at-a-glance)
3. [Technical Flows](#technical-flows)
4. [Schema Reference](#schema-reference)
5. [Prompts Catalog](#prompts-catalog)
6. [File Map](#file-map)
7. [Known Follow-ups](#known-follow-ups)
---
## Product Overview
### Trigger types
A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track.
| Trigger | When it fires | How to express it |
|---|---|---|
| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset |
| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` |
| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` |
| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` |
| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` |
Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals.
### Creating a track
Three paths, all produce identical on-disk YAML:
1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension.
2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`.
3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name.
### Viewing and managing a track
The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running.
Clicking the chip opens the **track modal**, where everything happens:
- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`).
- **Tabs***What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata).
- **Advanced** — expandable raw-YAML editor for power users.
- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region.
- **Footer***Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately).
Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`.
### What Copilot can do
- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`).
- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event.
- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`.
- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill.
### After a run
- The **target region** (between `<!--track-target:ID-->` markers) is rewritten by the track-run agent using the `update-track-content` tool.
- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML.
- The chip pulses while running, then displays the latest `lastRunAt`.
- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook.
---
## Architecture at a Glance
```
Editor chip (display-only) ──click──► TrackModal (React)
├──► IPC: track:get / update /
│ replaceYaml / delete / run
Backend (main process)
├─ Scheduler loop (15 s) ──┐
├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent
└─ Copilot tool run-track-block ──┘ │
update-track-content tool
target region rewritten on disk
```
**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields.
**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context.
---
## Technical Flows
### 4.1 Scheduling (cron / window / once)
- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`.
- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed.
- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
### 4.2 Event pipeline
**Producers** — any data source that should feed tracks emits events:
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`.
**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`.
3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below).
4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/<id>.json`, unlink from `pending/`.
**Pass 1 routing** (`routing.ts:73+ findCandidates`):
- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
- Filter to `active && instruction && eventMatchCriteria` tracks.
- Batches of `BATCH_SIZE = 20`.
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema``candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file.
- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config.
**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region.
### 4.3 Run flow (`triggerTrackUpdate`)
Module: `packages/core/src/knowledge/track/runner.ts`.
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`.
3. **Create agent run**`createRun({ agentId: 'track-run' })`.
4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set.
5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive.
7. **Wait for completion**`waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`.
9. **Store `lastRunSummary`** via `updateTrackBlock`.
10. **Emit `track_run_complete`** with `summary` or `error`.
11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`.
### 4.4 IPC surface
| Channel | Caller → handler | Purpose |
|---|---|---|
| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` |
| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML |
| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML |
| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region |
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook |
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`.
### 4.5 Renderer integration
- **Chip**`apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save.
- **Modal**`apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called.
- **Status hook**`apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state.
- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file.
### 4.6 Copilot skill integration
- **Skill content**`packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called.
- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync.
- **Skill registration**`packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array).
- **Loading trigger**`packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests.
- **Builtin tools**`packages/core/src/application/lib/builtin-tools.ts`:
- `update-track-content` — low-level: rewrite the target region between `<!--track-target:ID-->` markers. Used mainly by the track-run agent.
- `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`).
### 4.7 Concurrency & FIFO guarantees
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC.
- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file.
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too.
- **No retry storms**`lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point.
---
## Schema Reference
All canonical schemas live in `packages/shared/src/track-block.ts`:
- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`.
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`.
- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`.
Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth.
---
## Prompts Catalog
Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`).
### 1. Routing system prompt (Pass 1 classifier)
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them.
- **File**: `packages/core/src/knowledge/track/routing.ts:2237` (`ROUTING_SYSTEM_PROMPT`).
- **Inputs**: none interpolated — constant system prompt.
- **Output**: structured `Pass1OutputSchema``{ candidates: { trackId, filePath }[] }`.
- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`.
### 2. Routing user prompt template
- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt.
- **File**: `packages/core/src/knowledge/track/routing.ts:5166` (`buildRoutingPrompt`).
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`).
- **Output**: plain text, two sections — `## Event` and `## Track Blocks`.
- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below).
### 3. Track-run agent instructions
- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path.
- **File**: `packages/core/src/knowledge/track/run-agent.ts:650` (`TRACK_RUN_INSTRUCTIONS`).
- **Inputs**: `${WorkDir}` template literal (substituted at module load).
- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`.
- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
### 4. Track-run agent message (`buildMessage`)
- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`.
- **File**: `packages/core/src/knowledge/track/runner.ts:2362`.
- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`.
- **Output**: free-form — the agent decides whether to call `update-track-content`.
Three branches:
- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills.
- **`timed`** — same as `manual`. Called by the scheduler with no `context`.
- **`event`** — adds a **Pass 2 decision block** (lines 4556). Quoted verbatim:
> **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
>
> **Event match criteria for this track:**
>
> **Event payload:**
>
> **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track.
### 5. Tracks skill (Copilot-facing)
- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context.
- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant.
- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically.
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires.
- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`.
- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template.
### 6. Copilot trigger paragraph
- **Purpose**: tells Copilot *when* to load the `tracks` skill.
- **File**: `packages/core/src/application/assistant/instructions.ts:73`.
- **Inputs**: none; static prose.
- **Output**: part of the baseline Copilot system prompt.
- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh.
### 7. `run-track-block` tool — `context` parameter description
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema.
- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt).
- **Inputs**: free-form string from Copilot.
- **Output**: flows into `triggerTrackUpdate(..., 'manual')``buildMessage` → appended as `**Context:**` in the agent message.
- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`.
### 8. Calendar sync digest (event payload template)
- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`.
- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126.
- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync.
- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars.
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look.
---
## File Map
| Purpose | File |
|---|---|
| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` |
| IPC channel schemas | `packages/shared/src/ipc.ts` |
| IPC handlers (main process) | `apps/main/src/ipc.ts` |
| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` |
| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` |
| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` |
| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` |
| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` |
| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` |
| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` |
| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` |
| Track state type | `packages/core/src/knowledge/track/types.ts` |
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
| Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` |
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` |
| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` |
| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` |
| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` |
| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` |
| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` |
| Main process startup (schedulers & processors) | `apps/main/src/main.ts` |
---
## Known Follow-ups
- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields.
- **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save.
- **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor).
- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow.

View file

@ -44,6 +44,14 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.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 {
fetchYaml,
updateTrackBlock,
replaceTrackBlockYaml,
deleteTrackBlock,
} from '@x/core/dist/knowledge/track/fileops.js';
/** /**
* Convert markdown to a styled HTML document for PDF/DOCX export. * Convert markdown to a styled HTML document for PDF/DOCX export.
@ -362,6 +370,19 @@ export async function startServicesWatcher(): Promise<void> {
}); });
} }
let tracksWatcher: (() => void) | null = null;
export function startTracksWatcher(): void {
if (tracksWatcher) return;
tracksWatcher = trackBus.subscribe((event) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('tracks:events', event);
}
}
});
}
export function stopRunsWatcher(): void { export function stopRunsWatcher(): void {
if (runsWatcher) { if (runsWatcher) {
runsWatcher(); runsWatcher();
@ -758,6 +779,48 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => { 'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text); return voice.synthesizeSpeech(args.text);
}, },
// Track handlers
'track:run': async (_event, args) => {
const result = await triggerTrackUpdate(args.trackId, args.filePath);
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
},
'track:get': async (_event, args) => {
try {
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track not found' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:update': async (_event, args) => {
try {
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track vanished after update' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:replaceYaml': async (_event, args) => {
try {
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
const yaml = await fetchYaml(args.filePath, args.trackId);
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
return { success: true, yaml };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:delete': async (_event, args) => {
try {
await deleteTrackBlock(args.filePath, args.trackId);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
// Billing handler // Billing handler
'billing:getInfo': async () => { 'billing:getInfo': async () => {
return await getBillingInfo(); return await getBillingInfo();

View file

@ -4,6 +4,7 @@ import {
setupIpcHandlers, setupIpcHandlers,
startRunsWatcher, startRunsWatcher,
startServicesWatcher, startServicesWatcher,
startTracksWatcher,
startWorkspaceWatcher, startWorkspaceWatcher,
stopRunsWatcher, stopRunsWatcher,
stopServicesWatcher, stopServicesWatcher,
@ -22,6 +23,9 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.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 initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup"; import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process"; import { execSync, exec, execFileSync } from "node:child_process";
@ -228,6 +232,15 @@ app.whenReady().then(async () => {
// start services watcher // start services watcher
startServicesWatcher(); startServicesWatcher();
// start tracks watcher
startTracksWatcher();
// start track scheduler (cron/window/once)
initTrackScheduler();
// start track event processor (consumes events/pending/, triggers matching tracks)
initTrackEventProcessor();
// start gmail sync // start gmail sync
initGmailSync(); initGmailSync();

View file

@ -55,6 +55,7 @@
"tiptap-markdown": "^0.9.0", "tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"yaml": "^2.8.2",
"zod": "^4.2.1" "zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {

View file

@ -55,6 +55,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { OnboardingModal } from '@/components/onboarding' import { OnboardingModal } from '@/components/onboarding'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackModal } from '@/components/track-modal'
import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { VersionHistoryPanel } from '@/components/version-history-panel' import { VersionHistoryPanel } from '@/components/version-history-panel'
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
@ -2687,6 +2688,27 @@ function App() {
setPendingPaletteSubmit(null) setPendingPaletteSubmit(null)
}, [pendingPaletteSubmit]) }, [pendingPaletteSubmit])
// Listener for track-block "Edit with Copilot" events
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
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
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.`,
{ path: filePath, displayName },
)
}
window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
}, [submitFromPalette])
const toggleKnowledgePane = useCallback(() => { const toggleKnowledgePane = useCallback(() => {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setIsChatSidebarOpen(prev => !prev) setIsChatSidebarOpen(prev => !prev)
@ -4560,6 +4582,7 @@ function App() {
/> />
</SidebarSectionProvider> </SidebarSectionProvider>
<Toaster /> <Toaster />
<TrackModal />
<OnboardingModal <OnboardingModal
open={showOnboarding} open={showOnboarding}
onComplete={handleOnboardingComplete} onComplete={handleOnboardingComplete}

View file

@ -9,6 +9,8 @@ import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item' import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block' import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-block'
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block' import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block' import { EmbedBlockExtension } from '@/extensions/embed-block'
import { ChartBlockExtension } from '@/extensions/chart-block' import { ChartBlockExtension } from '@/extensions/chart-block'
@ -42,6 +44,31 @@ function preprocessMarkdown(markdown: string): string {
}) })
} }
// Convert track-target open/close HTML comment markers into placeholder divs
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
// nodes. Content *between* the markers is left untouched — tiptap-markdown
// parses it naturally as whatever it is (paragraphs, lists, custom-block
// fences, etc.), all rendered live by the existing extension set.
//
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
// line until a blank line terminates it, and markdown inline rules (bold,
// italics, links) don't apply inside the block. Without surrounding blank
// lines, the line right after our placeholder div gets absorbed as HTML and
// its markdown is not parsed. We consume any adjacent newlines in the match
// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on
// its own line.
function preprocessTrackTargets(md: string): string {
return md
.replace(
/\n?<!--track-target:([^\s>]+)-->\n?/g,
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
)
.replace(
/\n?<!--\/track-target:([^\s>]+)-->\n?/g,
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
)
}
// Post-process to clean up any zero-width spaces in the output // Post-process to clean up any zero-width spaces in the output
function postprocessMarkdown(markdown: string): string { function postprocessMarkdown(markdown: string): string {
// Remove lines that contain only the zero-width space marker // Remove lines that contain only the zero-width space marker
@ -140,6 +167,12 @@ function blockToMarkdown(node: JsonNode): string {
return serializeList(node, 0).join('\n') return serializeList(node, 0).join('\n')
case 'taskBlock': case 'taskBlock':
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```' return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'trackBlock':
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackTargetOpen':
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'trackTargetClose':
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'imageBlock': case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```' return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock': case 'embedBlock':
@ -638,6 +671,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}), }),
ImageUploadPlaceholderExtension, ImageUploadPlaceholderExtension,
TaskBlockExtension, TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension, ImageBlockExtension,
EmbedBlockExtension, EmbedBlockExtension,
ChartBlockExtension, ChartBlockExtension,
@ -1032,8 +1068,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) { if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
isInternalUpdate.current = true isInternalUpdate.current = true
// Pre-process to preserve blank lines // Pre-process to preserve blank lines, then wrap track-target comment
const preprocessed = preprocessMarkdown(content) // regions into placeholder divs so TrackTargetExtension can pick them up.
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
// Treat tab-open content as baseline: do not add hydration to undo history. // Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false isInternalUpdate.current = false

View file

@ -0,0 +1,522 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { z } from 'zod'
import '@/styles/track-modal.css'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
Trash2, ChevronDown, ChevronUp,
} from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { Streamdown } from 'streamdown'
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
import type { OpenTrackModalDetail } from '@/extensions/track-block'
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
// ---------------------------------------------------------------------------
// Schedule helpers
// ---------------------------------------------------------------------------
const CRON_PHRASES: Record<string, string> = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Hourly',
'0 */2 * * *': 'Every 2 hours',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 8 * * *': 'Daily at 8 AM',
'0 9 * * *': 'Daily at 9 AM',
'0 12 * * *': 'Daily at noon',
'0 18 * * *': 'Daily at 6 PM',
'0 9 * * 1-5': 'Weekdays at 9 AM',
'0 17 * * 1-5': 'Weekdays at 5 PM',
'0 0 * * 0': 'Sundays at midnight',
'0 0 * * 1': 'Mondays at midnight',
'0 0 1 * *': 'First of each month',
}
function describeCron(expr: string): string {
return CRON_PHRASES[expr.trim()] ?? expr
}
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
if (schedule.type === 'once') {
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
}
if (schedule.type === 'cron') {
return { icon: 'timer', text: describeCron(schedule.expression) }
}
if (schedule.type === 'window') {
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}${schedule.endTime}` }
}
return { icon: 'calendar', text: 'Scheduled' }
}
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
if (icon === 'timer') return <Clock size={size} />
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
return <Zap size={size} />
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
type Tab = 'what' | 'when' | 'event' | 'details'
export function TrackModal() {
const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
const [yaml, setYaml] = useState<string>('')
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<Tab>('what')
const [editingRaw, setEditingRaw] = useState(false)
const [rawDraft, setRawDraft] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Listen for the open event and seed modal state.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<OpenTrackModalDetail>
const d = ev.detail
if (!d?.trackId || !d?.filePath) return
setDetail(d)
setYaml(d.initialYaml ?? '')
setActiveTab('what')
setEditingRaw(false)
setRawDraft('')
setShowAdvanced(false)
setConfirmingDelete(false)
setError(null)
setOpen(true)
void fetchFresh(d)
}
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
try {
setLoading(true)
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
if (res?.success && res.yaml) {
setYaml(res.yaml)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
if (!yaml) return null
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
}, [yaml])
const trackId = track?.trackId ?? detail?.trackId ?? ''
const instruction = track?.instruction ?? ''
const active = track?.active ?? true
const schedule = track?.schedule
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const lastRunAt = track?.lastRunAt ?? ''
const lastRunId = track?.lastRunId ?? ''
const lastRunSummary = track?.lastRunSummary ?? ''
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
const triggerType: 'scheduled' | 'event' | 'manual' =
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
const allTrackStatus = useTrackStatus()
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
useEffect(() => {
if (editingRaw && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [editingRaw])
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
{ key: 'what', label: 'What to track', visible: true },
{ key: 'when', label: 'When to run', visible: !!schedule },
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
{ key: 'details', label: 'Details', visible: true },
]
const shown = visibleTabs.filter(t => t.visible)
useEffect(() => {
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule, eventMatchCriteria])
// -------------------------------------------------------------------------
// IPC-backed mutations
// -------------------------------------------------------------------------
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:update', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
updates,
})
if (res?.success && res.yaml) {
setYaml(res.yaml)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail])
const handleToggleActive = useCallback(() => {
void runUpdate({ active: !active })
}, [active, runUpdate])
const handleRun = useCallback(async () => {
if (!detail || isRunning) return
try {
await window.ipc.invoke('track:run', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
})
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [detail, isRunning])
const handleSaveRaw = useCallback(async () => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:replaceYaml', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
yaml: rawDraft,
})
if (res?.success && res.yaml) {
setYaml(res.yaml)
setEditingRaw(false)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail, rawDraft])
const handleDelete = useCallback(async () => {
if (!detail) return
setSaving(true)
setError(null)
try {
const res = await window.ipc.invoke('track:delete', {
trackId: detail.trackId,
filePath: stripKnowledgePrefix(detail.filePath),
})
if (res?.success) {
// Tell the editor to remove the node so Tiptap's next save doesn't
// re-create the track block on disk.
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
setOpen(false)
} else if (res?.error) {
setError(res.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
}
}, [detail])
const handleEditWithCopilot = useCallback(() => {
if (!detail) return
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
detail: {
trackId: detail.trackId,
filePath: detail.filePath,
},
}))
setOpen(false)
}, [detail])
if (!detail) return null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
data-trigger={triggerType}
data-active={active ? 'true' : 'false'}
>
<div className="track-modal-header">
<div className="track-modal-header-left">
<div className="track-modal-icon-wrap">
<Radio size={16} />
</div>
<div className="track-modal-title-col">
<DialogHeader className="space-y-0">
<DialogTitle className="track-modal-title">
{trackId || 'Track'}
</DialogTitle>
<DialogDescription className="track-modal-subtitle">
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
{scheduleSummary.text}
{eventMatchCriteria && triggerType === 'scheduled' && (
<span className="track-modal-subtitle-sep">· also event-driven</span>
)}
</DialogDescription>
</DialogHeader>
</div>
</div>
<div className="track-modal-header-actions">
<label className="track-modal-toggle">
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
</label>
</div>
</div>
{/* Tabs */}
<div className="track-modal-tabs">
{shown.map(tab => (
<button
key={tab.key}
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
>
{tab.label}
</button>
))}
</div>
{/* Body */}
<div className="track-modal-body">
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest</div>}
{activeTab === 'what' && (
<div className="track-modal-prose">
{instruction
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
: <span className="track-modal-empty">No instruction set.</span>}
</div>
)}
{activeTab === 'when' && schedule && (
<div className="track-modal-when">
<div className="track-modal-when-headline">
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
<span>{scheduleSummary.text}</span>
</div>
<dl className="track-modal-dl">
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
{schedule.type === 'cron' && (
<>
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
</>
)}
{schedule.type === 'window' && (
<>
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
<dt>Window</dt><dd>{schedule.startTime} {schedule.endTime}</dd>
</>
)}
{schedule.type === 'once' && (
<>
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
</>
)}
</dl>
</div>
)}
{activeTab === 'event' && (
<div className="track-modal-prose">
{eventMatchCriteria
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
: <span className="track-modal-empty">No event matching set.</span>}
</div>
)}
{activeTab === 'details' && (
<div className="track-modal-details">
<dl className="track-modal-dl">
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
{lastRunAt && (<>
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
</>)}
{lastRunId && (<>
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
</>)}
{lastRunSummary && (<>
<dt>Summary</dt><dd>{lastRunSummary}</dd>
</>)}
</dl>
</div>
)}
{/* Advanced (raw YAML) — all tabs */}
<div className="track-modal-advanced">
<button
className="track-modal-advanced-toggle"
onClick={() => {
const next = !showAdvanced
setShowAdvanced(next)
if (next) {
setRawDraft(yaml)
setEditingRaw(true)
} else {
setEditingRaw(false)
}
}}
>
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<Code2 size={12} />
Advanced (raw YAML)
</button>
{showAdvanced && (
<div className="track-modal-raw-editor">
<Textarea
ref={textareaRef}
value={rawDraft}
onChange={(e) => setRawDraft(e.target.value)}
rows={12}
spellCheck={false}
className="track-modal-textarea"
/>
<div className="track-modal-raw-actions">
<Button
variant="outline"
size="sm"
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveRaw}
disabled={saving || rawDraft.trim() === yaml.trim()}
>
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
Save
</Button>
</div>
</div>
)}
</div>
{/* Danger zone — on Details tab only */}
{activeTab === 'details' && (
<div className="track-modal-danger-zone">
{confirmingDelete ? (
<div className="track-modal-confirm">
<span>Delete this track and its generated content?</span>
<div className="track-modal-confirm-actions">
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
Yes, delete
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="track-modal-delete-btn"
onClick={() => setConfirmingDelete(true)}
>
<Trash2 size={12} />
Delete track block
</Button>
)}
</div>
)}
</div>
{error && (
<div className="track-modal-error">{error}</div>
)}
<DialogFooter className="track-modal-footer">
<Button
variant="outline"
size="sm"
onClick={handleEditWithCopilot}
disabled={saving}
>
<Sparkles size={12} />
Edit with Copilot
</Button>
<Button
size="sm"
onClick={handleRun}
disabled={isRunning || saving}
className="track-modal-run-btn"
>
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{isRunning ? 'Running…' : 'Run now'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function stripKnowledgePrefix(p: string): string {
return p.replace(/^knowledge\//, '')
}

View file

@ -0,0 +1,178 @@
import { z } from 'zod'
import { useMemo } from 'react'
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Radio, Loader2 } from 'lucide-react'
import { parse as parseYaml } from 'yaml'
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
import { useTrackStatus } from '@/hooks/use-track-status'
function truncate(text: string, maxLen: number): string {
const clean = text.replace(/\s+/g, ' ').trim()
if (clean.length <= maxLen) return clean
return clean.slice(0, maxLen).trimEnd() + '…'
}
// Detail shape for the open-track-modal window event. Defined here so the
// consumer (TrackModal) can import it without a circular dependency.
export type OpenTrackModalDetail = {
trackId: string
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
filePath: string
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
initialYaml: string
/** Invoked after a successful IPC delete so the editor can remove the node. */
onDeleted: () => void
}
// ---------------------------------------------------------------------------
// Chip (display-only)
// ---------------------------------------------------------------------------
function TrackBlockView({ node, deleteNode, extension }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
updateAttributes: (attrs: Record<string, unknown>) => void
extension: { options: { notePath?: string } }
}) {
const raw = node.attrs.data as string
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
try {
return TrackBlockSchema.parse(parseYaml(raw))
} catch { return null }
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
const trackId = track?.trackId ?? ''
const instruction = track?.instruction ?? ''
const active = track?.active ?? true
const schedule = track?.schedule
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
const notePath = extension.options.notePath
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
const triggerType: 'scheduled' | 'event' | 'manual' =
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
const allTrackStatus = useTrackStatus()
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
const isRunning = runState.status === 'running'
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation()
if (!trackId || !notePath) return
const detail: OpenTrackModalDetail = {
trackId,
filePath: notePath,
initialYaml: raw,
onDeleted: () => deleteNode(),
}
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
'rowboat:open-track-modal',
{ detail },
))
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen(e as unknown as React.MouseEvent)
}
}
return (
<NodeViewWrapper
className="track-block-chip-wrapper"
data-type="track-block"
data-trigger={triggerType}
data-active={active ? 'true' : 'false'}
>
<button
type="button"
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
onClick={handleOpen}
onKeyDown={handleKey}
onMouseDown={(e) => e.stopPropagation()}
title={instruction ? `${trackId}: ${instruction}` : trackId}
>
{isRunning
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
: <Radio size={13} className="track-block-chip-icon" />}
<span className="track-block-chip-id">{trackId || 'track'}</span>
{instruction && (
<span className="track-block-chip-sep">·</span>
)}
{instruction && (
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
)}
{!active && <span className="track-block-chip-paused-label">paused</span>}
</button>
</NodeViewWrapper>
)
}
// ---------------------------------------------------------------------------
// Tiptap extension — unchanged schema, parseHTML, serialize
// ---------------------------------------------------------------------------
export const TrackBlockExtension = Node.create({
name: 'trackBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
notePath: undefined as string | undefined,
}
},
addAttributes() {
return {
data: {
default: '',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-track')) {
return { data: code.textContent || '' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TrackBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```track\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,90 @@
import { mergeAttributes, Node } from '@tiptap/react'
/**
* Track target markers two Tiptap atom nodes that represent the open and
* close HTML comment markers bracketing a track's output region on disk:
*
* <!--track-target:ID--> TrackTargetOpenExtension
* content in between regular Tiptap nodes (paragraphs, lists,
* custom blocks, whatever tiptap-markdown parses)
* <!--/track-target:ID--> TrackTargetCloseExtension
*
* The markers are *semantic boundaries*, not a UI container. Content between
* them is real, editable document content fully rendered by the existing
* extension set and freely editable by the user. The backend's updateContent()
* in fileops.ts still locates the region on disk by these comment markers.
*
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
* regex replace, converting each comment into a placeholder div that these
* extensions' parseHTML rules pick up. No content capture.
*
* Save path: both Tiptap's built-in markdown serializer
* (`addStorage().markdown.serialize`) AND the app's custom serializer
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
* back out they must stay in sync.
*/
type MarkerVariant = 'open' | 'close'
function buildMarkerExtension(variant: MarkerVariant) {
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
const commentFor = (id: string) =>
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
return Node.create({
name,
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
trackId: { default: '' },
}
},
parseHTML() {
return [
{
tag: `div[data-type="${htmlType}"]`,
getAttrs(el) {
if (!(el instanceof HTMLElement)) return false
return { trackId: el.getAttribute('data-track-id') ?? '' }
},
},
]
},
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': htmlType,
'data-track-id': (node.attrs.trackId as string) ?? '',
}),
]
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
node: { attrs: { trackId: string } },
) {
state.write(commentFor(node.attrs.trackId ?? ''))
state.closeBlock(node)
},
parse: {
// handled via preprocessTrackTargets → parseHTML
},
},
}
},
})
}
export const TrackTargetOpenExtension = buildMarkerExtension('open')
export const TrackTargetCloseExtension = buildMarkerExtension('close')

View file

@ -0,0 +1,72 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track-block.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
export interface TrackState {
status: TrackRunStatus;
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>();
const listeners = new Set<() => void>();
let subscribed = false;
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
store = new Map(store);
fn(store);
for (const listener of listeners) listener();
}
function ensureSubscription() {
if (subscribed) return;
subscribed = true;
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
const key = `${event.trackId}:${event.filePath}`;
if (event.type === 'track_run_start') {
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
} else if (event.type === 'track_run_complete') {
updateStore(s => s.set(key, {
status: event.error ? 'error' : 'done',
runId: event.runId,
summary: event.summary ?? null,
error: event.error ?? null,
}));
// Auto-clear after 5 seconds
setTimeout(() => {
updateStore(s => s.delete(key));
}, 5000);
}
}) as (event: z.infer<typeof TrackEvent>) => void);
}
function subscribe(onStoreChange: () => void): () => void {
ensureSubscription();
listeners.add(onStoreChange);
return () => { listeners.delete(onStoreChange); };
}
function getSnapshot(): Map<string, TrackState> {
return store;
}
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
*
* Usage in a track block component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
*
* Usage for a global indicator:
* const trackStatus = useTrackStatus();
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
*/
export function useTrackStatus(): Map<string, TrackState> {
return useSyncExternalStore(subscribe, getSnapshot);
}

View file

@ -611,6 +611,155 @@
.tiptap-editor .ProseMirror .task-block-last-run { .tiptap-editor .ProseMirror .task-block-last-run {
color: color-mix(in srgb, var(--foreground) 38%, transparent); color: color-mix(in srgb, var(--foreground) 38%, transparent);
} }
/* =============================================================
Track Block inline chip (display-only)
The chip just opens a modal (TrackModal). All mutations live in the
modal and go through IPC, so the editor never writes track state.
============================================================= */
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
--track-accent: #64748b; /* default: manual/slate */
margin: 4px 0;
display: inline-block;
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
.tiptap-editor .ProseMirror .track-block-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 12px;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
color: var(--foreground);
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
border-left: 3px solid var(--track-accent);
border-radius: 999px;
cursor: pointer;
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-chip:hover {
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent);
}
.tiptap-editor .ProseMirror .track-block-chip:active {
transform: translateY(0.5px);
}
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
opacity: 0.65;
}
.tiptap-editor .ProseMirror .track-block-chip-running {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
animation: track-chip-pulse 2s ease-in-out infinite;
}
@keyframes track-chip-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
}
.tiptap-editor .ProseMirror .track-block-chip-icon {
flex-shrink: 0;
color: var(--track-accent);
}
.tiptap-editor .ProseMirror .track-block-chip-id {
font-weight: 600;
color: var(--track-accent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-sep {
color: color-mix(in srgb, var(--foreground) 25%, transparent);
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-instruction {
color: color-mix(in srgb, var(--foreground) 80%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: color-mix(in srgb, var(--foreground) 10%, transparent);
padding: 1px 6px;
border-radius: 999px;
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
/* =============================================================
Track target markers thin visual bookends around a track's
output region. The content BETWEEN these markers is normal,
editable document content (rendered by the existing extensions).
============================================================= */
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
position: relative;
height: 1px;
margin: 14px 0 6px 0;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
content: 'track: ' attr(data-track-id);
position: absolute;
top: -8px;
left: 8px;
padding: 0 6px;
background: var(--background, #fff);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
text-transform: none;
white-space: nowrap;
pointer-events: auto;
}
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
height: 1px;
margin: 6px 0 14px 0;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
outline-offset: 1px;
pointer-events: auto;
}
/* Shared block styles (image, embed, chart, table) */ /* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper, .tiptap-editor .ProseMirror .image-block-wrapper,

View file

@ -0,0 +1,311 @@
/* =============================================================
Track Modal dialog overlay for track block details / edits
============================================================= */
.track-modal-content {
--track-accent: #64748b;
}
.track-modal-content[data-trigger="scheduled"] { --track-accent: #6366f1; }
.track-modal-content[data-trigger="event"] { --track-accent: #a855f7; }
.track-modal-content[data-trigger="manual"] { --track-accent: #64748b; }
.track-modal-content[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
/* Header */
.track-modal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--track-accent) 6%, transparent);
border-left: 4px solid var(--track-accent);
}
.track-modal-header-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.track-modal-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: color-mix(in srgb, var(--track-accent) 15%, transparent);
color: var(--track-accent);
flex-shrink: 0;
}
.track-modal-title-col {
min-width: 0;
flex: 1;
}
.track-modal-title {
font-size: 16px;
font-weight: 600;
color: var(--foreground);
font-family: var(--font-mono, ui-monospace, monospace);
}
.track-modal-subtitle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
margin-top: 2px;
}
.track-modal-subtitle-sep {
margin-left: 2px;
}
.track-modal-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.track-modal-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.track-modal-toggle-label {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}
/* Tabs */
.track-modal-tabs {
display: flex;
padding: 0 20px;
border-bottom: 1px solid var(--border);
}
.track-modal-tab {
padding: 10px 14px;
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.1s ease, border-color 0.1s ease;
white-space: nowrap;
}
.track-modal-tab:hover {
color: var(--foreground);
}
.track-modal-tab-active {
color: var(--track-accent);
border-bottom-color: var(--track-accent);
}
/* Body */
.track-modal-body {
padding: 18px 20px;
max-height: 60vh;
overflow-y: auto;
}
.track-modal-loading {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
margin-bottom: 10px;
}
.track-modal-prose {
font-size: 13.5px;
line-height: 1.6;
color: var(--foreground);
}
.track-modal-markdown {
user-select: text;
}
.track-modal-markdown > *:first-child { margin-top: 0; }
.track-modal-markdown > *:last-child { margin-bottom: 0; }
.track-modal-empty {
color: color-mix(in srgb, var(--foreground) 40%, transparent);
font-style: italic;
}
/* When-to-run panel */
.track-modal-when {
display: flex;
flex-direction: column;
gap: 14px;
}
.track-modal-when-headline {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: var(--track-accent);
padding: 12px 14px;
background: color-mix(in srgb, var(--track-accent) 10%, transparent);
border-radius: 8px;
border-left: 3px solid var(--track-accent);
}
/* Description list (Details / When) */
.track-modal-dl {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 16px;
row-gap: 8px;
margin: 0;
font-size: 13px;
}
.track-modal-dl dt {
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-weight: 500;
}
.track-modal-dl dd {
margin: 0;
color: var(--foreground);
word-break: break-word;
}
.track-modal-dl code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
padding: 1px 6px;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
border-radius: 3px;
}
/* Advanced / raw YAML disclosure */
.track-modal-advanced {
margin-top: 20px;
padding-top: 14px;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 12%, transparent);
}
.track-modal-advanced-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 11px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: none;
border: none;
cursor: pointer;
}
.track-modal-advanced-toggle:hover {
color: var(--foreground);
}
.track-modal-raw-editor {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.track-modal-textarea {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.5;
min-height: 200px;
}
.track-modal-raw-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Danger zone */
.track-modal-danger-zone {
margin-top: 20px;
padding-top: 14px;
border-top: 1px dashed color-mix(in srgb, var(--destructive, #ef4444) 20%, transparent);
}
.track-modal-delete-btn {
color: color-mix(in srgb, var(--destructive, #ef4444) 85%, var(--foreground));
border-color: color-mix(in srgb, var(--destructive, #ef4444) 30%, transparent);
}
.track-modal-delete-btn:hover {
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
color: var(--destructive, #ef4444);
}
.track-modal-confirm {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
flex-wrap: wrap;
}
.track-modal-confirm-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* Error */
.track-modal-error {
margin: 0 20px 14px 20px;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
color: var(--destructive, #ef4444);
font-size: 12px;
}
/* Footer */
.track-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
.track-modal-run-btn {
background: var(--track-accent);
color: white;
}
.track-modal-run-btn:hover {
background: color-mix(in srgb, var(--track-accent) 85%, black);
}

View file

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

View file

@ -70,6 +70,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**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. **App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
## Learning About the User (save-to-memory) ## Learning About the User (save-to-memory)

View file

@ -12,10 +12,13 @@ import createPresentationsSkill from "./create-presentations/skill.js";
import appNavigationSkill from "./app-navigation/skill.js"; import appNavigationSkill from "./app-navigation/skill.js";
import composioIntegrationSkill from "./composio-integration/skill.js"; import composioIntegrationSkill from "./composio-integration/skill.js";
import tracksSkill from "./tracks/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills"; const CATALOG_PREFIX = "src/application/assistant/skills";
// console.log(tracksSkill);
type SkillDefinition = { type SkillDefinition = {
id: string; // Also used as folder name id: string; // Also used as folder name
title: string; title: string;
@ -96,6 +99,12 @@ const definitions: SkillDefinition[] = [
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.", summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
content: appNavigationSkill, content: appNavigationSkill,
}, },
{
id: "tracks",
title: "Tracks",
summary: "Create and manage track blocks — YAML-scheduled auto-updating content blocks in notes (weather, news, prices, status, dashboards). Insert at cursor (Cmd+K) or append to notes.",
content: tracksSkill,
},
]; ];
const skillEntries = definitions.map((definition) => ({ const skillEntries = definitions.map((definition) => ({

View file

@ -0,0 +1,318 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
export const skill = String.raw`
# Tracks Skill
You are helping the user create and manage **track blocks** YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
## What Is a Track Block
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
- A sibling "target region" an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
**Concrete example** (a track that shows the current time in Chicago every hour):
` + "```" + `track
trackId: chicago-time
instruction: Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:chicago-time-->
<!--/track-target:chicago-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)
- Any recurring summary that decays fast
## Anatomy
Each track has two parts that live next to each other in the note:
1. The ` + "`" + `track` + "`" + ` code fence contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
2. The target-comment region ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
## Canonical Schema
Below is the authoritative schema for a track block (generated at build time from the TypeScript source never out of date). Use it to validate every field name, type, and constraint before writing YAML:
` + "```" + `yaml
${schemaYaml}
` + "```" + `
**Runtime-managed fields never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
## Choosing a trackId
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
- **Must be unique within the note file.** Before inserting, read the file and check:
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
- Don't reuse an old ID even if the previous block was deleted pick a fresh one.
## Writing a Good Instruction
- **Specific and actionable.** State exactly what to fetch or compute.
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
- **Imperative voice, 1-3 sentences.**
- **Mention output style** if it matters ("markdown bullet list", "one sentence", "table with 5 rows").
Good:
> Fetch the current temperature, feels-like, and conditions for Chicago, IL in Fahrenheit. Return as a single line: "72°F (feels like 70°F), partly cloudy".
Bad:
> Tell me about Chicago.
## Schedules
Schedule is an **optional** discriminated union. Three types:
### ` + "`" + `cron` + "`" + ` recurring at exact times
` + "```" + `yaml
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
### ` + "`" + `window` + "`" + ` recurring within a time-of-day range
` + "```" + `yaml
schedule:
type: window
cron: "0 0 * * 1-5"
startTime: "09:00"
endTime: "17:00"
` + "```" + `
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" flexible timing with bounds.
### ` + "`" + `once` + "`" + ` one-shot at a future time
` + "```" + `yaml
schedule:
type: once
runAt: "2026-04-14T09:00:00"
` + "```" + `
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
### 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
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** the user triggers it via the Play button in the UI.
## Event Triggers (third trigger type)
In addition to manual and scheduled, a track can be triggered by **events** incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
` + "```" + `track
trackId: q3-planning-emails
instruction: Maintain a running summary of decisions and open questions about Q3 planning, drawn from emails on the topic.
active: true
eventMatchCriteria: Emails about Q3 planning, roadmap decisions, or quarterly OKRs
` + "```" + `
How it works:
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update no fabricated content.
When to suggest event triggers:
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
- Be descriptive but not overly narrow Pass 1 routing is liberal by design.
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely they'll only run on schedule or manually.
## Insertion Workflow
### Cmd+K with cursor context
When the user invokes Cmd+K, the context includes an attachment mention like:
> User has attached the following files:
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
Workflow:
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` always re-read fresh.
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
5. Construct the full track block (YAML + target pair).
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
### Sidebar chat with a specific note
1. If a file is mentioned/attached, read it.
2. If ambiguous, ask one question: "Which note should I add the track to?"
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
### 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.
## The Exact Text to Insert
Write it verbatim like this (including the blank line between fence and target):
` + "```" + `track
trackId: <id>
instruction: <instruction>
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:<id>-->
<!--/track-target:<id>-->
**Rules:**
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
- Target pair is **empty on creation**. The runner fills it on the first run.
- **Always quote cron expressions** in YAML they contain spaces and ` + "`" + `*` + "`" + `.
- Use 2-space YAML indent. No tabs.
- Top-level markdown only never inside a code fence, blockquote, or table.
## After Insertion
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
- **Then offer to run it once now** (see "Running a Track" below) especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
### When to proactively offer to run
These are upsells ask first, don't run silently.
- **Just created a new track block.** Before declaring done, offer:
> "Want me to run it once now to seed the initial content?"
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) otherwise the target region stays empty until the next matching event arrives.
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
- **Just edited an existing track.** Offer:
> "Want me to run it now to see the updated output?"
- **Explicit user request.** "run the X track", "test it", "refresh that block" call the tool directly.
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
**Examples:**
- New track: "Track emails about Q3 planning" after creating it, run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
- New track: "Summarize this week's customer calls" run with:
> 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 says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context it can mislead the agent.
### What to do with the result
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
- **` + "`" + `action: 'replace'` + "`" + `** the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
> "Done — track now shows: 72°F, partly cloudy in Chicago."
- **` + "`" + `action: 'no_update'` + "`" + `** the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
- **` + "`" + `error` + "`" + ` set** surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
### Don'ts
- **Don't auto-run** after every edit ask first.
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn one run per user-facing action.
## Proactive Suggestions
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
- "I want to track / monitor / watch / keep an eye on / follow X"
- "Can you check on X every morning / hourly / weekly?"
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
Suggestion style one line, concrete:
> "I can turn this into a track block that refreshes hourly — want that?"
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
## Don'ts
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` runtime-managed.
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` local time only.
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
## Editing or Removing an Existing Track
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
## Quick Reference
Minimal template:
` + "```" + `track
trackId: <kebab-id>
instruction: <what to produce>
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:<kebab-id>-->
<!--/track-target:<kebab-id>-->
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
`;
export default skill;

View file

@ -25,6 +25,7 @@ import { isSignedIn } from "../../account/account.js";
import { getGatewayProvider } from "../../models/gateway.js"; import { getGatewayProvider } from "../../models/gateway.js";
import { getAccessToken } from "../../auth/tokens.js"; import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js"; import { API_URL } from "../../config/env.js";
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
// Parser libraries are loaded dynamically inside parseFile.execute() // Parser libraries are loaded dynamically inside parseFile.execute()
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. // to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
// Import paths are computed so esbuild cannot statically resolve them. // Import paths are computed so esbuild cannot statically resolve them.
@ -1431,4 +1432,56 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
isAvailable: async () => isComposioConfigured(), isAvailable: async () => isComposioConfigured(),
}, },
'update-track-content': {
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId"),
content: z.string().describe("The new content to place inside the track's target region"),
}),
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
try {
await updateContent(filePath, trackId, content);
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
'run-track-block': {
description: "Manually trigger a track block to run now. Equivalent to the user clicking the Play button on the block, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking block from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new content.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId (must exist in the file)"),
context: z.string().optional().describe(
"Optional extra context for the track agent to consider for THIS run only — does not modify the block's instruction. " +
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
"Omit for a plain refresh."
),
}),
execute: async ({ filePath, trackId, context }: { filePath: string; trackId: string; context?: string }) => {
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
try {
// Lazy import to break a module-init cycle:
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
const result = await triggerTrackUpdate(trackId, knowledgeRelativePath, context, 'manual');
return {
success: !result.error,
runId: result.runId,
action: result.action,
summary: result.summary,
contentAfter: result.contentAfter,
error: result.error,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
}; };

View file

@ -9,6 +9,130 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js'; import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js'; import { composioAccountsRepo } from '../composio/repo.js';
import { createEvent } from './track/events.js';
const MAX_EVENTS_IN_DIGEST = 50;
const MAX_DESCRIPTION_CHARS = 500;
type AnyEvent = Record<string, unknown> | cal.Schema$Event;
function getStr(obj: unknown, key: string): string | undefined {
if (obj && typeof obj === 'object' && key in obj) {
const v = (obj as Record<string, unknown>)[key];
return typeof v === 'string' ? v : undefined;
}
return undefined;
}
function formatEventTime(event: AnyEvent): string {
const start = (event as Record<string, unknown>).start as Record<string, unknown> | undefined;
const end = (event as Record<string, unknown>).end as Record<string, unknown> | undefined;
const startStr = getStr(start, 'dateTime') ?? getStr(start, 'date') ?? 'unknown';
const endStr = getStr(end, 'dateTime') ?? getStr(end, 'date') ?? 'unknown';
return `${startStr}${endStr}`;
}
function formatEventBlock(event: AnyEvent, label: 'NEW' | 'UPDATED'): string {
const id = getStr(event, 'id') ?? '(unknown id)';
const title = getStr(event, 'summary') ?? '(no title)';
const time = formatEventTime(event);
const organizer = getStr((event as Record<string, unknown>).organizer, 'email') ?? 'unknown';
const location = getStr(event, 'location') ?? '';
const rawDescription = getStr(event, 'description') ?? '';
const description = rawDescription.length > MAX_DESCRIPTION_CHARS
? rawDescription.slice(0, MAX_DESCRIPTION_CHARS) + '…(truncated)'
: rawDescription;
const attendeesRaw = (event as Record<string, unknown>).attendees;
let attendeesLine = '';
if (Array.isArray(attendeesRaw) && attendeesRaw.length > 0) {
const emails = attendeesRaw
.map(a => getStr(a, 'email'))
.filter((e): e is string => !!e);
if (emails.length > 0) {
attendeesLine = `**Attendees:** ${emails.join(', ')}\n`;
}
}
return [
`### [${label}] ${title}`,
`**ID:** ${id}`,
`**Time:** ${time}`,
`**Organizer:** ${organizer}`,
location ? `**Location:** ${location}` : '',
attendeesLine.trimEnd(),
description ? `\n${description}` : '',
].filter(Boolean).join('\n');
}
function summarizeCalendarSync(
newEvents: AnyEvent[],
updatedEvents: AnyEvent[],
deletedEventIds: string[],
): string {
const totalChanges = newEvents.length + updatedEvents.length + deletedEventIds.length;
const lines: string[] = [
`# Calendar sync update`,
``,
`${newEvents.length} new, ${updatedEvents.length} updated, ${deletedEventIds.length} deleted.`,
``,
];
const allChanges: Array<{ event: AnyEvent; label: 'NEW' | 'UPDATED' }> = [
...newEvents.map(e => ({ event: e, label: 'NEW' as const })),
...updatedEvents.map(e => ({ event: e, label: 'UPDATED' as const })),
];
const shown = allChanges.slice(0, MAX_EVENTS_IN_DIGEST);
const hidden = allChanges.length - shown.length;
if (shown.length > 0) {
lines.push(`## Changed events`, ``);
for (const { event, label } of shown) {
lines.push(formatEventBlock(event, label), ``);
}
if (hidden > 0) {
lines.push(`_…and ${hidden} more change(s) omitted from digest._`, ``);
}
}
if (deletedEventIds.length > 0) {
lines.push(`## Deleted event IDs`, ``);
for (const id of deletedEventIds.slice(0, MAX_EVENTS_IN_DIGEST)) {
lines.push(`- ${id}`);
}
if (deletedEventIds.length > MAX_EVENTS_IN_DIGEST) {
lines.push(`- _…and ${deletedEventIds.length - MAX_EVENTS_IN_DIGEST} more_`);
}
lines.push(``);
}
if (totalChanges === 0) {
lines.push(`(no changes — should not be emitted)`);
}
return lines.join('\n');
}
async function publishCalendarSyncEvent(
newEvents: AnyEvent[],
updatedEvents: AnyEvent[],
deletedEventIds: string[],
): Promise<void> {
if (newEvents.length === 0 && updatedEvents.length === 0 && deletedEventIds.length === 0) {
return;
}
try {
await createEvent({
source: 'calendar',
type: 'calendar.synced',
createdAt: new Date().toISOString(),
payload: summarizeCalendarSync(newEvents, updatedEvents, deletedEventIds),
});
} catch (err) {
console.error('[Calendar] Failed to publish sync event:', err);
}
}
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
@ -194,6 +318,8 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
let deletedCount = 0; let deletedCount = 0;
let attachmentCount = 0; let attachmentCount = 0;
const changedTitles: string[] = []; const changedTitles: string[] = [];
const newEvents: AnyEvent[] = [];
const updatedEvents: AnyEvent[] = [];
const ensureRun = async () => { const ensureRun = async () => {
if (!runId) { if (!runId) {
@ -234,8 +360,10 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
changedTitles.push(result.title); changedTitles.push(result.title);
if (result.isNew) { if (result.isNew) {
newCount++; newCount++;
newEvents.push(event);
} else { } else {
updatedCount++; updatedCount++;
updatedEvents.push(event);
} }
} }
@ -253,6 +381,9 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
deletedCount = deletedFiles.length; deletedCount = deletedFiles.length;
} }
// Publish a single bundled event capturing all changes from this sync.
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
if (runId) { if (runId) {
const totalChanges = newCount + updatedCount + deletedCount + attachmentCount; const totalChanges = newCount + updatedCount + deletedCount + attachmentCount;
const limitedTitles = limitEventItems(changedTitles); const limitedTitles = limitEventItems(changedTitles);
@ -438,6 +569,8 @@ async function performSyncComposio() {
let newCount = 0; let newCount = 0;
let updatedCount = 0; let updatedCount = 0;
const changedTitles: string[] = []; const changedTitles: string[] = [];
const newEvents: AnyEvent[] = [];
const updatedEvents: AnyEvent[] = [];
let pageToken: string | null = null; let pageToken: string | null = null;
const MAX_PAGES = 20; const MAX_PAGES = 20;
@ -508,8 +641,10 @@ async function performSyncComposio() {
changedTitles.push(saveResult.title); changedTitles.push(saveResult.title);
if (saveResult.isNew) { if (saveResult.isNew) {
newCount++; newCount++;
newEvents.push(event);
} else { } else {
updatedCount++; updatedCount++;
updatedEvents.push(event);
} }
} }
} }
@ -534,6 +669,9 @@ async function performSyncComposio() {
deletedCount = deletedFiles.length; deletedCount = deletedFiles.length;
} }
// Publish a single bundled event capturing all changes from this sync.
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
// Log results if any changes were detected (run was started by ensureRun) // Log results if any changes were detected (run was started by ensureRun)
if (run) { if (run) {
const r = run as ServiceRunContext; const r = run as ServiceRunContext;

View file

@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogle } from '../composio/client.js'; import { executeAction, useComposioForGoogle } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js'; import { composioAccountsRepo } from '../composio/repo.js';
import { createEvent } from './track/events.js';
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
@ -172,6 +173,13 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
console.log(`Synced Thread: ${subject} (${threadId})`); console.log(`Synced Thread: ${subject} (${threadId})`);
await createEvent({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
} catch (error) { } catch (error) {
console.error(`Error processing thread ${threadId}:`, error); console.error(`Error processing thread ${threadId}:`, error);
} }
@ -595,6 +603,12 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`); console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
await createEvent({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
newestDate = tryParseDate(parsed.date); newestDate = tryParseDate(parsed.date);
} else { } else {
const firstParsed = parseMessageData(messages[0]); const firstParsed = parseMessageData(messages[0]);
@ -617,6 +631,12 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`); console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
await createEvent({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
} }
if (!newestDate) return null; if (!newestDate) return null;

View file

@ -0,0 +1,23 @@
import type { TrackEventType } from '@x/shared/dist/track-block.js';
type Handler = (event: TrackEventType) => void;
class TrackBus {
private subs: Handler[] = [];
publish(event: TrackEventType): void {
for (const handler of this.subs) {
handler(event);
}
}
subscribe(handler: Handler): () => void {
this.subs.push(handler);
return () => {
const idx = this.subs.indexOf(handler);
if (idx >= 0) this.subs.splice(idx, 1);
};
}
}
export const trackBus = new TrackBus();

View file

@ -0,0 +1,189 @@
import fs from 'fs';
import path from 'path';
import { PrefixLogger, trackBlock } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import { 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 type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import container from '../../di/container.js';
const POLL_INTERVAL_MS = 5_000; // 5 seconds — events should feel responsive
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');
/**
* Write a KnowledgeEvent to the events/pending/ directory.
* Filename is a monotonically increasing ID so events sort by creation order.
* Call this function in chronological order (oldest event first) within a sync batch
* to ensure correct ordering.
*/
export async function createEvent(event: Omit<KnowledgeEvent, 'id'>): Promise<void> {
fs.mkdirSync(PENDING_DIR, { recursive: true });
const idGen = container.resolve<IMonotonicallyIncreasingIdGenerator>('idGenerator');
const id = await idGen.next();
const fullEvent: KnowledgeEvent = { id, ...event };
const filePath = path.join(PENDING_DIR, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(fullEvent, null, 2), 'utf-8');
}
function ensureDirs(): void {
fs.mkdirSync(PENDING_DIR, { recursive: true });
fs.mkdirSync(DONE_DIR, { recursive: true });
}
async function listAllTracks(): Promise<ParsedTrack[]> {
const tracks: ParsedTrack[] = [];
let entries;
try {
entries = await workspace.readdir('knowledge', { recursive: true });
} catch {
return tracks;
}
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;
try {
parsedTracks = await fetchAll(filePath);
} catch {
continue;
}
for (const t of parsedTracks) {
tracks.push({
trackId: t.track.trackId,
filePath,
eventMatchCriteria: t.track.eventMatchCriteria ?? '',
instruction: t.track.instruction,
active: t.track.active,
});
}
}
return tracks;
}
function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
const donePath = path.join(DONE_DIR, filename);
const pendingPath = path.join(PENDING_DIR, filename);
fs.writeFileSync(donePath, JSON.stringify(enriched, null, 2), 'utf-8');
try {
fs.unlinkSync(pendingPath);
} catch (err) {
log.log(`Failed to remove pending event ${filename}:`, err);
}
}
async function processOneEvent(filename: string): Promise<void> {
const pendingPath = path.join(PENDING_DIR, filename);
let event: KnowledgeEvent;
try {
const raw = fs.readFileSync(pendingPath, 'utf-8');
const parsed = JSON.parse(raw);
event = trackBlock.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);
const stub: KnowledgeEvent = {
id: filename.replace(/\.json$/, ''),
source: 'unknown',
type: 'unknown',
createdAt: new Date().toISOString(),
payload: '',
processedAt: new Date().toISOString(),
error: `Failed to parse: ${msg}`,
};
moveEventToDone(filename, stub);
return;
}
log.log(`Processing event ${event.id} (source=${event.source}, type=${event.type})`);
const allTracks = await listAllTracks();
const candidates = await findCandidates(event, allTracks);
const runIds: string[] = [];
let processingError: string | undefined;
// Sequential — preserves total ordering
for (const candidate of candidates) {
try {
const result = await triggerTrackUpdate(
candidate.trackId,
candidate.filePath,
event.payload,
'event',
);
if (result.runId) runIds.push(result.runId);
log.log(`Candidate ${candidate.trackId}: ${result.action}${result.error ? ` (${result.error})` : ''}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.log(`Error triggering candidate ${candidate.trackId}:`, msg);
processingError = (processingError ? processingError + '; ' : '') + `${candidate.trackId}: ${msg}`;
}
}
const enriched: KnowledgeEvent = {
...event,
processedAt: new Date().toISOString(),
candidates: candidates.map(c => ({ trackId: c.trackId, filePath: c.filePath })),
runIds,
...(processingError ? { error: processingError } : {}),
};
moveEventToDone(filename, enriched);
}
async function processPendingEvents(): Promise<void> {
ensureDirs();
let filenames: string[];
try {
filenames = fs.readdirSync(PENDING_DIR).filter(f => f.endsWith('.json'));
} catch (err) {
log.log('Failed to read pending dir:', err);
return;
}
if (filenames.length === 0) return;
// FIFO: monotonic IDs are lexicographically sortable
filenames.sort();
log.log(`Processing ${filenames.length} pending event(s)`);
for (const filename of filenames) {
try {
await processOneEvent(filename);
} catch (err) {
log.log(`Unhandled error processing ${filename}:`, 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`);
ensureDirs();
// Initial run
await processPendingEvents();
while (true) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
await processPendingEvents();
} catch (err) {
log.log('Error in main loop:', err);
}
}
}

View file

@ -0,0 +1,190 @@
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 { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { TrackStateSchema } from './types.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
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 lines = content.split('\n');
const blocks: z.infer<typeof TrackStateSchema>[] = [];
let i = 0;
const contentFenceStartMatcher = /<!--track-target:(.+)-->/;
const contentFenceEndMatcher = /<!--\/track-target:(.+)-->/;
while (i < lines.length) {
if (lines[i].trim() === '```track') {
const fenceStart = i;
i++;
const blockLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
blockLines.push(lines[i]);
i++;
}
try {
const data = parseYaml(blockLines.join('\n'));
const result = TrackBlockSchema.safeParse(data);
if (result.success) {
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
}
} catch { /* skip */ }
} else if (contentFenceStartMatcher.test(lines[i])) {
const match = contentFenceStartMatcher.exec(lines[i]);
if (match) {
const trackId = match[1];
// have we already collected this track block?
const existingBlock = blocks.find(b => b.track.trackId === trackId);
if (!existingBlock) {
i++;
continue;
}
const contentStart = i + 1;
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
i++;
}
const contentEnd = i;
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
}
}
i++;
}
return blocks;
}
export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const blocks = await fetchAll(filePath);
return blocks.find(b => b.track.trackId === trackId) ?? null;
}
/**
* Fetch a track block and return its canonical YAML string (or null if not found).
* Useful for IPC handlers that need to return the fresh YAML without taking a
* dependency on the `yaml` package themselves.
*/
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
const block = await fetch(filePath, trackId);
if (!block) return null;
return stringifyYaml(block.track).trimEnd();
}
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
let content = await fs.readFile(absPath(filePath), 'utf-8');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
const openIdx = content.indexOf(openTag);
const closeIdx = content.indexOf(closeTag);
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
} else {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const lines = content.split('\n');
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
const contentFence = [openTag, newContent, closeTag];
lines.splice(insertAt, 0, ...contentFence);
content = lines.join('\n');
}
await fs.writeFile(absPath(filePath), content, 'utf-8');
}
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
const block = await fetch(filepath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filepath}`);
}
block.track = { ...block.track, ...updates };
// read file contents
let content = await fs.readFile(absPath(filepath), 'utf-8');
const lines = content.split('\n');
const yaml = stringifyYaml(block.track).trimEnd();
const yamlLines = yaml ? yaml.split('\n') : [];
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
content = lines.join('\n');
await fs.writeFile(absPath(filepath), content, 'utf-8');
}
/**
* Replace the entire YAML of a track block on disk with a new string.
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim
* used when the user explicitly edits raw YAML in the modal.
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
* otherwise the write is rejected.
*/
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
if (!parsed.success) {
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
}
if (parsed.data.trackId !== trackId) {
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const yamlLines = newYaml.trimEnd().split('\n');
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
}
/**
* Remove a track block and its sibling target region from the file.
*/
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
const block = await fetch(filePath, trackId);
if (!block) {
// Already gone — treat as success.
return;
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
// Find target region (may not exist)
let targetStart = -1;
let targetEnd = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(openTag)) { targetStart = i; }
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
}
// Build a list of [start, end] ranges to remove, sorted descending so
// indices stay valid as we splice.
const ranges: Array<[number, number]> = [];
ranges.push([block.fenceStart, block.fenceEnd]);
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
ranges.push([targetStart, targetEnd]);
}
ranges.sort((a, b) => b[0] - a[0]);
for (const [start, end] of ranges) {
lines.splice(start, end - start + 1);
// Also drop a trailing blank line if the removal left two in a row.
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
lines.splice(start, 1);
}
}
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
}

View file

@ -0,0 +1,118 @@
import { generateObject } from 'ai';
import { trackBlock, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import container from '../../di/container.js';
import type { IModelConfigRepo } from '../../models/repo.js';
import { createProvider } from '../../models/models.js';
import { isSignedIn } from '../../account/account.js';
import { getGatewayProvider } from '../../models/gateway.js';
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 track blocks. Each track block has:
- trackId: an identifier (only unique within its file)
- filePath: the note file the track lives in
- eventMatchCriteria: a description of what kinds of signals are relevant to this track
Your job is to identify which track blocks 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 repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
const modelId = config.knowledgeGraphModel
|| (signedIn ? 'gpt-5.4' : config.model);
return provider.languageModel(modelId);
}
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
const trackList = batch
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n eventMatchCriteria: ${t.eventMatchCriteria}`)
.join('\n\n');
return `## Event
Source: ${event.source}
Type: ${event.type}
Time: ${event.createdAt}
${event.payload}
## Track Blocks
${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 = await resolveModel();
const candidateKeys = new Set<string>();
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
const batch = filtered.slice(i, i + BATCH_SIZE);
try {
const { object } = await generateObject({
model,
system: ROUTING_SYSTEM_PROMPT,
prompt: buildRoutingPrompt(event, batch),
schema: trackBlock.Pass1OutputSchema,
});
for (const c of 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

@ -0,0 +1,65 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
import { WorkDir } from '../../config/config.js';
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that updates a specific section of a knowledge note.
You will receive a message containing a track instruction, the current content of the target region, and optionally some context. Your job is to follow the instruction and produce updated content.
# Background Mode
You are running as a background task there is no user present.
- Do NOT ask clarifying questions make reasonable assumptions
- Be concise and action-oriented just do the work
# The Knowledge Graph
The knowledge graph is stored as plain markdown in \`${WorkDir}/knowledge/\` (inside the workspace). It's organized into:
- **People/** Notes on individuals
- **Organizations/** Notes on companies
- **Projects/** Notes on initiatives
- **Topics/** Notes on recurring themes
Use workspace tools to search and read the knowledge graph for context.
# How to Access the Knowledge Graph
**CRITICAL:** Always include \`knowledge/\` in paths.
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\`
- \`workspace-readdir("knowledge/People")\`
**NEVER** use an empty path or root path.
# How to Write Your Result
Use the \`update-track-content\` tool to write your result. The message will tell you the file path and track ID.
- Produce the COMPLETE replacement content (not a diff)
- Preserve existing content that's still relevant
- Write in a clear, concise style appropriate for personal notes
# Web Search
You have access to \`web-search\` for tracks that need external information (news, trends, current events). Use it when the track instruction requires information beyond the knowledge graph.
# After You're Done
End your response with a brief summary of what you did (1-2 sentences).
`;
export function buildTrackRunAgent(): z.infer<typeof Agent> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
tools[name] = { type: 'builtin', name };
}
return {
name: 'track-run',
description: 'Background agent that updates track block content',
instructions: TRACK_RUN_INSTRUCTIONS,
tools,
};
}

View file

@ -0,0 +1,168 @@
import z from 'zod';
import { fetchAll, updateTrackBlock } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.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;
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
**Time:** ${localNow} (${tz})
**Instruction:**
${track.track.instruction}
**Current content:**
${track.content || '(empty — first run)'}
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
if (trigger === 'event') {
msg += `
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
**Event match criteria for this track:**
${track.track.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)'}
**Event payload:**
${context ?? '(no payload)'}
**Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update do NOT call \`update-track-content\`. Only call the tool if the event provides new or changed information that should be reflected in the track.`;
} 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 block.
* 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.trackId === trackId);
if (!track) {
logger.log('track not found', trackId, filePath, trigger, context);
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
}
const contentBefore = track.content;
// Emit start event — runId is set after agent run is created
const agentRun = await createRun({ agentId: 'track-run' });
// Set lastRunAt and lastRunId immediately (before agent executes) so
// the scheduler's next poll won't re-trigger this track.
await updateTrackBlock(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 updatedTracks = await fetchAll(filePath);
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
const didUpdate = contentAfter !== contentBefore;
// Update summary on completion
await updateTrackBlock(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: contentBefore ?? null,
contentAfter: contentAfter ?? null,
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: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
}
} finally {
runningTracks.delete(key);
}
}

View file

@ -0,0 +1,63 @@
import { CronExpressionParser } from 'cron-parser';
import type { TrackSchedule } from '@x/shared/dist/track-block.js';
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
/**
* Determine if a scheduled track is due to run.
* All schedule types enforce a 2-minute grace period if the scheduled time
* was more than 2 minutes ago, it's considered a miss and skipped.
*/
export function isTrackScheduleDue(schedule: TrackSchedule, 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': {
// Time-of-day filter (applies regardless of lastRunAt state).
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;
try {
const interval = CronExpressionParser.parse(schedule.cron, {
currentDate: now,
});
const prevRun = interval.prev().toDate();
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
return now.getTime() <= prevRun.getTime() + GRACE_MS;
} catch {
return false;
}
}
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

@ -0,0 +1,66 @@
import { PrefixLogger } from '@x/shared';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
import { triggerTrackUpdate } from './runner.js';
import { isTrackScheduleDue } from './schedule-utils.js';
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.schedule) continue;
const due = isTrackScheduleDue(track.schedule, track.lastRunAt ?? null);
log.log(`Track "${track.trackId}" in ${relativePath}: schedule=${track.schedule.type}, lastRunAt=${track.lastRunAt ?? 'never'}, due=${due}`);
if (due) {
log.log(`Triggering "${track.trackId}" in ${relativePath}`);
triggerTrackUpdate(track.trackId, relativePath, undefined, 'timed').catch(err => {
log.log(`Error running ${track.trackId}:`, 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

@ -0,0 +1,9 @@
import z from "zod";
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
export const TrackStateSchema = z.object({
track: TrackBlockSchema,
fenceStart: z.number(),
fenceEnd: z.number(),
content: z.string(),
});

View file

@ -9,6 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
export * as serviceEvents from './service-events.js' export * as serviceEvents from './service-events.js'
export * as inlineTask from './inline-task.js'; export * as inlineTask from './inline-task.js';
export * as blocks from './blocks.js'; export * as blocks from './blocks.js';
export * as trackBlock from './track-block.js';
export * as frontmatter from './frontmatter.js'; export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js'; export * as bases from './bases.js';
export { PrefixLogger }; export { PrefixLogger };

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js'; import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js'; import { ServiceEvent } from './service-events.js';
import { TrackEvent } from './track-block.js';
import { UserMessageContent } from './message.js'; import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js'; import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js'; import { ZListToolkitsResponse } from './composio.js';
@ -193,6 +194,10 @@ const ipcSchemas = {
req: ServiceEvent, req: ServiceEvent,
res: z.null(), res: z.null(),
}, },
'tracks:events': {
req: TrackEvent,
res: z.null(),
},
'models:list': { 'models:list': {
req: z.null(), req: z.null(),
res: z.object({ res: z.object({
@ -560,6 +565,67 @@ const ipcSchemas = {
response: z.string().nullable(), response: z.string().nullable(),
}), }),
}, },
// Track channels
'track:run': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
summary: z.string().optional(),
error: z.string().optional(),
}),
},
'track:get': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
// Fresh, authoritative YAML of the track block from disk.
// Renderer should use this for display/edit — never its Tiptap node attr.
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:update': {
req: z.object({
trackId: z.string(),
filePath: z.string(),
// Partial TrackBlock updates — merged into the block's YAML 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({
trackId: 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({
trackId: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
// Billing channels // Billing channels
'billing:getInfo': { 'billing:getInfo': {
req: z.null(), req: z.null(),

View file

@ -0,0 +1,87 @@
import z from 'zod';
export const TrackScheduleSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('cron').describe('Fires at exact cron times'),
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
}).describe('Recurring at exact times'),
z.object({
type: z.literal('window').describe('Fires at most once per cron occurrence, only within a time-of-day window'),
cron: z.string().describe('5-field cron expression, quoted'),
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
}).describe('Recurring within a time-of-day window'),
z.object({
type: z.literal('once').describe('Fires once and never again'),
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
}).describe('One-shot future run'),
]).describe('Optional schedule. Omit entirely for manual-only tracks.');
export type TrackSchedule = z.infer<typeof TrackScheduleSchema>;
export const TrackBlockSchema = z.object({
trackId: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
active: z.boolean().default(true).describe('Set false to pause without deleting'),
schedule: TrackScheduleSchema.optional(),
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 block 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 TrackBlock = z.infer<typeof TrackBlockSchema>;
export type TrackEventType = z.infer<typeof TrackEvent>;

3
apps/x/pnpm-lock.yaml generated
View file

@ -265,6 +265,9 @@ importers:
use-stick-to-bottom: use-stick-to-bottom:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(react@19.2.3) version: 1.1.1(react@19.2.3)
yaml:
specifier: ^2.8.2
version: 2.8.2
zod: zod:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1