mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
feat: tracks — frontmatter directives, sidebar UI, multi-trigger
Recasts the old "track blocks" as "tracks" — directives stored in a note's frontmatter rather than inline YAML fences and HTML-comment target regions. The motivation is UX: the inline anatomy made notes feel like config, leaked into the editing surface, and competed with the writing flow. Frontmatter is invisible to the body editor, so moving directives there reclaims the body as just markdown the user wrote. The runtime agent now edits the note body freely via standard workspace tools rather than rewriting a constrained target region. Each track's instruction names an H2 section to own; the agent finds or creates that section, updates only its content, and self-heals position on subsequent runs. Triggers are now a unified array per track. cron / window / once / event in any combination, including multi-trigger setups (the flagship example: a priorities track that rebuilds at three day-windows and reacts to incoming gmail / calendar events). window is forgiving — fires once per day anywhere inside its band — so users opening the app late in the morning still get the morning run. The chip-in-editor is gone. Tracks are managed from a right-side sidebar opened by a Radio-icon button at the top-right of the editor toolbar. Cmd+K is no longer a Copilot entry point — search- only — pending a more intuitive invocation surface later. Today.md ships as the flagship demo of what tracks can do, with a versioned migration system so future template updates roll out cleanly to existing users (existing body preserved, old version backed up). Copilot is tuned to listen for any signal that the user wants something dynamic — not just the literal word "track". Strong phrasings get acted on directly; one-off questions about decaying information are answered first and then offered as a track. New or edited tracks run once by default so the user immediately sees content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4709e6eb89
commit
db6757514c
36 changed files with 2043 additions and 2275 deletions
|
|
@ -108,7 +108,7 @@ Long-form docs for specific features. Read the relevant file before making chang
|
|||
|
||||
| Feature | Doc |
|
||||
|---------|-----|
|
||||
| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
|
||||
| Tracks — frontmatter directives that keep a note's body auto-updated (cron / window / once / event / multi-trigger), section-placement model, sidebar UI, Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
|
||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
|
|
|||
395
apps/x/TRACKS.md
395
apps/x/TRACKS.md
|
|
@ -1,24 +1,29 @@
|
|||
# Track Blocks
|
||||
# Tracks
|
||||
|
||||
> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand.
|
||||
> Frontmatter directives that keep a markdown note's body auto-updated — on a schedule, when a relevant event arrives, or on demand.
|
||||
|
||||
A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary.
|
||||
A track is a single entry in a note's YAML frontmatter under the `track:` array. Each entry defines an instruction, optional triggers (cron / window / once / event — any mix), and (after the first run) some runtime state. When a trigger fires, a background agent edits the **note body** to satisfy the instruction. A note with no `track:` key is just a static note.
|
||||
|
||||
**Example** (a Chicago-time track refreshed hourly):
|
||||
**Example** (a note that shows the current Chicago time, refreshed hourly):
|
||||
|
||||
~~~markdown
|
||||
```track
|
||||
trackId: chicago-time
|
||||
instruction: Show the current time in Chicago, IL in 12-hour format.
|
||||
---
|
||||
track:
|
||||
- id: chicago-time
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
```
|
||||
lastRunAt: "2026-05-07T15:00:01.234Z"
|
||||
lastRunId: "..."
|
||||
lastRunSummary: "Updated — 3:00 PM, Central Time."
|
||||
---
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
2:30 PM, Central Time
|
||||
<!--/track-target:chicago-time-->
|
||||
# Chicago time
|
||||
|
||||
3:00 PM, Central Time
|
||||
~~~
|
||||
|
||||
## Table of Contents
|
||||
|
|
@ -27,70 +32,74 @@ schedule:
|
|||
2. [Architecture at a Glance](#architecture-at-a-glance)
|
||||
3. [Technical Flows](#technical-flows)
|
||||
4. [Schema Reference](#schema-reference)
|
||||
5. [Prompts Catalog](#prompts-catalog)
|
||||
6. [File Map](#file-map)
|
||||
7. [Known Follow-ups](#known-follow-ups)
|
||||
5. [Section Placement](#section-placement)
|
||||
6. [Daily-Note Template & Migrations](#daily-note-template--migrations)
|
||||
7. [Renderer UI](#renderer-ui)
|
||||
8. [Prompts Catalog](#prompts-catalog)
|
||||
9. [File Map](#file-map)
|
||||
|
||||
---
|
||||
|
||||
## Product Overview
|
||||
|
||||
### Trigger types
|
||||
### Triggers
|
||||
|
||||
A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track.
|
||||
A track has zero or more triggers under a single `triggers:` array. Each trigger is one of four types and can be mixed freely:
|
||||
|
||||
| Trigger | When it fires | How to express it |
|
||||
| Type | When it fires | Shape |
|
||||
|---|---|---|
|
||||
| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset |
|
||||
| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` |
|
||||
| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` |
|
||||
| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` |
|
||||
| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` |
|
||||
| **`cron`** | At exact cron times | `{ type: cron, expression: "0 * * * *" }` |
|
||||
| **`window`** | Once per day, anywhere inside a time-of-day band | `{ type: window, startTime: "09:00", endTime: "12:00" }` |
|
||||
| **`once`** | Once at a future time, then never | `{ type: once, runAt: "2026-04-14T09:00:00" }` |
|
||||
| **`event`** | When a matching event arrives (e.g. new Gmail thread) | `{ type: event, matchCriteria: "Emails about Q3 planning" }` |
|
||||
|
||||
Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals.
|
||||
A track with no `triggers` (or an empty array) is **manual-only** — fires only when the user clicks Run in the sidebar.
|
||||
|
||||
`cron` and `once` enforce a 2-minute grace window — if the scheduled time passed more than 2 minutes ago (e.g. the app was offline), the run is skipped, not replayed. `window` is forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, it fires the moment the app is open. The day's cycle is anchored at `startTime`.
|
||||
|
||||
A single track can carry multiple triggers. The flagship example is in Today.md's `priorities` track: three `window` entries (morning / midday / post-lunch) plus two `event` entries (gmail / calendar) — five triggers total, giving a baseline rebuild three times per day plus reactive updates on incoming signals.
|
||||
|
||||
### Creating a track
|
||||
|
||||
Three paths, all produce identical on-disk YAML:
|
||||
Two paths, both producing identical on-disk YAML:
|
||||
|
||||
1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension.
|
||||
2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`.
|
||||
3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name.
|
||||
1. **Hand-written** — type the entry directly into a note's frontmatter under `track:`. The scheduler picks it up on its next 15-second tick.
|
||||
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond the literal word "track" (see "Prompts Catalog → Copilot trigger paragraph" for the signal taxonomy); it loads the `tracks` skill, edits the note's frontmatter via `workspace-edit`, then **runs the track once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
|
||||
|
||||
### Viewing and managing a track
|
||||
There is no inline-block creation flow anymore. The Cmd+K palette is search-only and does not invoke Copilot.
|
||||
|
||||
The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running.
|
||||
### Viewing and managing tracks
|
||||
|
||||
Clicking the chip opens the **track modal**, where everything happens:
|
||||
The editor has a Radio-icon button in the top toolbar (right side) that opens the **Track Sidebar** for the current note. The sidebar:
|
||||
|
||||
- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`).
|
||||
- **Tabs** — *What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata).
|
||||
- **Advanced** — expandable raw-YAML editor for power users.
|
||||
- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region.
|
||||
- **Footer** — *Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately).
|
||||
- **List view** — one row per track in the note's frontmatter. Title is the track's `id`; subtitle is the trigger summary plus a `Paused ·` prefix when applicable, plus the instruction's first line as a tertiary line. A Play button on the right runs that track.
|
||||
- **Detail view** (click a row) — back arrow + tabs (*What* / *Schedule* / *Events* / *Details*), an advanced raw-YAML editor, danger-zone delete, and a footer with "Edit with Copilot" + "Run now".
|
||||
- **Status hook** — `useTrackStatus` subscribes to `tracks:events` IPC; rows show a spinner whenever a track is running, regardless of hover state.
|
||||
|
||||
Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`.
|
||||
Every mutation in the sidebar goes through IPC to the backend — the renderer never writes the file itself. This avoids races with the scheduler/runner writing runtime fields like `lastRunAt`.
|
||||
|
||||
### What Copilot can do
|
||||
### What the runtime agent does
|
||||
|
||||
- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`).
|
||||
- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event.
|
||||
- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`.
|
||||
- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill.
|
||||
When a trigger fires, a background agent ("track-run") receives a short message:
|
||||
- The track's `id`, the workspace-relative path to the note, and a localized timestamp.
|
||||
- The instruction.
|
||||
- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it").
|
||||
|
||||
### After a run
|
||||
The agent's system prompt tells it to:
|
||||
1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
|
||||
2. Find or create the H2 section the instruction names (placement model below).
|
||||
3. Update only that section's content. Never modify YAML frontmatter — that's owned by the user and the runtime.
|
||||
4. After writing, re-check its section's position; cut-and-paste only its own block if it's misplaced (handles the cold-start firing-order problem).
|
||||
5. End with a one-line summary stored as `lastRunSummary`.
|
||||
|
||||
- The **target region** (between `<!--track-target:ID-->` markers) is rewritten by the track-run agent using the `update-track-content` tool.
|
||||
- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML.
|
||||
- The chip pulses while running, then displays the latest `lastRunAt`.
|
||||
- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook.
|
||||
The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP) — there's no special "track-content" tool anymore; tracks just ship general edits.
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
Editor chip (display-only) ──click──► TrackModal (React)
|
||||
Editor toolbar Radio button ─click──► TrackSidebar (React)
|
||||
│
|
||||
├──► IPC: track:get / update /
|
||||
│ replaceYaml / delete / run
|
||||
|
|
@ -98,208 +107,229 @@ Editor chip (display-only) ──click──► TrackModal (React)
|
|||
Backend (main process)
|
||||
├─ Scheduler loop (15 s) ──┐
|
||||
├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent
|
||||
└─ Copilot tool run-track-block ──┘ │
|
||||
└─ Builtin tool run-track ─┘ │
|
||||
▼
|
||||
update-track-content tool
|
||||
workspace-readFile / -edit
|
||||
│
|
||||
▼
|
||||
target region rewritten on disk
|
||||
body region rewritten on disk
|
||||
frontmatter lastRun* patched
|
||||
```
|
||||
|
||||
**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields.
|
||||
**Single-writer invariant** — the renderer is never a file writer for the `track:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrack`, `replaceTrackYaml`, `deleteTrack`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `track:` key (and `buildFrontmatter` splices it back from the original raw on save), so the FrontmatterProperties UI can't accidentally mangle it.
|
||||
|
||||
**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context.
|
||||
**Event contract** — `window.dispatchEvent(CustomEvent('rowboat:open-track-sidebar', { detail: { filePath } }))` is the sole entry point from editor toolbar → sidebar. `rowboat:open-copilot-edit-track` opens the Copilot sidebar with the note attached.
|
||||
|
||||
---
|
||||
|
||||
## Technical Flows
|
||||
|
||||
### 4.1 Scheduling (cron / window / once)
|
||||
### Scheduling (cron / window / once)
|
||||
|
||||
- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
|
||||
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`.
|
||||
- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed.
|
||||
- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
|
||||
- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
|
||||
- **Module**: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
|
||||
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all tracks via `fetchAll(relPath)`.
|
||||
- For each track with `active === true` and at least one timed trigger (`cron` / `window` / `once`), `find` the first due trigger via `isTriggerDue(t, lastRunAt)` (`schedule-utils.ts`).
|
||||
- When due, fire `triggerTrackUpdate(track.id, relPath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
|
||||
- **Grace window** — `cron` and `once` enforce a 2-minute grace; missed schedules are skipped, not replayed. `window` has no grace — anywhere inside the band counts.
|
||||
- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a fire lands strictly after today's `startTime`, that window is done for the day. The strict comparison handles the boundary case (e.g. an 08:00–12:00 + a 12:00–15:00 window each get to fire even when the morning fire happens exactly at 12:00:00).
|
||||
- **Startup** — `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
|
||||
|
||||
### 4.2 Event pipeline
|
||||
### Event pipeline
|
||||
|
||||
**Producers** — any data source that should feed tracks emits events:
|
||||
|
||||
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
|
||||
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`.
|
||||
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
|
||||
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`.
|
||||
|
||||
**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
|
||||
|
||||
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
|
||||
|
||||
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
|
||||
2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`.
|
||||
3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below).
|
||||
4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
|
||||
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/<id>.json`, unlink from `pending/`.
|
||||
2. `listAllTracks()` scans every `.md` under `knowledge/`. Only tracks with at least one `event`-type trigger appear in the routing list; their `eventMatchCriteria` is the joined `matchCriteria` from all event triggers (`'; '`-separated).
|
||||
3. `findCandidates(event, allTracks)` runs Pass 1 LLM routing (below).
|
||||
4. For each candidate, `triggerTrackUpdate(id, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
|
||||
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then move to `events/done/<id>.json`.
|
||||
|
||||
**Pass 1 routing** (`routing.ts:73+ findCandidates`):
|
||||
|
||||
- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
|
||||
**Pass 1 routing** (`routing.ts`):
|
||||
- **Short-circuit** — if `event.targetTrackId` + `event.targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
|
||||
- Filter to `active && instruction && eventMatchCriteria` tracks.
|
||||
- Batches of `BATCH_SIZE = 20`.
|
||||
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file.
|
||||
- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config.
|
||||
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `id` is only unique per file.
|
||||
|
||||
**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region.
|
||||
**Pass 2 decision** happens inside the track-run agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body.
|
||||
|
||||
### 4.3 Run flow (`triggerTrackUpdate`)
|
||||
### Run flow (`triggerTrackUpdate`)
|
||||
|
||||
Module: `packages/core/src/knowledge/track/runner.ts`.
|
||||
|
||||
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
|
||||
2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`.
|
||||
3. **Create agent run** — `createRun({ agentId: 'track-run' })`.
|
||||
4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set.
|
||||
5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
|
||||
6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive.
|
||||
7. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
|
||||
8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`.
|
||||
9. **Store `lastRunSummary`** via `updateTrackBlock`.
|
||||
10. **Emit `track_run_complete`** with `summary` or `error`.
|
||||
11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
|
||||
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${id}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
|
||||
2. **Fetch track** via `fetchAll(filePath)`, locate by `id`.
|
||||
3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff.
|
||||
4. **Create agent run** — `createRun({ agentId: 'track-run' })`.
|
||||
5. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger; for `once` tracks the "done" marker is already set.
|
||||
6. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
|
||||
7. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly.
|
||||
8. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
|
||||
9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`.
|
||||
10. **Patch `lastRunSummary`** via `updateTrack(filePath, id, { lastRunSummary })`.
|
||||
11. **Emit `track_run_complete`** with `summary` or `error`.
|
||||
12. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
|
||||
|
||||
Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`.
|
||||
|
||||
### 4.4 IPC surface
|
||||
### IPC surface
|
||||
|
||||
| Channel | Caller → handler | Purpose |
|
||||
|---|---|---|
|
||||
| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
|
||||
| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` |
|
||||
| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML |
|
||||
| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML |
|
||||
| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region |
|
||||
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook |
|
||||
| `track:run` | Renderer (sidebar Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
|
||||
| `track:get` | Sidebar on detail open | Returns fresh per-track YAML from disk via `fetchYaml(filePath, id)` |
|
||||
| `track:update` | Sidebar toggle / partial edits | `updateTrack` merges a partial into the on-disk entry |
|
||||
| `track:replaceYaml` | Sidebar advanced raw-YAML save | `replaceTrackYaml` validates + writes the full entry |
|
||||
| `track:delete` | Sidebar danger-zone confirm | `deleteTrack` removes the entry from the `track:` array |
|
||||
| `track:setNoteActive` | Background-agents view toggle | Flips `active` on every track in a note |
|
||||
| `track:listNotes` | Background-agents view load | Lists all notes that contain at least one track, with summary fields |
|
||||
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to `useTrackStatus` |
|
||||
|
||||
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`.
|
||||
|
||||
### 4.5 Renderer integration
|
||||
### Concurrency & FIFO guarantees
|
||||
|
||||
- **Chip** — `apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save.
|
||||
- **Modal** — `apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called.
|
||||
- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state.
|
||||
- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file.
|
||||
|
||||
### 4.6 Copilot skill integration
|
||||
|
||||
- **Skill content** — `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called.
|
||||
- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync.
|
||||
- **Skill registration** — `packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array).
|
||||
- **Loading trigger** — `packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests.
|
||||
- **Builtin tools** — `packages/core/src/application/lib/builtin-tools.ts`:
|
||||
- `update-track-content` — low-level: rewrite the target region between `<!--track-target:ID-->` markers. Used mainly by the track-run agent.
|
||||
- `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`).
|
||||
|
||||
### 4.7 Concurrency & FIFO guarantees
|
||||
|
||||
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC.
|
||||
- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file.
|
||||
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too.
|
||||
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`.
|
||||
- **Backend is single writer for `track:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `track:` byte-for-byte across saves.
|
||||
- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file.
|
||||
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially.
|
||||
- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point.
|
||||
|
||||
---
|
||||
|
||||
## Schema Reference
|
||||
|
||||
All canonical schemas live in `packages/shared/src/track-block.ts`:
|
||||
All canonical schemas live in `packages/shared/src/track.ts`:
|
||||
|
||||
- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
|
||||
- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`.
|
||||
- `TrackSchema` — a single entry in the frontmatter `track:` array. Fields: `id` (kebab-case, unique within the note), `instruction`, `active` (default true), `triggers?`, `model?`, `provider?`, `icon?`. **Runtime-managed (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
|
||||
- `TriggerSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' | 'event' }`. Window has just `startTime` + `endTime` (no `cron` field — the cycle is anchored at `startTime`).
|
||||
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`.
|
||||
- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`.
|
||||
|
||||
Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth.
|
||||
The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(TrackSchema))` — so editing `TrackSchema` propagates to the skill on the next build.
|
||||
|
||||
---
|
||||
|
||||
## Section Placement
|
||||
|
||||
Tracks no longer have formal target regions. Each instruction names a section by H2 heading (e.g. *"in a section titled 'Overview' at the top"*) and the agent finds or creates that section.
|
||||
|
||||
The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/track/run-agent.ts`):
|
||||
|
||||
- Sections are **H2 headings** (`## Section Name`). Match by exact heading text.
|
||||
- **Existing**: replace its content (everything between that heading and the next H2 — or end of file). Heading itself stays.
|
||||
- **Missing**: create it. The placement hint determines location:
|
||||
- "at the top" → just below the H1 title.
|
||||
- "after X" → immediately after section X.
|
||||
- no hint → append.
|
||||
- **Self-heal**: after writing, the agent re-checks its section's position. If misplaced (the cold-start case where empty notes get sections in firing order rather than reading order), the agent moves only its **own** H2 block — never reorders other tracks' sections.
|
||||
- **Boundaries**: never modify another track's section content; never duplicate; never touch frontmatter; if the user renamed the heading, recreate per the placement hint.
|
||||
|
||||
This keeps tracks loosely coupled: each one stakes out a section by name, and the rest of the body is entirely the user's.
|
||||
|
||||
---
|
||||
|
||||
## Daily-Note Template & Migrations
|
||||
|
||||
`Today.md` is the canonical demo of what tracks can do. It ships with six tracks (overview/photo combined into one, calendar, emails, what-you-missed, priorities) showing pure-cron, pure-event, multi-window, and multi-trigger configurations.
|
||||
|
||||
**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`:
|
||||
|
||||
- File missing → fresh write at canonical version.
|
||||
- File at-or-above canonical → no-op.
|
||||
- File below canonical → rename existing to `Today.md.bkp.<ISO-stamp>` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template with the body byte-preserved via `splitFrontmatter` from `application/lib/parse-frontmatter.ts`.
|
||||
|
||||
Any change to the canonical TRACKS list, instructions, default body, or trigger config should bump the constant. Existing users will get the new template on next launch with their body sections preserved; their `lastRunAt` and any custom additions to the tracks list are dropped (the .bkp file is the recovery path).
|
||||
|
||||
---
|
||||
|
||||
## Renderer UI
|
||||
|
||||
The chip-in-editor model is gone. Replacements:
|
||||
|
||||
- **Toolbar button** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon ghost button at the top-right of the editor toolbar. `markdown-editor.tsx` passes `onOpenTracks` (only when a `notePath` is available) which dispatches `rowboat:open-track-sidebar` with `{ filePath }`.
|
||||
- **Sidebar** — `apps/renderer/src/components/track-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-track-sidebar`; on open, calls `workspace:readFile` and parses tracks from the frontmatter on the renderer side (uses the same `TrackSchema` from `@x/shared`). All mutations go through IPC.
|
||||
- Constant top header: Radio icon, "Tracks" title, note name subtitle, X close. Uses the `bg-sidebar` design tokens to match the app's left sidebar.
|
||||
- List view: one row per track. Title is `id`; subtitle is the trigger summary (with `Paused ·` prefix); third line is the instruction's first line, truncated. Run button always visible while running, otherwise fades in on hover.
|
||||
- Detail view: back arrow + track id; status row (trigger summary + Active/Paused toggle); tabs (`What` / `Schedule` / `Events` / `Details`); advanced raw-YAML editor; danger-zone delete; footer (Edit with Copilot + Run now).
|
||||
- **Status hook** — `apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` IPC and maintains a `Map<"${id}:${filePath}", RunState>` keyed by composite key.
|
||||
- **Edit-with-Copilot flow** — sidebar dispatches `rowboat:open-copilot-edit-track` (App.tsx listener handles it via `submitFromPalette`).
|
||||
- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` adds `STRUCTURED_KEYS = new Set(['track'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `track:` block back from `preserveRaw` on save. This means the property panel can edit `tags` / `status` / etc. without ever clobbering the tracks frontmatter.
|
||||
|
||||
---
|
||||
|
||||
## Prompts Catalog
|
||||
|
||||
Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`).
|
||||
Every LLM-facing prompt in the feature, with file pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app.
|
||||
|
||||
### 1. Routing system prompt (Pass 1 classifier)
|
||||
|
||||
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them.
|
||||
- **File**: `packages/core/src/knowledge/track/routing.ts:22–37` (`ROUTING_SYSTEM_PROMPT`).
|
||||
- **Inputs**: none interpolated — constant system prompt.
|
||||
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; the run-agent does Pass 2.
|
||||
- **File**: `packages/core/src/knowledge/track/routing.ts` (`ROUTING_SYSTEM_PROMPT`).
|
||||
- **Output**: structured `Pass1OutputSchema` — `{ candidates: { trackId, filePath }[] }`.
|
||||
- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`.
|
||||
- **Invoked by**: `findCandidates()` per batch of 20 tracks via `generateObject({ model, system, prompt, schema })`.
|
||||
|
||||
### 2. Routing user prompt template
|
||||
|
||||
- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt.
|
||||
- **File**: `packages/core/src/knowledge/track/routing.ts:51–66` (`buildRoutingPrompt`).
|
||||
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`).
|
||||
- **Output**: plain text, two sections — `## Event` and `## Track Blocks`.
|
||||
- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below).
|
||||
- **Purpose**: formats the event and the current batch of tracks into the user message for Pass 1.
|
||||
- **File**: `packages/core/src/knowledge/track/routing.ts` (`buildRoutingPrompt`).
|
||||
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `matchCriteria` — joined from all event triggers, `'; '`-separated).
|
||||
- **Output**: plain text, two sections — `## Event` and `## Tracks`.
|
||||
|
||||
### 3. Track-run agent instructions
|
||||
|
||||
- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path.
|
||||
- **File**: `packages/core/src/knowledge/track/run-agent.ts:6–50` (`TRACK_RUN_INSTRUCTIONS`).
|
||||
- **Inputs**: `${WorkDir}` template literal (substituted at module load).
|
||||
- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the section-placement contract (find/create/self-heal), points at the knowledge graph, and prescribes general `workspace-readFile` / `workspace-edit` as the write path.
|
||||
- **File**: `packages/core/src/knowledge/track/run-agent.ts` (`TRACK_RUN_INSTRUCTIONS`).
|
||||
- **Inputs**: `${WorkDir}` template literal substituted at module load.
|
||||
- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`.
|
||||
- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
|
||||
- **Invoked by**: `buildTrackRunAgent()`, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
|
||||
|
||||
### 4. Track-run agent message (`buildMessage`)
|
||||
|
||||
- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`.
|
||||
- **File**: `packages/core/src/knowledge/track/runner.ts:23–62`.
|
||||
- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`.
|
||||
- **Output**: free-form — the agent decides whether to call `update-track-content`.
|
||||
- **Purpose**: the user message seeded into each track-run.
|
||||
- **File**: `packages/core/src/knowledge/track/runner.ts` (`buildMessage`).
|
||||
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `track.id`, `track.instruction`, all event triggers' `matchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`.
|
||||
- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run).
|
||||
|
||||
Three branches:
|
||||
|
||||
- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills.
|
||||
Three branches by `trigger`:
|
||||
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-track` tool uses this path for both plain refreshes and context-biased backfills.
|
||||
- **`timed`** — same as `manual`. Called by the scheduler with no `context`.
|
||||
- **`event`** — adds a **Pass 2 decision block** (lines 45–56). Quoted verbatim:
|
||||
|
||||
> **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
|
||||
>
|
||||
> **Event match criteria for this track:** …
|
||||
>
|
||||
> **Event payload:** …
|
||||
>
|
||||
> **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track.
|
||||
- **`event`** — adds a Pass 2 decision block listing all event triggers' `matchCriteria` (numbered if multiple) and the event payload, with the directive to skip the edit if the event isn't truly relevant.
|
||||
|
||||
### 5. Tracks skill (Copilot-facing)
|
||||
|
||||
- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context.
|
||||
- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant.
|
||||
- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically.
|
||||
- **Purpose**: teaches Copilot the frontmatter `track:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, user-facing language (call them "tracks"; surface the **Track sidebar** by name), the auto-run-once-on-create/edit default, schema, triggers, multi-trigger combos, YAML-safety rules, insertion workflow, and the `run-track` tool with `context` backfills.
|
||||
- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts`. Exported `skill` constant.
|
||||
- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(TrackSchema))` is interpolated into the "Canonical Schema" section. Edits to `TrackSchema` propagate automatically.
|
||||
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires.
|
||||
- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`.
|
||||
- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template.
|
||||
- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`.
|
||||
|
||||
### 6. Copilot trigger paragraph
|
||||
|
||||
- **Purpose**: tells Copilot *when* to load the `tracks` skill.
|
||||
- **File**: `packages/core/src/application/assistant/instructions.ts:73`.
|
||||
- **Inputs**: none; static prose.
|
||||
- **Output**: part of the baseline Copilot system prompt.
|
||||
- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh.
|
||||
- **Purpose**: tells Copilot *when* to load the `tracks` skill, and frames how aggressively to act once loaded.
|
||||
- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Tracks (Auto-Updating Notes)" paragraph).
|
||||
- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…").
|
||||
- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up.
|
||||
- **Anti-signals (do NOT track)**: definitional questions, one-off lookups, manual document editing.
|
||||
|
||||
### 7. `run-track-block` tool — `context` parameter description
|
||||
### 7. `run-track` tool — `context` parameter description
|
||||
|
||||
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema.
|
||||
- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt).
|
||||
- **Inputs**: free-form string from Copilot.
|
||||
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run.
|
||||
- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-track` tool definition).
|
||||
- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), `id`, optional `context`.
|
||||
- **Output**: flows into `triggerTrackUpdate(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message.
|
||||
- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`.
|
||||
- **Key use case**: backfill a newly-created event-driven track so its section isn't empty on day 1.
|
||||
|
||||
### 8. Calendar sync digest (event payload template)
|
||||
|
||||
- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`.
|
||||
- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126.
|
||||
- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync.
|
||||
- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars.
|
||||
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look.
|
||||
- **File**: `packages/core/src/knowledge/sync_calendar.ts` (`summarizeCalendarSync`, wrapped by `publishCalendarSyncEvent()`).
|
||||
- **Output**: markdown with a counts header, `## Changed events` (per-event block: title, ID, time, organizer, location, attendees, truncated description), `## Deleted event IDs`. Capped at ~50 events; descriptions truncated to 500 chars.
|
||||
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -307,37 +337,30 @@ Three branches:
|
|||
|
||||
| Purpose | File |
|
||||
|---|---|
|
||||
| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` |
|
||||
| Zod schemas (track, triggers, events, Pass1) | `packages/shared/src/track.ts` |
|
||||
| IPC channel schemas | `packages/shared/src/ipc.ts` |
|
||||
| IPC handlers (main process) | `apps/main/src/ipc.ts` |
|
||||
| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` |
|
||||
| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` |
|
||||
| File operations (fetchAll / fetch / updateTrack / replaceTrackYaml / deleteTrack / readNoteBody / list / setActive) | `packages/core/src/knowledge/track/fileops.ts` |
|
||||
| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` |
|
||||
| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` |
|
||||
| Trigger due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` |
|
||||
| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` |
|
||||
| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` |
|
||||
| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` |
|
||||
| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` |
|
||||
| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` |
|
||||
| Track state type | `packages/core/src/knowledge/track/types.ts` |
|
||||
| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` |
|
||||
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
|
||||
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
|
||||
| Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` |
|
||||
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
|
||||
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
|
||||
| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` |
|
||||
| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` |
|
||||
| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` |
|
||||
| `run-track` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` |
|
||||
| Editor toolbar (Radio button → sidebar) | `apps/renderer/src/components/editor-toolbar.tsx` |
|
||||
| Track sidebar (list + detail view) | `apps/renderer/src/components/track-sidebar.tsx` |
|
||||
| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` |
|
||||
| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` |
|
||||
| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` |
|
||||
| Renderer frontmatter helper (preserves `track:`) | `apps/renderer/src/lib/frontmatter.ts` |
|
||||
| App-level listeners (sidebar open + Copilot edit) | `apps/renderer/src/App.tsx` |
|
||||
| CSS (sidebar styles, legacy filename) | `apps/renderer/src/styles/track-modal.css`, `apps/renderer/src/styles/editor.css` |
|
||||
| Main process startup (schedulers & processors) | `apps/main/src/main.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Known Follow-ups
|
||||
|
||||
- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields.
|
||||
- **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save.
|
||||
- **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor).
|
||||
|
||||
- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow.
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ import {
|
|||
fetchYaml,
|
||||
listNotesWithTracks,
|
||||
setNoteTracksActive,
|
||||
updateTrackBlock,
|
||||
replaceTrackBlockYaml,
|
||||
deleteTrackBlock,
|
||||
updateTrack,
|
||||
replaceTrackYaml,
|
||||
deleteTrack,
|
||||
} from '@x/core/dist/knowledge/track/fileops.js';
|
||||
import { browserIpcHandlers } from './browser/ipc.js';
|
||||
|
||||
|
|
@ -815,12 +815,12 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
// Track handlers
|
||||
'track:run': async (_event, args) => {
|
||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||
const result = await triggerTrackUpdate(args.id, args.filePath);
|
||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
||||
},
|
||||
'track:get': async (_event, args) => {
|
||||
try {
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
const yaml = await fetchYaml(args.filePath, args.id);
|
||||
if (yaml === null) return { success: false, error: 'Track not found' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
|
|
@ -829,8 +829,8 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'track:update': async (_event, args) => {
|
||||
try {
|
||||
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
await updateTrack(args.filePath, args.id, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.id);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after update' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
|
|
@ -839,8 +839,8 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'track:replaceYaml': async (_event, args) => {
|
||||
try {
|
||||
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
await replaceTrackYaml(args.filePath, args.id, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.id);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
|
|
@ -849,7 +849,7 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'track:delete': async (_event, args) => {
|
||||
try {
|
||||
await deleteTrackBlock(args.filePath, args.trackId);
|
||||
await deleteTrack(args.filePath, args.id);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
@ -858,7 +858,7 @@ export function setupIpcHandlers() {
|
|||
'track:setNoteActive': async (_event, args) => {
|
||||
try {
|
||||
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
|
||||
if (!note) return { success: false, error: 'No track blocks found in note' };
|
||||
if (!note) return { success: false, error: 'No tracks found in note' };
|
||||
return { success: true, note };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
|||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
|
||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackModal } from '@/components/track-modal'
|
||||
import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackSidebar } from '@/components/track-sidebar'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
|
|
@ -351,20 +351,20 @@ const buildSuggestedTopicExplorePrompt = ({
|
|||
'Treat a clear confirmation from me as explicit approval to proceed.',
|
||||
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
|
||||
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
|
||||
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||
'Add a track to the note (a `track:` entry in its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const buildBackgroundAgentSetupPrompt = () => [
|
||||
'Help me set up a background agent.',
|
||||
'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.',
|
||||
'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.',
|
||||
'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.',
|
||||
'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.',
|
||||
'Start with a short, plain-English explanation of what a background agent is.',
|
||||
'Do not make the explanation too terse.',
|
||||
'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.',
|
||||
'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.',
|
||||
'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.',
|
||||
'In the first reply, tell me that you will create this in my Tasks folder by default.',
|
||||
'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.',
|
||||
'Then ask only what I want it to monitor or update and how often I want it to run.',
|
||||
|
|
@ -874,7 +874,6 @@ function App() {
|
|||
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
||||
// queued across the new-chat-tab state flush before submit fires.
|
||||
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
|
||||
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null)
|
||||
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
|
||||
|
||||
const handleSubmitRecording = useCallback(() => {
|
||||
|
|
@ -2933,8 +2932,7 @@ function App() {
|
|||
setPendingPaletteSubmit(null)
|
||||
}, [pendingPaletteSubmit])
|
||||
|
||||
// Listener for track-block "Edit with Copilot" events
|
||||
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
|
||||
// Listener for "Edit with Copilot" events from the track sidebar.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<{
|
||||
|
|
@ -3539,16 +3537,11 @@ function App() {
|
|||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
|
||||
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode).
|
||||
// If an editor tab is currently active, capture cursor context so Chat mode shows the
|
||||
// note + line as a removable chip.
|
||||
// Keyboard shortcut: Cmd+K / Ctrl+K opens the search palette (search-only).
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
const activeId = activeFileTabIdRef.current
|
||||
const handle = activeId ? editorRefsByTabId.current.get(activeId) : null
|
||||
setPaletteContext(handle?.getCursorContext() ?? null)
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -5090,12 +5083,10 @@ function App() {
|
|||
onOpenChange={setIsSearchOpen}
|
||||
onSelectFile={navigateToFile}
|
||||
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
|
||||
initialContext={paletteContext}
|
||||
onChatSubmit={submitFromPalette}
|
||||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<TrackModal />
|
||||
<TrackSidebar />
|
||||
<OnboardingModal
|
||||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: Ba
|
|||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Notes that contain track blocks. Toggle a note inactive to pause every background agent in it.
|
||||
Notes that contain tracks. Toggle a note inactive to pause every background agent in it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
FileTextIcon,
|
||||
FileIcon,
|
||||
FileTypeIcon,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -42,6 +43,7 @@ interface EditorToolbarProps {
|
|||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onExport?: (format: 'md' | 'pdf' | 'docx') => void
|
||||
onOpenTracks?: () => void
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
|
|
@ -49,6 +51,7 @@ export function EditorToolbar({
|
|||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onExport,
|
||||
onOpenTracks,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -385,6 +388,19 @@ export function EditorToolbar({
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tracks — pushed to far right */}
|
||||
{onOpenTracks && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onOpenTracks}
|
||||
title="Tracks"
|
||||
className="ml-auto"
|
||||
>
|
||||
<Radio className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
|||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||
function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null {
|
||||
const record: Record<string, string | string[]> = {}
|
||||
for (const { key, value } of fields) {
|
||||
if (key.trim()) record[key.trim()] = value
|
||||
}
|
||||
return buildFrontmatter(record)
|
||||
return buildFrontmatter(record, preserveRaw)
|
||||
}
|
||||
|
||||
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||
|
|
@ -45,10 +45,12 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
|
|||
}, [editingNewKey])
|
||||
|
||||
const commit = useCallback((updated: FieldEntry[]) => {
|
||||
const newRaw = fieldsToRaw(updated)
|
||||
// Use the latest raw seen as the preserve-source so structured keys
|
||||
// (like `track:`) survive a round-trip through this UI.
|
||||
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
|
||||
lastCommittedRaw.current = newRaw
|
||||
onRawChange(newRaw)
|
||||
}, [onRawChange])
|
||||
}, [onRawChange, raw])
|
||||
|
||||
// For scalar fields: update local state immediately, commit on blur
|
||||
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
|
|||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { TrackBlockExtension } from '@/extensions/track-block'
|
||||
import { PromptBlockExtension } from '@/extensions/prompt-block'
|
||||
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
||||
|
|
@ -48,36 +46,6 @@ function preprocessMarkdown(markdown: string): string {
|
|||
})
|
||||
}
|
||||
|
||||
// Convert track-target open/close HTML comment markers into placeholder divs
|
||||
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
|
||||
// nodes. Content *between* the markers is left untouched — tiptap-markdown
|
||||
// parses it naturally as whatever it is (paragraphs, lists, custom-block
|
||||
// fences, etc.), all rendered live by the existing extension set.
|
||||
//
|
||||
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
|
||||
// line until a blank line terminates it, and markdown inline rules (bold,
|
||||
// italics, links) don't apply inside the block. Without surrounding blank
|
||||
// lines, the line right after our placeholder div gets absorbed as HTML and
|
||||
// its markdown is not parsed.
|
||||
//
|
||||
// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n`
|
||||
// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks
|
||||
// on save; a `\n?` regex on reload would only consume one of those two
|
||||
// newlines, so every cycle would add a net newline on each side of every
|
||||
// marker — causing tracks running on an open note to steadily inflate the
|
||||
// file with blank lines around target regions.
|
||||
function preprocessTrackTargets(md: string): string {
|
||||
return md
|
||||
.replace(
|
||||
/\n*<!--track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
.replace(
|
||||
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
}
|
||||
|
||||
// Post-process to clean up any zero-width spaces in the output
|
||||
function postprocessMarkdown(markdown: string): string {
|
||||
// Remove lines that contain only the zero-width space marker
|
||||
|
|
@ -189,12 +157,6 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'promptBlock':
|
||||
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackBlock':
|
||||
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackTargetOpen':
|
||||
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'trackTargetClose':
|
||||
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
|
|
@ -697,10 +659,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
PromptBlockExtension.configure({ notePath }),
|
||||
TrackTargetOpenExtension,
|
||||
TrackTargetCloseExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
|
|
@ -1100,9 +1059,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines, then wrap track-target comment
|
||||
// regions into placeholder divs so TrackTargetExtension can pick them up.
|
||||
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
isInternalUpdate.current = false
|
||||
|
|
@ -1472,6 +1429,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onExport={onExport}
|
||||
onOpenTracks={notePath ? () => {
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', {
|
||||
detail: { filePath: notePath },
|
||||
}))
|
||||
} : undefined}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
|
||||
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
|
|
@ -22,13 +22,14 @@ interface SearchResult {
|
|||
}
|
||||
|
||||
type SearchType = 'knowledge' | 'chat'
|
||||
type Mode = 'chat' | 'search'
|
||||
|
||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||
if (section === 'knowledge') return ['knowledge']
|
||||
return ['chat'] // "tasks" tab maps to chat
|
||||
return ['chat']
|
||||
}
|
||||
|
||||
// Retained for any remaining programmatic Copilot entry points (background-agent
|
||||
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
|
||||
export type CommandPaletteContext = {
|
||||
path: string
|
||||
lineNumber: number
|
||||
|
|
@ -43,12 +44,8 @@ export type CommandPaletteMention = {
|
|||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
// Search mode
|
||||
onSelectFile: (path: string) => void
|
||||
onSelectRun: (runId: string) => void
|
||||
// Chat mode
|
||||
initialContext?: CommandPaletteContext | null
|
||||
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
|
|
@ -56,14 +53,8 @@ export function CommandPalette({
|
|||
onOpenChange,
|
||||
onSelectFile,
|
||||
onSelectRun,
|
||||
initialContext,
|
||||
onChatSubmit,
|
||||
}: CommandPaletteProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [mode, setMode] = useState<Mode>('chat')
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
|
||||
const chatInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -74,45 +65,23 @@ export function CommandPalette({
|
|||
)
|
||||
const debouncedQuery = useDebounce(query, 250)
|
||||
|
||||
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
|
||||
// and reset search filters.
|
||||
// Sync filters and clear query when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('chat')
|
||||
setChatInput('')
|
||||
setContextChip(initialContext ?? null)
|
||||
setQuery('')
|
||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||
}
|
||||
}, [open, activeSection, initialContext])
|
||||
}, [open, activeSection])
|
||||
|
||||
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
|
||||
// swallow it. Only fires while the dialog is open.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
|
||||
}
|
||||
document.addEventListener('keydown', handler, true)
|
||||
return () => document.removeEventListener('keydown', handler, true)
|
||||
searchInputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
// Refocus the appropriate input on mode change so the user can start typing immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const target = mode === 'chat' ? chatInputRef : searchInputRef
|
||||
target.current?.focus()
|
||||
}, [open, mode])
|
||||
|
||||
const toggleType = useCallback((type: SearchType) => {
|
||||
setActiveTypes(new Set([type]))
|
||||
}, [])
|
||||
|
||||
// Search query effect (only meaningful while in search mode, but the debounce keeps running
|
||||
// harmlessly otherwise — empty query skips the IPC call below).
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([])
|
||||
|
|
@ -133,25 +102,19 @@ export function CommandPalette({
|
|||
})
|
||||
.catch((err) => {
|
||||
console.error('Search failed:', err)
|
||||
if (!cancelled) {
|
||||
setResults([])
|
||||
}
|
||||
if (!cancelled) setResults([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
if (!cancelled) setIsSearching(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [debouncedQuery, activeTypes])
|
||||
|
||||
// Reset transient state on close.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setChatInput('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
|
@ -164,20 +127,6 @@ export function CommandPalette({
|
|||
}
|
||||
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||
|
||||
const submitChat = useCallback(() => {
|
||||
const text = chatInput.trim()
|
||||
if (!text && !contextChip) return
|
||||
const mention: CommandPaletteMention | null = contextChip
|
||||
? {
|
||||
path: contextChip.path,
|
||||
displayName: deriveDisplayName(contextChip.path),
|
||||
lineNumber: contextChip.lineNumber,
|
||||
}
|
||||
: null
|
||||
onChatSubmit(text, mention)
|
||||
onOpenChange(false)
|
||||
}, [chatInput, contextChip, onChatSubmit, onOpenChange])
|
||||
|
||||
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||
const chatResults = results.filter(r => r.type === 'chat')
|
||||
|
||||
|
|
@ -185,74 +134,11 @@ export function CommandPalette({
|
|||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
|
||||
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
|
||||
title="Search"
|
||||
description="Search across knowledge and chats"
|
||||
showCloseButton={false}
|
||||
className="top-[20%] translate-y-0"
|
||||
>
|
||||
{/* Mode strip */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||
<ModeButton
|
||||
active={mode === 'chat'}
|
||||
onClick={() => setMode('chat')}
|
||||
icon={<MessageSquareIcon className="size-3" />}
|
||||
label="Chat"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === 'search'}
|
||||
onClick={() => setMode('search')}
|
||||
icon={<FileTextIcon className="size-3" />}
|
||||
label="Search"
|
||||
/>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
|
||||
</div>
|
||||
|
||||
{mode === 'chat' ? (
|
||||
<div className="flex flex-col">
|
||||
<input
|
||||
ref={chatInputRef}
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// cmdk's Command component intercepts Enter for item selection — stop it
|
||||
// before bubbling so we control the chat submit ourselves.
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
submitChat()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask copilot anything…"
|
||||
autoFocus
|
||||
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{contextChip && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
|
||||
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextChip(null)}
|
||||
aria-label="Remove context"
|
||||
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
{!contextChip && (
|
||||
<div className="flex items-center px-3 pb-3">
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
|
|
@ -315,48 +201,10 @@ export function CommandPalette({
|
|||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
||||
function deriveDisplayName(path: string): string {
|
||||
const base = path.split('/').pop() ?? path
|
||||
return base.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
active,
|
||||
onClick,
|
||||
|
|
@ -370,17 +218,19 @@ function FilterToggle({
|
|||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Back-compat export: thin alias to CommandPalette.
|
||||
export const SearchDialog = CommandPalette
|
||||
|
|
|
|||
|
|
@ -1,530 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
import type { OpenTrackModalDetail } from '@/extensions/track-block'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
|
||||
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
|
||||
if (schedule.type === 'once') {
|
||||
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
|
||||
}
|
||||
if (schedule.type === 'cron') {
|
||||
return { icon: 'timer', text: describeCron(schedule.expression) }
|
||||
}
|
||||
if (schedule.type === 'window') {
|
||||
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` }
|
||||
}
|
||||
return { icon: 'calendar', text: 'Scheduled' }
|
||||
}
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
|
||||
const [yaml, setYaml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Listen for the open event and seed modal state.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackModalDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.trackId || !d?.filePath) return
|
||||
setDetail(d)
|
||||
setYaml(d.initialYaml ?? '')
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void fetchFresh(d)
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
if (!yaml) return null
|
||||
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
|
||||
}, [yaml])
|
||||
|
||||
const trackId = track?.trackId ?? detail?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const model = track?.model ?? ''
|
||||
const provider = track?.provider ?? ''
|
||||
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What to track', visible: true },
|
||||
{ key: 'when', label: 'When to run', visible: !!schedule },
|
||||
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule, eventMatchCriteria])
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IPC-backed mutations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
updates,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleToggleActive = useCallback(() => {
|
||||
void runUpdate({ active: !active })
|
||||
}, [active, runUpdate])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!detail || isRunning) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [detail, isRunning])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
yaml: rawDraft,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
setEditingRaw(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail, rawDraft])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
if (res?.success) {
|
||||
// Tell the editor to remove the node so Tiptap's next save doesn't
|
||||
// re-create the track block on disk.
|
||||
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
|
||||
setOpen(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!detail) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: {
|
||||
trackId: detail.trackId,
|
||||
filePath: detail.filePath,
|
||||
},
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [detail])
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<div className="track-modal-header">
|
||||
<div className="track-modal-header-left">
|
||||
<div className="track-modal-icon-wrap">
|
||||
<Radio size={16} />
|
||||
</div>
|
||||
<div className="track-modal-title-col">
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogTitle className="track-modal-title">
|
||||
{trackId || 'Track'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="track-modal-subtitle">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
|
||||
{scheduleSummary.text}
|
||||
{eventMatchCriteria && triggerType === 'scheduled' && (
|
||||
<span className="track-modal-subtitle-sep">· also event-driven</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-modal-header-actions">
|
||||
<label className="track-modal-toggle">
|
||||
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
|
||||
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="track-modal-tabs">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="track-modal-body">
|
||||
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>}
|
||||
|
||||
{activeTab === 'what' && (
|
||||
<div className="track-modal-prose">
|
||||
{instruction
|
||||
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-modal-empty">No instruction set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && schedule && (
|
||||
<div className="track-modal-when">
|
||||
<div className="track-modal-when-headline">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
|
||||
<span>{scheduleSummary.text}</span>
|
||||
</div>
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
|
||||
{schedule.type === 'cron' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'window' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
|
||||
<dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'once' && (
|
||||
<>
|
||||
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="track-modal-prose">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-modal-empty">No event matching set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-details">
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{model && (<>
|
||||
<dt>Model</dt><dd><code>{model}</code></dd>
|
||||
</>)}
|
||||
{provider && (<>
|
||||
<dt>Provider</dt><dd><code>{provider}</code></dd>
|
||||
</>)}
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
{lastRunId && (<>
|
||||
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
|
||||
</>)}
|
||||
{lastRunSummary && (<>
|
||||
<dt>Summary</dt><dd>{lastRunSummary}</dd>
|
||||
</>)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced (raw YAML) — all tabs */}
|
||||
<div className="track-modal-advanced">
|
||||
<button
|
||||
className="track-modal-advanced-toggle"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) {
|
||||
setRawDraft(yaml)
|
||||
setEditingRaw(true)
|
||||
} else {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<Code2 size={12} />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="track-modal-raw-editor">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="track-modal-textarea"
|
||||
/>
|
||||
<div className="track-modal-raw-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRaw}
|
||||
disabled={saving || rawDraft.trim() === yaml.trim()}
|
||||
>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — on Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-danger-zone">
|
||||
{confirmingDelete ? (
|
||||
<div className="track-modal-confirm">
|
||||
<span>Delete this track and its generated content?</span>
|
||||
<div className="track-modal-confirm-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Yes, delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="track-modal-delete-btn"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete track block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="track-modal-error">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="track-modal-footer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditWithCopilot}
|
||||
disabled={saving}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || saving}
|
||||
className="track-modal-run-btn"
|
||||
>
|
||||
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
627
apps/x/apps/renderer/src/components/track-sidebar.tsx
Normal file
627
apps/x/apps/renderer/src/components/track-sidebar.tsx
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp, ChevronLeft, X,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackSchema, type Trigger } from '@x/shared/dist/track.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
export type OpenTrackSidebarDetail = {
|
||||
filePath: string
|
||||
selectId?: string
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function describeTrigger(t: Trigger): ScheduleSummary {
|
||||
if (t.type === 'once') return { icon: 'target', text: `Once at ${formatDateTime(t.runAt)}` }
|
||||
if (t.type === 'cron') return { icon: 'timer', text: describeCron(t.expression) }
|
||||
if (t.type === 'window') return { icon: 'calendar', text: `${t.startTime}–${t.endTime}` }
|
||||
return { icon: 'bolt', text: 'Event-driven' }
|
||||
}
|
||||
|
||||
function summarizeTriggers(triggers: Trigger[] | undefined): ScheduleSummary {
|
||||
if (!triggers || triggers.length === 0) return { icon: 'bolt', text: 'Manual only' }
|
||||
const timed = triggers.filter(t => t.type !== 'event')
|
||||
const events = triggers.filter(t => t.type === 'event')
|
||||
if (timed.length === 0) {
|
||||
return { icon: 'bolt', text: events.length > 1 ? `${events.length} event triggers` : 'Event-driven' }
|
||||
}
|
||||
const first = describeTrigger(timed[0])
|
||||
let text = first.text
|
||||
if (timed.length > 1) text += ` (+${timed.length - 1})`
|
||||
if (events.length > 0) text += ' · also event-driven'
|
||||
return { icon: first.icon, text }
|
||||
}
|
||||
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
||||
type Track = z.infer<typeof TrackSchema>
|
||||
|
||||
function parseTracksFromFile(content: string): Track[] {
|
||||
if (!content.startsWith('---')) return []
|
||||
const close = /\r?\n---\r?\n/.exec(content)
|
||||
if (!close) return []
|
||||
const yamlText = content.slice(3, close.index).trim()
|
||||
if (!yamlText) return []
|
||||
let fm: unknown
|
||||
try { fm = parseYaml(yamlText) } catch { return [] }
|
||||
if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return []
|
||||
const raw = (fm as Record<string, unknown>).track
|
||||
if (!Array.isArray(raw)) return []
|
||||
const tracks: Track[] = []
|
||||
for (const entry of raw) {
|
||||
const result = TrackSchema.safeParse(entry)
|
||||
if (result.success) tracks.push(result.data)
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackSidebar() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filePath, setFilePath] = useState<string>('')
|
||||
const [tracks, setTracks] = useState<Track[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Detail-view state (per-track local UI)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const knowledgeRelPath = useMemo(() => stripKnowledgePrefix(filePath), [filePath])
|
||||
const allTrackStatus = useTrackStatus()
|
||||
|
||||
const refresh = useCallback(async (relPath: string) => {
|
||||
if (!relPath) { setTracks([]); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('workspace:readFile', { path: `knowledge/${relPath}` })
|
||||
if (res?.data) {
|
||||
setTracks(parseTracksFromFile(res.data))
|
||||
} else {
|
||||
setTracks([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setTracks([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackSidebarDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.filePath) return
|
||||
setFilePath(d.filePath)
|
||||
setSelectedId(d.selectId ?? null)
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void refresh(stripKnowledgePrefix(d.filePath))
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-sidebar', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-sidebar', handler as EventListener)
|
||||
}, [refresh])
|
||||
|
||||
// Re-fetch when a run completes for a track in this file.
|
||||
useEffect(() => {
|
||||
if (!open || !knowledgeRelPath) return
|
||||
let stale = false
|
||||
for (const [, state] of allTrackStatus) {
|
||||
if (state.status === 'done' || state.status === 'error') {
|
||||
stale = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (stale) void refresh(knowledgeRelPath)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allTrackStatus, open, knowledgeRelPath])
|
||||
|
||||
const selected = useMemo(
|
||||
() => (selectedId ? tracks.find(t => t.id === selectedId) ?? null : null),
|
||||
[selectedId, tracks],
|
||||
)
|
||||
|
||||
// Seed raw editor draft when entering advanced mode.
|
||||
useEffect(() => {
|
||||
if (showAdvanced && selected) {
|
||||
try {
|
||||
// Lazy import yaml stringify only when needed; avoid top-level dep cycle.
|
||||
import('yaml').then(({ stringify }) => {
|
||||
setRawDraft(stringify(selected).trimEnd())
|
||||
})
|
||||
} catch {
|
||||
setRawDraft('')
|
||||
}
|
||||
}
|
||||
}, [showAdvanced, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const runUpdate = useCallback(async (id: string, updates: Record<string, unknown>) => {
|
||||
if (!knowledgeRelPath) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', { id, filePath: knowledgeRelPath, updates })
|
||||
if (!res?.success && res?.error) setError(res.error)
|
||||
await refresh(knowledgeRelPath)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, refresh])
|
||||
|
||||
const handleToggleActive = useCallback((id: string, currentlyActive: boolean) => {
|
||||
void runUpdate(id, { active: !currentlyActive })
|
||||
}, [runUpdate])
|
||||
|
||||
const handleRun = useCallback(async (id: string) => {
|
||||
if (!knowledgeRelPath) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', { id, filePath: knowledgeRelPath })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [knowledgeRelPath])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!knowledgeRelPath || !selectedId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', { id: selectedId, filePath: knowledgeRelPath, yaml: rawDraft })
|
||||
if (res?.success) {
|
||||
setEditingRaw(false)
|
||||
await refresh(knowledgeRelPath)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, selectedId, rawDraft, refresh])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!knowledgeRelPath || !selectedId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', { id: selectedId, filePath: knowledgeRelPath })
|
||||
if (res?.success) {
|
||||
setSelectedId(null)
|
||||
setConfirmingDelete(false)
|
||||
await refresh(knowledgeRelPath)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [knowledgeRelPath, selectedId, refresh])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!filePath || !selectedId) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: { trackId: selectedId, filePath },
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [filePath, selectedId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const noteTitle = filePath
|
||||
? (filePath.split('/').pop() ?? filePath).replace(/\.md$/, '')
|
||||
: 'Tracks'
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-60 flex w-[min(420px,calc(100vw-2rem))] flex-col overflow-hidden border-l border-border bg-background shadow-2xl">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-3 text-sidebar-foreground">
|
||||
<Radio className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium">Tracks</span>
|
||||
<span className="truncate text-xs text-sidebar-foreground/60">{noteTitle}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selected && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && tracks.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-1.5 px-6 py-12 text-center">
|
||||
<Radio className="size-6 text-muted-foreground/50" />
|
||||
<div className="text-sm text-muted-foreground">No tracks in this note yet.</div>
|
||||
<div className="text-xs text-muted-foreground/70">
|
||||
Ask Copilot “track Chicago time hourly” to add one.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border">
|
||||
{tracks.map(t => {
|
||||
const sched = summarizeTriggers(t.triggers)
|
||||
const runState = allTrackStatus.get(`${t.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
const paused = t.active === false
|
||||
const instructionPreview = t.instruction.split('\n')[0].trim()
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-accent ${paused ? 'opacity-60' : ''}`}
|
||||
onClick={() => { setSelectedId(t.id); setActiveTab('what') }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">{t.id}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{paused ? 'Paused · ' : ''}{sched.text}
|
||||
</span>
|
||||
{instructionPreview && (
|
||||
<span className="truncate text-xs text-muted-foreground/70">
|
||||
{instructionPreview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity hover:bg-background hover:text-foreground ${isRunning ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
onClick={(ev) => { ev.stopPropagation(); void handleRun(t.id) }}
|
||||
disabled={isRunning}
|
||||
aria-label={isRunning ? `Running ${t.id}` : `Run ${t.id}`}
|
||||
title={isRunning ? `Running…` : `Run ${t.id}`}
|
||||
>
|
||||
{isRunning ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
||||
</button>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (() => {
|
||||
const triggers: Trigger[] = selected.triggers ?? []
|
||||
const timedTriggers = triggers.filter((t): t is Exclude<Trigger, { type: 'event' }> => t.type !== 'event')
|
||||
const eventTriggers = triggers.filter((t): t is Extract<Trigger, { type: 'event' }> => t.type === 'event')
|
||||
const sched = summarizeTriggers(triggers)
|
||||
const runState = allTrackStatus.get(`${selected.id}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
const paused = selected.active === false
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What', visible: true },
|
||||
{ key: 'when', label: 'Schedule', visible: timedTriggers.length > 0 },
|
||||
{ key: 'event', label: 'Events', visible: eventTriggers.length > 0 },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-1 flex-col overflow-hidden ${paused ? 'opacity-80' : ''}`}>
|
||||
{/* Subheader: back arrow + track id */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSelectedId(null)
|
||||
setShowAdvanced(false)
|
||||
setEditingRaw(false)
|
||||
setConfirmingDelete(false)
|
||||
}}
|
||||
aria-label="Back to tracks"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<span className="truncate text-sm font-medium">{selected.id}</span>
|
||||
</div>
|
||||
|
||||
{/* Status row: schedule summary + active toggle */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-xs text-muted-foreground">{sched.text}</span>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<Switch
|
||||
checked={!paused}
|
||||
onCheckedChange={() => handleToggleActive(selected.id, !paused)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{paused ? 'Paused' : 'Active'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-3 py-3">
|
||||
{activeTab === 'what' && (
|
||||
<div className="text-sm leading-relaxed">
|
||||
{selected.instruction ? (
|
||||
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{selected.instruction}
|
||||
</Streamdown>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No instruction set.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && timedTriggers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{timedTriggers.map((trig, idx) => {
|
||||
const tSched = describeTrigger(trig)
|
||||
return (
|
||||
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<ScheduleIcon icon={tSched.icon} size={14} />
|
||||
<span>{tSched.text}</span>
|
||||
</div>
|
||||
<DetailGrid>
|
||||
<DetailRow label="Type" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.type}</code>} />
|
||||
{trig.type === 'cron' && (
|
||||
<DetailRow label="Expression" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{trig.expression}</code>} />
|
||||
)}
|
||||
{trig.type === 'window' && (
|
||||
<DetailRow label="Window" value={`${trig.startTime} – ${trig.endTime}`} />
|
||||
)}
|
||||
{trig.type === 'once' && (
|
||||
<DetailRow label="Runs at" value={formatDateTime(trig.runAt)} />
|
||||
)}
|
||||
</DetailGrid>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{eventTriggers.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">No event matching set.</span>
|
||||
) : eventTriggers.map((trig, idx) => (
|
||||
<div key={idx} className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
||||
<Streamdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{trig.matchCriteria}
|
||||
</Streamdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<DetailGrid>
|
||||
<DetailRow label="ID" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.id}</code>} />
|
||||
<DetailRow label="File" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px] break-all">{filePath}</code>} />
|
||||
<DetailRow label="Status" value={paused ? 'Paused' : 'Active'} />
|
||||
{selected.model && <DetailRow label="Model" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.model}</code>} />}
|
||||
{selected.provider && <DetailRow label="Provider" value={<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{selected.provider}</code>} />}
|
||||
{selected.lastRunAt && <DetailRow label="Last run" value={formatDateTime(selected.lastRunAt)} />}
|
||||
{selected.lastRunSummary && <DetailRow label="Summary" value={selected.lastRunSummary} />}
|
||||
</DetailGrid>
|
||||
)}
|
||||
|
||||
{/* Advanced — raw YAML */}
|
||||
<div className="mt-6 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
setEditingRaw(next)
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
<Code2 className="size-3" />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveRaw} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-sm">
|
||||
<span className="text-destructive">Delete this track?</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete track
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/20 px-3 py-2.5">
|
||||
<Button variant="outline" size="sm" onClick={handleEditWithCopilot} disabled={saving}>
|
||||
<Sparkles className="size-3" />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleRun(selected.id)}
|
||||
disabled={isRunning || saving}
|
||||
>
|
||||
{isRunning ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 text-xs">
|
||||
{children}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground">{value}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo, type ComponentType } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, Loader2, type LucideProps } from 'lucide-react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function resolveIcon(iconName: string): ComponentType<LucideProps> | null {
|
||||
const key = iconName
|
||||
.split('-')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join('')
|
||||
const component = (LucideIcons as Record<string, unknown>)[key]
|
||||
if (component != null) return component as ComponentType<LucideProps>
|
||||
return null
|
||||
}
|
||||
|
||||
function TrackIcon({ icon, size }: { icon?: string; size: number }) {
|
||||
if (icon) {
|
||||
const Icon = resolveIcon(icon)
|
||||
if (Icon) return <Icon size={size} />
|
||||
}
|
||||
return <Radio size={size} />
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
// Detail shape for the open-track-modal window event. Defined here so the
|
||||
// consumer (TrackModal) can import it without a circular dependency.
|
||||
export type OpenTrackModalDetail = {
|
||||
trackId: string
|
||||
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
|
||||
filePath: string
|
||||
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
|
||||
initialYaml: string
|
||||
/** Invoked after a successful IPC delete so the editor can remove the node. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chip (display-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrackBlockView({ node, deleteNode, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(cleaned))
|
||||
} catch(error) { console.error('error', error); return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!trackId || !notePath) return
|
||||
const detail: OpenTrackModalDetail = {
|
||||
trackId,
|
||||
filePath: notePath,
|
||||
initialYaml: raw,
|
||||
onDeleted: () => deleteNode(),
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||
'rowboat:open-track-modal',
|
||||
{ detail },
|
||||
))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="track-block-chip-wrapper"
|
||||
data-type="track-block"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
data-trackid={trackId}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||
>
|
||||
<span className="track-block-chip-icon">
|
||||
{isRunning
|
||||
? <Loader2 size={24} className="animate-spin" />
|
||||
: <TrackIcon icon={track?.icon} size={24} />}
|
||||
</span>
|
||||
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||
{instruction && <span className="track-block-chip-sep">·</span>}
|
||||
{instruction && (
|
||||
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||
)}
|
||||
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-track')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TrackBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```track\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Track target markers — two Tiptap atom nodes that represent the open and
|
||||
* close HTML comment markers bracketing a track's output region on disk:
|
||||
*
|
||||
* <!--track-target:ID--> → TrackTargetOpenExtension
|
||||
* content in between → regular Tiptap nodes (paragraphs, lists,
|
||||
* custom blocks, whatever tiptap-markdown parses)
|
||||
* <!--/track-target:ID--> → TrackTargetCloseExtension
|
||||
*
|
||||
* The markers are *semantic boundaries*, not a UI container. Content between
|
||||
* them is real, editable document content — fully rendered by the existing
|
||||
* extension set and freely editable by the user. The backend's updateContent()
|
||||
* in fileops.ts still locates the region on disk by these comment markers.
|
||||
*
|
||||
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
|
||||
* regex replace, converting each comment into a placeholder div that these
|
||||
* extensions' parseHTML rules pick up. No content capture.
|
||||
*
|
||||
* Save path: both Tiptap's built-in markdown serializer
|
||||
* (`addStorage().markdown.serialize`) AND the app's custom serializer
|
||||
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
|
||||
* back out — they must stay in sync.
|
||||
*/
|
||||
|
||||
type MarkerVariant = 'open' | 'close'
|
||||
|
||||
function buildMarkerExtension(variant: MarkerVariant) {
|
||||
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
|
||||
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
|
||||
const commentFor = (id: string) =>
|
||||
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
|
||||
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
trackId: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${htmlType}"]`,
|
||||
getAttrs(el) {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return { trackId: el.getAttribute('data-track-id') ?? '' }
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': htmlType,
|
||||
'data-track-id': (node.attrs.trackId as string) ?? '',
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
|
||||
node: { attrs: { trackId: string } },
|
||||
) {
|
||||
state.write(commentFor(node.attrs.trackId ?? ''))
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled via preprocessTrackTargets → parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const TrackTargetOpenExtension = buildMarkerExtension('open')
|
||||
export const TrackTargetCloseExtension = buildMarkerExtension('close')
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TrackEvent } from '@x/shared/dist/track-block.js';
|
||||
import { TrackEvent } from '@x/shared/dist/track.js';
|
||||
|
||||
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ function getSnapshot(): Map<string, TrackState> {
|
|||
/**
|
||||
* Returns a Map of all track run states, keyed by "trackId:filePath".
|
||||
*
|
||||
* Usage in a track block component:
|
||||
* Usage in a track-aware component:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
|
||||
*
|
||||
|
|
|
|||
|
|
@ -133,9 +133,19 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
|
|||
}
|
||||
|
||||
/**
|
||||
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list values are string[].
|
||||
* Skips `---` delimiters and blank lines.
|
||||
* Keys that hold structured (nested object/array-of-object) data and must NOT
|
||||
* be mangled by the flat-string FrontmatterProperties UI. These pass through
|
||||
* unchanged on a round-trip — never exposed as editable fields, never
|
||||
* re-emitted by buildFrontmatter (callers must splice them back from the
|
||||
* original raw if they want to preserve them on save — see the helpers below).
|
||||
*/
|
||||
const STRUCTURED_KEYS = new Set(['track'])
|
||||
|
||||
/**
|
||||
* Extract editable top-level YAML key/value pairs from raw frontmatter.
|
||||
* Returns a flat record where scalar values are strings and list-of-string
|
||||
* values are string[]. Structured keys (e.g. `track:`) and any nested-object
|
||||
* shapes are filtered out — they are not editable via this surface.
|
||||
*/
|
||||
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {}
|
||||
|
|
@ -143,10 +153,12 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
|
|||
|
||||
const lines = raw.split('\n')
|
||||
let currentKey: string | null = null
|
||||
let pendingNested = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---' || line.trim() === '') {
|
||||
currentKey = null
|
||||
pendingNested = false
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -155,39 +167,61 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
|
|||
if (topMatch) {
|
||||
const key = topMatch[1]
|
||||
const value = topMatch[2].trim()
|
||||
pendingNested = false
|
||||
if (STRUCTURED_KEYS.has(key)) {
|
||||
currentKey = null
|
||||
continue
|
||||
}
|
||||
if (value) {
|
||||
result[key] = value
|
||||
currentKey = null
|
||||
} else {
|
||||
// List will follow
|
||||
currentKey = key
|
||||
result[key] = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item under current key
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||
if (!currentKey) continue
|
||||
|
||||
// List item under current key.
|
||||
const itemMatch = line.match(/^\s+-\s+(.*)$/)
|
||||
if (itemMatch) {
|
||||
const item = itemMatch[1].trim()
|
||||
// If the list-item line itself contains a `key: value` pair, this is a
|
||||
// nested-object shape (e.g. `- id: chicago-time` under `track:`). We
|
||||
// can't represent that as a flat string array — drop the whole key.
|
||||
if (/^\w[\w\s]*\w?:\s*\S/.test(item)) {
|
||||
delete result[currentKey]
|
||||
currentKey = null
|
||||
pendingNested = true
|
||||
continue
|
||||
}
|
||||
const arr = result[currentKey]
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim())
|
||||
}
|
||||
}
|
||||
if (Array.isArray(arr)) arr.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Indented continuation of a nested object — keep dropping its parent.
|
||||
if (pendingNested && /^\s/.test(line)) continue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
|
||||
* Returns null if no non-empty fields remain.
|
||||
* Convert a Record of editable frontmatter fields back to a raw YAML
|
||||
* frontmatter string. If `preserveRaw` is provided, structured keys (e.g.
|
||||
* `track:`) are spliced back from the original raw byte-for-byte, so
|
||||
* round-trips through the FrontmatterProperties UI never lose them.
|
||||
*/
|
||||
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||
export function buildFrontmatter(
|
||||
fields: Record<string, string | string[]>,
|
||||
preserveRaw: string | null = null,
|
||||
): string | null {
|
||||
const lines: string[] = []
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (STRUCTURED_KEYS.has(key)) continue
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) continue
|
||||
lines.push(`${key}:`)
|
||||
|
|
@ -200,8 +234,55 @@ export function buildFrontmatter(fields: Record<string, string | string[]>): str
|
|||
lines.push(`${key}: ${trimmed}`)
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
return `---\n${lines.join('\n')}\n---`
|
||||
|
||||
// Splice preserved structured-key blocks (e.g. track:) back from preserveRaw.
|
||||
const preservedBlocks: string[] = []
|
||||
if (preserveRaw) {
|
||||
for (const key of STRUCTURED_KEYS) {
|
||||
const block = extractTopLevelBlock(preserveRaw, key)
|
||||
if (block) preservedBlocks.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0 && preservedBlocks.length === 0) return null
|
||||
const allLines = [...lines, ...preservedBlocks.flatMap(b => b.split('\n'))]
|
||||
return `---\n${allLines.join('\n')}\n---`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the byte-for-byte line block for a top-level key in raw frontmatter,
|
||||
* including its nested children (any indented lines that follow), or null if
|
||||
* the key is absent. Used to round-trip structured keys safely.
|
||||
*/
|
||||
function extractTopLevelBlock(raw: string, key: string): string | null {
|
||||
const lines = raw.split('\n')
|
||||
let start = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '---') continue
|
||||
const m = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
|
||||
if (m && m[1] === key) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (start === -1) return null
|
||||
let end = start
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === '---') break
|
||||
if (/^\s/.test(line)) {
|
||||
end = i
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
// blank line — end of this top-level block
|
||||
break
|
||||
}
|
||||
// another top-level key — stop
|
||||
break
|
||||
}
|
||||
return lines.slice(start, end + 1).join('\n')
|
||||
}
|
||||
|
||||
/** Map known tag values → category for legacy flat-list frontmatter. */
|
||||
|
|
|
|||
|
|
@ -656,159 +656,11 @@
|
|||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
/* =============================================================
|
||||
Track Block — inline chip (display-only)
|
||||
The chip just opens a modal (TrackModal). All mutations live in the
|
||||
modal and go through IPC, so the editor never writes track state.
|
||||
(Track inline chip and target-marker styles removed — tracks now
|
||||
live entirely in the note's frontmatter and are managed via the
|
||||
right-side track sidebar.)
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
|
||||
--track-accent: #64748b; /* default: manual/slate */
|
||||
margin: 8px 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 24px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:hover {
|
||||
background: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-running {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
|
||||
animation: track-chip-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes track-chip-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-icon {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="up-next"] .track-block-chip-icon { color: #3b82f6; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="calendar"] .track-block-chip-icon { color: #22c55e; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="emails"] .track-block-chip-icon { color: #f59e0b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="what-you-missed"] .track-block-chip-icon { color: #3b82f6; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trackid="priorities"] .track-block-chip-icon { color: #ef4444; }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--foreground) 75%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track target markers — thin visual bookends around a track's
|
||||
output region. The content BETWEEN these markers is normal,
|
||||
editable document content (rendered by the existing extensions).
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 14px 0 6px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
|
||||
content: 'track: ' attr(data-track-id);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 8px;
|
||||
padding: 0 6px;
|
||||
background: var(--background, #fff);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
|
||||
height: 1px;
|
||||
margin: 6px 0 14px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
outline-offset: 1px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
/* =============================================================
|
||||
Track Modal — dialog overlay for track block details / edits
|
||||
Track sidebar styles. Filename is legacy (predates the modal →
|
||||
sidebar refactor); the .track-modal-* class names are reused by
|
||||
the sidebar's detail-view layout.
|
||||
============================================================= */
|
||||
|
||||
.track-modal-content {
|
||||
|
|
@ -309,3 +311,167 @@
|
|||
.track-modal-run-btn:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 85%, black);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track sidebar — right panel that lists/edits tracks for the
|
||||
currently-open note. Reuses the .track-modal-* inner styles.
|
||||
============================================================= */
|
||||
|
||||
.track-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background, #fff);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 24px -12px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.track-sidebar-back,
|
||||
.track-sidebar-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-back:hover,
|
||||
.track-sidebar-close:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-sidebar-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track-sidebar-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.track-sidebar-empty-hint {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row {
|
||||
--track-accent: #64748b;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--track-accent);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.track-sidebar-row[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-sidebar-row[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-sidebar-row[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-sidebar-row[data-active="false"] { opacity: 0.65; }
|
||||
|
||||
.track-sidebar-row:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row-icon {
|
||||
color: var(--track-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.track-sidebar-row-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-sidebar-row-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track-sidebar-row-sub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.track-sidebar-row-instruction {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-sidebar-detail {
|
||||
--track-accent: #64748b;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track-sidebar-detail[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-sidebar-detail[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-sidebar-detail[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-sidebar-detail[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
|
|
|||
|
|
@ -84,7 +84,13 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
|
||||
**Tracks (Auto-Updating Notes):** A note's body can be partially or fully agent-maintained — *living* notes that refresh on a schedule or react to incoming emails / calendar events. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the word "track" — load the \`tracks\` skill the moment you spot one.
|
||||
|
||||
*Strong signals (load the skill, act without asking):* "every morning / daily / hourly…", "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "pin live updates of…", "track / follow X", "whenever a relevant email comes in…".
|
||||
|
||||
*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard").
|
||||
|
||||
A track is a directive in a note's frontmatter (\`track:\` array entry) with one or more triggers (cron / window / once / event). Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor). When you set one up, tell them where to find it.
|
||||
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
|
||||
|
||||
**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const definitions: SkillDefinition[] = [
|
|||
{
|
||||
id: "tracks",
|
||||
title: "Tracks",
|
||||
summary: "Create and manage track blocks — YAML-scheduled auto-updating content blocks in notes (weather, news, prices, status, dashboards). Insert at cursor (Cmd+K) or append to notes.",
|
||||
summary: "Create and manage tracks — frontmatter directives that keep a note's body auto-updated on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).",
|
||||
content: tracksSkill,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const skill = String.raw`
|
||||
# Notify User
|
||||
|
||||
Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result.
|
||||
Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track detects something noteworthy, or when an agent wants to ping the user with a clickable result.
|
||||
|
||||
## When to use
|
||||
- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive.
|
||||
|
|
@ -62,7 +62,7 @@ The \`type=file\` path is workspace-relative (the same path you'd pass to \`work
|
|||
|
||||
## Anti-patterns
|
||||
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
|
||||
- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification.
|
||||
- **Don't repeat what's already on screen.** If the result is already in the chat or in a note the user is viewing, skip the notification.
|
||||
- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link.
|
||||
- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done".
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
import { TrackSchema } from '@x/shared/dist/track.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ The track agent can emit *rich blocks* — special fenced blocks the editor rend
|
|||
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
|
|
@ -28,36 +28,93 @@ You **do not** need to write the block body yourself — describe the desired ou
|
|||
export const skill = String.raw`
|
||||
# Tracks Skill
|
||||
|
||||
You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
|
||||
A track is a directive in a note's YAML frontmatter (under the ` + "`" + `track:` + "`" + ` array) that turns the note's body into a *living* document — refreshed on a schedule or reactively when a matching email / calendar event arrives. A note with no ` + "`" + `track:` + "`" + ` key is just static; one or more entries under it make it live. Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor).
|
||||
|
||||
## First: Just Do It — Do Not Ask About Edit Mode
|
||||
When this skill is loaded, your job is: set up (or update) a track, run it once so the user immediately sees content, and tell them where to manage it.
|
||||
|
||||
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
## Mode: act-first
|
||||
|
||||
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
|
||||
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
|
||||
- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact.
|
||||
Track creation and editing are action-first. Read the file, update the frontmatter via ` + "`" + `workspace-edit` + "`" + `, run the track once. Do not ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
|
||||
## What Is a Track Block
|
||||
- If another skill or earlier turn was waiting on edit-mode permission, treat the track request as implicit "direct mode" and proceed.
|
||||
- You may ask **one** short clarifying question only when genuinely ambiguous (e.g. *which* note). Never ask about permission to edit.
|
||||
- The Suggested Topics and Background Agent setup flows below are first-turn-confirmation exceptions — leave those intact.
|
||||
|
||||
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
|
||||
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
|
||||
- A sibling "target region" — an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
|
||||
## Reading the user's intent
|
||||
|
||||
**Concrete example** (a track that shows the current time in Chicago every hour):
|
||||
You're loaded any time the user might be asking for something dynamic. Two postures, depending on signal strength:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: chicago-time
|
||||
### Strong signals — act, then confirm
|
||||
|
||||
Just build the track. Don't ask permission. Confirm in one line at the end.
|
||||
|
||||
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
|
||||
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
|
||||
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
|
||||
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
|
||||
- **Direct**: "track X" — the user used the word; you can too in your reply
|
||||
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
|
||||
|
||||
### Medium signals — answer the one-off, then offer
|
||||
|
||||
Answer the user's actual question first. Then add a single-line offer to keep it updated. If they say yes, build the track. If they don't engage, leave it — don't push twice.
|
||||
|
||||
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
|
||||
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" — especially when in a note context
|
||||
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
|
||||
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
|
||||
|
||||
Offer line shape (one line, concrete):
|
||||
> "I can keep this updated here, refreshing every morning — want that?"
|
||||
|
||||
### Anti-signals — do NOT track
|
||||
|
||||
- Definitional questions ("what is X?")
|
||||
- One-off lookups ("look up X for me")
|
||||
- Manual document work ("help me write…", "edit this paragraph…")
|
||||
- General how-to ("how do I do Y?")
|
||||
|
||||
## What to say to the user
|
||||
|
||||
The user knows the feature as **tracks** and finds them in the **Track sidebar**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "instruction" in user-facing prose unless the user uses them first.
|
||||
|
||||
After creating a track, surface where it lives:
|
||||
> "Done — I've set up a track here that refreshes every morning. Running it once now so you see content right away. You can manage it from the Track sidebar (Radio icon, top-right of the editor)."
|
||||
|
||||
After editing one:
|
||||
> "Updated. Re-running now so you can see the new output."
|
||||
|
||||
When skipping a re-run (because the user said not to or "later"):
|
||||
> "Updated — I'll let it run on its next trigger."
|
||||
|
||||
## What Is a Track (concretely)
|
||||
|
||||
**Concrete example** — a note that shows the current Chicago time, refreshed hourly:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
track:
|
||||
- id: chicago-time
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
---
|
||||
|
||||
# Chicago time
|
||||
|
||||
(empty — the agent will fill this in on the first run)
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
<!--/track-target:chicago-time-->
|
||||
After the first run, the body might become:
|
||||
|
||||
` + "```" + `markdown
|
||||
# Chicago time
|
||||
|
||||
2:30 PM, Central Time
|
||||
` + "```" + `
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
|
|
@ -66,20 +123,35 @@ Good use cases:
|
|||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Any recurring summary that decays fast
|
||||
- Living summaries fed by incoming events (emails, meeting notes)
|
||||
- Any recurring content that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
Each track has two parts that live next to each other in the note:
|
||||
A track lives entirely in the note's frontmatter — there is no inline marker in the body. The agent writes whatever content the instruction demands into the body itself, choosing where to place it based on the existing structure.
|
||||
|
||||
1. The ` + "`" + `track` + "`" + ` code fence — contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
|
||||
2. The target-comment region — ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
|
||||
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
|
||||
|
||||
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
` + "```" + `markdown
|
||||
---
|
||||
track:
|
||||
- id: <kebab-id>
|
||||
instruction: |
|
||||
<what the agent should produce>
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
---
|
||||
|
||||
# Note body
|
||||
` + "```" + `
|
||||
|
||||
A note may have multiple entries under ` + "`" + `track:` + "`" + ` — they run independently. Each entry can have multiple triggers (e.g. an hourly cron AND an event trigger). Omit ` + "`" + `triggers` + "`" + ` for a manual-only track.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a track block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
Below is the authoritative schema for a single track entry (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
|
|
@ -100,18 +172,15 @@ Things that are **not** reasons to set these:
|
|||
- "Tracks should be fast" / "I want a small model" — that's a global preference, not a per-track one. Leave it; the global default exists.
|
||||
- "This track is complex" — write a clearer instruction; don't reach for a different model.
|
||||
- "Just to be safe" / "in case it matters" — this is the antipattern. Leave them out.
|
||||
- The user changed their main chat model — that has nothing to do with tracks. Leave them out.
|
||||
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest. If you find yourself adding them as a sensible default, stop — you're wrong.
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
|
||||
|
||||
## Choosing a trackId
|
||||
## Choosing an ` + "`" + `id` + "`" + `
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
- **Must be unique within the note file.** Before inserting, read the file and check:
|
||||
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
|
||||
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
|
||||
- **Must be unique within the note's ` + "`" + `track:` + "`" + ` array.** Before inserting, read the file and check existing ` + "`" + `id:` + "`" + ` values.
|
||||
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
|
||||
- Don't reuse an old ID even if the previous block was deleted — pick a fresh one.
|
||||
- Don't reuse an old ID even if a previous entry was deleted — pick a fresh one.
|
||||
|
||||
## Writing a Good Instruction
|
||||
|
||||
|
|
@ -122,7 +191,7 @@ Track output lives in a personal knowledge base the user scans frequently. Aim f
|
|||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to fetch or compute.
|
||||
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
|
||||
- **Single-focus.** One track = one purpose. Split "weather + news + stocks" into three tracks, don't bundle.
|
||||
- **Imperative voice, 1-3 sentences.**
|
||||
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
|
||||
|
||||
|
|
@ -163,106 +232,131 @@ ${richBlockMenu}
|
|||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **Bundling multiple purposes** into one instruction — split into separate track blocks.
|
||||
- **Bundling multiple purposes** into one instruction — split into separate tracks.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
|
||||
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or event-trigger ` + "`" + `matchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `, every time.**
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: world-clock
|
||||
instruction: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
Note: when a location is in DST, reflect that in the offset column.
|
||||
eventMatchCriteria: |
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
- type: event
|
||||
matchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line.
|
||||
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
|
||||
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
|
||||
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line.
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields with no newline needs:
|
||||
Fine for short single-sentence fields:
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: chicago-time
|
||||
instruction: "Show the current time in Chicago, IL in 12-hour format."
|
||||
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
|
||||
active: true
|
||||
` + "```" + `
|
||||
|
||||
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
|
||||
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
|
||||
|
||||
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: 'He said "hi" at 9:00.'
|
||||
` + "```" + `
|
||||
|
||||
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
|
||||
- No other escape sequences work.
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not.
|
||||
|
||||
### Editing an existing track
|
||||
|
||||
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
|
||||
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Schedules
|
||||
## Triggers
|
||||
|
||||
Schedule is an **optional** discriminated union. Three types:
|
||||
A track has zero or more **triggers** under a single ` + "`" + `triggers:` + "`" + ` array. Each trigger is one of four types:
|
||||
|
||||
### ` + "`" + `cron` + "`" + ` — recurring at exact times
|
||||
- ` + "`" + `cron` + "`" + ` — fires at an exact time, recurring
|
||||
- ` + "`" + `window` + "`" + ` — once per day, anywhere inside a time-of-day band
|
||||
- ` + "`" + `once` + "`" + ` — one-shot at a future time
|
||||
- ` + "`" + `event` + "`" + ` — fires when a matching event arrives (emails, calendar, etc.)
|
||||
|
||||
A track can carry **multiple triggers** of any mix. Omit ` + "`" + `triggers` + "`" + ` (or use an empty array) for a **manual-only** track — the user triggers it via the Run button in the sidebar.
|
||||
|
||||
### ` + "`" + `cron` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: cron
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
|
||||
|
||||
### ` + "`" + `window` + "`" + ` — recurring within a time-of-day range
|
||||
### ` + "`" + `window` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: window
|
||||
cron: "0 0 * * 1-5"
|
||||
triggers:
|
||||
- type: window
|
||||
startTime: "09:00"
|
||||
endTime: "17:00"
|
||||
endTime: "12:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `–` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" — flexible timing with bounds.
|
||||
Fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at ` + "`" + `startTime` + "`" + ` — once a fire lands at-or-after today's start, the trigger is done for the day. Use this when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
|
||||
|
||||
### ` + "`" + `once` + "`" + ` — one-shot at a future time
|
||||
### ` + "`" + `once` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: once
|
||||
triggers:
|
||||
- type: once
|
||||
runAt: "2026-04-14T09:00:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
|
||||
Local time, no ` + "`" + `Z` + "`" + ` suffix.
|
||||
|
||||
### ` + "`" + `event` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
- type: event
|
||||
matchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How event triggers work:
|
||||
1. When a new event arrives, a fast LLM classifier checks each event trigger's ` + "`" + `matchCriteria` + "`" + ` against the event content.
|
||||
2. If it might match, the track-run agent receives both the event payload and the existing note body, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
### Combining multiple triggers
|
||||
|
||||
A single track can have any combination — e.g. an hourly cron AND an event trigger:
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: q3-emails
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3 planning.
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 9 * * 1-5"
|
||||
- type: event
|
||||
matchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
This track refreshes on schedule (weekdays at 9am) AND on every relevant incoming email.
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
|
|
@ -273,62 +367,25 @@ Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`
|
|||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** — the user triggers it via the Play button in the UI.
|
||||
|
||||
## Event Triggers (third trigger type)
|
||||
|
||||
In addition to manual and scheduled, a track can be triggered by **events** — incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: q3-planning-emails
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3
|
||||
planning, drawn from emails on the topic.
|
||||
active: true
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How it works:
|
||||
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
|
||||
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
When to suggest event triggers:
|
||||
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
|
||||
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
|
||||
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined — a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
|
||||
|
||||
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
|
||||
- Be descriptive but not overly narrow — Pass 1 routing is liberal by design.
|
||||
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
|
||||
|
||||
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely — they'll only run on schedule or manually.
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Cmd+K with cursor context
|
||||
### Adding a track to an existing note
|
||||
|
||||
When the user invokes Cmd+K, the context includes an attachment mention like:
|
||||
> User has attached the following files:
|
||||
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
|
||||
|
||||
Workflow:
|
||||
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
|
||||
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` — always re-read fresh.
|
||||
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
|
||||
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
|
||||
5. Construct the full track block (YAML + target pair).
|
||||
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
|
||||
1. ` + "`" + `workspace-readFile({ path })` + "`" + ` — re-read fresh.
|
||||
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any). Note the existing ` + "`" + `track:` + "`" + ` ids if present.
|
||||
3. Construct the new track entry as YAML.
|
||||
4. ` + "`" + `workspace-edit` + "`" + `:
|
||||
- **If the note has frontmatter and a ` + "`" + `track:` + "`" + ` array already**: anchor on a unique line in/near the array and splice your new entry in.
|
||||
- **If the note has frontmatter but no ` + "`" + `track:` + "`" + ` array**: anchor on the closing ` + "`" + `---` + "`" + ` of the frontmatter, and insert ` + "`" + `track:\n - id: ...` + "`" + ` etc. just before it.
|
||||
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (` + "`" + `---\n` + "`" + ` ... ` + "`" + `\n---\n` + "`" + ` followed by the original first line).
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should I add the track to?"
|
||||
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
|
||||
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
|
||||
3. Update the note's frontmatter ` + "`" + `track:` + "`" + ` array using the workflow above.
|
||||
|
||||
### No note context at all
|
||||
|
||||
|
|
@ -344,163 +401,135 @@ In that flow:
|
|||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track entry should be the core of the note.
|
||||
|
||||
### Background agent setup flow
|
||||
|
||||
Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet.
|
||||
|
||||
In this flow, treat "background agent" and "track block" as the same feature. The user-facing term can stay "background agent", but the implementation is a track block inside a note. Do **not** claim these are different systems, and do **not** redirect the user toward standalone agent files or ` + "`" + `agent-schedule.json` + "`" + ` unless they explicitly ask for that architecture.
|
||||
In this flow, treat "background agent" and "track" as the same feature. The user-facing term can stay "background agent", but the implementation is a track in a note's frontmatter. Do **not** claim these are different systems.
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run.
|
||||
2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder or there is a real ambiguity you cannot resolve.
|
||||
2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder.
|
||||
3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder.
|
||||
4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists.
|
||||
5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup instead of bouncing back to ask.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup.
|
||||
6. Keep the surrounding note scaffolding minimal but useful.
|
||||
|
||||
## The Exact Text to Insert
|
||||
## The Exact Frontmatter Shape
|
||||
|
||||
Write it verbatim like this (including the blank line between fence and target):
|
||||
For a brand-new note:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <id>
|
||||
` + "```" + `markdown
|
||||
---
|
||||
track:
|
||||
- id: <kebab-id>
|
||||
instruction: |
|
||||
<instruction, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
---
|
||||
|
||||
# <Note title>
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<id>-->
|
||||
<!--/track-target:<id>-->
|
||||
|
||||
**Rules:**
|
||||
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
|
||||
- Target pair is **empty on creation**. The runner fills it on the first run.
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why.
|
||||
- ` + "`" + `track:` + "`" + ` is at the top level of the frontmatter, never nested.
|
||||
- Each entry is a list item starting with ` + "`" + `- id:` + "`" + `. 2-space YAML indent. No tabs.
|
||||
- ` + "`" + `triggers:` + "`" + ` is an array. Omit it for a manual-only track. Multiple entries are allowed (any mix of cron / window / once / event).
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `.
|
||||
- Use 2-space YAML indent. No tabs.
|
||||
- Top-level markdown only — never inside a code fence, blockquote, or table.
|
||||
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The track agent will edit the body on its first run.
|
||||
|
||||
## After Insertion
|
||||
## After Creating or Editing a Track
|
||||
|
||||
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
|
||||
- **Then offer to run it once now** (see "Running a Track" below) — especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
|
||||
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself — use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
|
||||
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the ` + "`" + `run-track` + "`" + ` tool — same as the user clicking Run in the sidebar.
|
||||
|
||||
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
|
||||
Why default-on:
|
||||
- For event-driven tracks (with ` + "`" + `event` + "`" + ` triggers), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
|
||||
- For tracks that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill ` + "`" + `context` + "`" + ` (see below) seeds rich initial content.
|
||||
- After an edit, the user expects to see the updated output without an extra round-trip.
|
||||
|
||||
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button — but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
|
||||
Confirm in one line and tell the user where to find it:
|
||||
> "Done — I've set up a track refreshing hourly. Running it once now so you see content right away. You can manage it from the Track sidebar."
|
||||
|
||||
### When to proactively offer to run
|
||||
For an edit:
|
||||
> "Updated. Re-running now so you can see the new output."
|
||||
|
||||
These are upsells — ask first, don't run silently.
|
||||
If you skipped the re-run (user said not to):
|
||||
> "Updated — I'll let it run on its next trigger."
|
||||
|
||||
- **Just created a new track block.** Before declaring done, offer:
|
||||
> "Want me to run it once now to seed the initial content?"
|
||||
**Do not** write content into the note body yourself — that's the track agent's job, delegated via ` + "`" + `run-track` + "`" + `.
|
||||
|
||||
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) — otherwise the target region stays empty until the next matching event arrives.
|
||||
## Using the ` + "`" + `run-track` + "`" + ` tool
|
||||
|
||||
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
|
||||
` + "`" + `run-track` + "`" + ` triggers a single run right now. You can pass an optional ` + "`" + `context` + "`" + ` string to bias *this run only* without modifying the track's instruction — the difference between a stock refresh and a smart backfill.
|
||||
|
||||
- **Just edited an existing track.** Offer:
|
||||
> "Want me to run it now to see the updated output?"
|
||||
### Backfill ` + "`" + `context` + "`" + ` examples
|
||||
|
||||
- **Explicit user request.** "run the X track", "test it", "refresh that block" → call the tool directly.
|
||||
|
||||
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
|
||||
|
||||
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- New track: "Track emails about Q3 planning" → after creating it, run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
|
||||
|
||||
- New track: "Summarize this week's customer calls" → run with:
|
||||
- New event-driven track on Q3 emails → run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
|
||||
- New track on this week's customer calls → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
- Plain refresh (user said "run it now"): **omit ` + "`" + `context` + "`" + `**. Don't invent it.
|
||||
|
||||
- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context — it can mislead the agent.
|
||||
|
||||
### What to do with the result
|
||||
### Reading the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- **` + "`" + `action: 'replace'` + "`" + `** → the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
|
||||
> "Done — track now shows: 72°F, partly cloudy in Chicago."
|
||||
|
||||
- **` + "`" + `action: 'no_update'` + "`" + `** → the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
|
||||
|
||||
- **` + "`" + `error` + "`" + ` set** → surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
|
||||
- ` + "`" + `action: 'replace'` + "`" + ` → body changed. Confirm in one line; optionally cite the first line of ` + "`" + `contentAfter` + "`" + `.
|
||||
- ` + "`" + `action: 'no_update'` + "`" + ` → agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` usually explains why.
|
||||
- ` + "`" + `error: 'Already running'` + "`" + ` → another run is in flight; tell the user to retry shortly.
|
||||
- Other ` + "`" + `error` + "`" + ` → surface concisely.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't auto-run** after every edit — ask first.
|
||||
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
|
||||
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
|
||||
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn — one run per user-facing action.
|
||||
|
||||
## Proactive Suggestions
|
||||
|
||||
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
|
||||
- "I want to track / monitor / watch / keep an eye on / follow X"
|
||||
- "Can you check on X every morning / hourly / weekly?"
|
||||
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
|
||||
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
|
||||
|
||||
Suggestion style — one line, concrete:
|
||||
> "I can turn this into a track block that refreshes hourly — want that?"
|
||||
|
||||
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
|
||||
- **Don't run more than once** per user-facing action — one tool call per turn.
|
||||
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — it can mislead the agent.
|
||||
- **Don't write content into the note body yourself** — always delegate via ` + "`" + `run-track` + "`" + `.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
|
||||
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
|
||||
- **Don't reuse** an existing ` + "`" + `id` + "`" + ` in the same note's ` + "`" + `track:` + "`" + ` array.
|
||||
- **Don't add ` + "`" + `triggers` + "`" + `** if the user explicitly wants a manual-only track.
|
||||
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` — runtime-managed.
|
||||
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` — local time only.
|
||||
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file — always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Track
|
||||
|
||||
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
|
||||
**Change triggers or instruction:** ` + "`" + `workspace-edit` + "`" + ` the relevant fields inside the ` + "`" + `track:` + "`" + ` array. Anchor on the unique ` + "`" + `id: <id>` + "`" + ` line plus a few surrounding lines.
|
||||
|
||||
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
|
||||
|
||||
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
|
||||
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full track entry (from its ` + "`" + `- id:` + "`" + ` line down to just before the next ` + "`" + `- id:` + "`" + ` line or the closing ` + "`" + `---` + "`" + ` of the frontmatter), ` + "`" + `newString` + "`" + ` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template:
|
||||
Minimal template (frontmatter only):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <kebab-id>
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: <kebab-id>
|
||||
instruction: |
|
||||
<what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<kebab-id>-->
|
||||
<!--/track-target:<kebab-id>-->
|
||||
|
||||
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
|
||||
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { getCurrentUseCase } from "../../analytics/use_case.js";
|
|||
import { isSignedIn } from "../../account/account.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
||||
import type { IBrowserControlService } from "../browser-control/service.js";
|
||||
import type { INotificationService } from "../notification/service.js";
|
||||
// Parser libraries are loaded dynamically inside parseFile.execute()
|
||||
|
|
@ -1551,44 +1550,25 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
'update-track-content': {
|
||||
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
|
||||
'run-track': {
|
||||
description: "Manually trigger a track to run now on its host note. Equivalent to the user clicking the Run button on the track in the sidebar, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking track from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
|
||||
trackId: z.string().describe("The track block's trackId"),
|
||||
content: z.string().describe("The new content to place inside the track's target region"),
|
||||
}),
|
||||
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
|
||||
try {
|
||||
await updateContent(filePath, trackId, content);
|
||||
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
|
||||
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'run-track-block': {
|
||||
description: "Manually trigger a track block to run now. Equivalent to the user clicking the Play button on the block, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking block from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new content.",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
|
||||
trackId: z.string().describe("The track block's trackId (must exist in the file)"),
|
||||
id: z.string().describe("The track's id (must exist in the note's frontmatter `track:` array)"),
|
||||
context: z.string().optional().describe(
|
||||
"Optional extra context for the track agent to consider for THIS run only — does not modify the block's instruction. " +
|
||||
"Optional extra context for the track agent to consider for THIS run only — does not modify the track's instruction. " +
|
||||
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
|
||||
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
|
||||
"Omit for a plain refresh."
|
||||
),
|
||||
}),
|
||||
execute: async ({ filePath, trackId, context }: { filePath: string; trackId: string; context?: string }) => {
|
||||
execute: async ({ filePath, id, context }: { filePath: string; id: string; context?: string }) => {
|
||||
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
|
||||
try {
|
||||
// Lazy import to break a module-init cycle:
|
||||
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
|
||||
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
|
||||
const result = await triggerTrackUpdate(trackId, knowledgeRelativePath, context, 'manual');
|
||||
const result = await triggerTrackUpdate(id, knowledgeRelativePath, context, 'manual');
|
||||
return {
|
||||
success: !result.error,
|
||||
runId: result.runId,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { parse as parseYaml } from "yaml";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
|
||||
/**
|
||||
* Parse the YAML frontmatter from the input string. Returns the frontmatter and content.
|
||||
* @param input - The input string to parse.
|
||||
* @returns The frontmatter and content.
|
||||
* Parse the YAML frontmatter from the input string. Trims the body — handy
|
||||
* when you only care about the frontmatter or treat the body as opaque
|
||||
* markdown (e.g. agent instructions). Use {@link splitFrontmatter} when you
|
||||
* need to round-trip the body byte-for-byte.
|
||||
*/
|
||||
export function parseFrontmatter(input: string): {
|
||||
frontmatter: unknown | null;
|
||||
|
|
@ -25,3 +26,54 @@ export function parseFrontmatter(input: string): {
|
|||
content: input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a file's frontmatter from its body without trimming or reformatting
|
||||
* the body. Used by callers that round-trip the file (read → mutate
|
||||
* frontmatter → re-emit) — preserving body bytes prevents whitespace drift
|
||||
* across writes. Pair with {@link joinFrontmatter} on the way out.
|
||||
*
|
||||
* - `frontmatter` is always an object (empty `{}` if absent or not a map).
|
||||
* - `body` is the rest of the file verbatim, including any leading/trailing
|
||||
* whitespace.
|
||||
*/
|
||||
export function splitFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
if (!content.startsWith('---')) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
const close = /\r?\n---\r?\n/.exec(content);
|
||||
if (!close) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
const yamlText = content.slice(3, close.index).trim();
|
||||
const body = content.slice(close.index + close[0].length);
|
||||
let parsed: unknown = {};
|
||||
if (yamlText) {
|
||||
try {
|
||||
parsed = parseYaml(yamlText);
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
}
|
||||
const frontmatter = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emit a file with the given frontmatter object and body. If the
|
||||
* frontmatter object is empty, no `---` fence is written — the file is body
|
||||
* only. Pairs with {@link splitFrontmatter}.
|
||||
*/
|
||||
export function joinFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
body: string,
|
||||
): string {
|
||||
if (Object.keys(frontmatter).length === 0) return body;
|
||||
const yamlText = stringifyYaml(frontmatter).replace(/\n$/, '');
|
||||
return `---\n${yamlText}\n---\n${body}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,214 +1,154 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { stringify as stringifyYaml, parse as parseYaml } from 'yaml';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackSchema } from '@x/shared/dist/track.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { splitFrontmatter } from '../application/lib/parse-frontmatter.js';
|
||||
import z from 'zod';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
||||
|
||||
interface Section {
|
||||
heading: string;
|
||||
track: z.infer<typeof TrackBlockSchema>;
|
||||
}
|
||||
// Bump this whenever the canonical Today.md template changes (TRACKS list,
|
||||
// instructions, default body, etc.). On app start, ensureDailyNote() compares
|
||||
// the on-disk `templateVersion` against this constant — if older or missing,
|
||||
// the existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with
|
||||
// the new template, preserving the body byte-for-byte.
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 1;
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
// Window triggers below fire once per day, anywhere inside their time-of-day
|
||||
// band — so the user opening the app late in the morning still gets the
|
||||
// morning run. See schedule-utils.ts for the exact semantics.
|
||||
|
||||
const TRACKS: z.infer<typeof TrackSchema>[] = [
|
||||
{
|
||||
heading: '## Up Next',
|
||||
track: {
|
||||
trackId: 'up-next',
|
||||
icon: 'clock',
|
||||
id: 'overview',
|
||||
instruction:
|
||||
`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today.
|
||||
|
||||
This section refreshes on calendar changes, not on a clock tick — do NOT promise live minute countdowns. Frame urgency in buckets based on the event's start time relative to now:
|
||||
- Start time is in the past or within roughly half an hour → imminent: name the meeting and say it's starting soon (e.g. "Standup is starting — join link in the Calendar section below.").
|
||||
- Start time is later this morning or this afternoon → upcoming: name the meeting and roughly when (e.g. "Design review later this morning." / "1:1 with Sam this afternoon.").
|
||||
- Start time is several hours out or nothing before then → focus block: frame the gap (e.g. "Next up is the all-hands at 3pm — good long focus block until then.").
|
||||
|
||||
Use the event's start time of day ("at 3pm", "this afternoon") rather than a countdown ("in 40 minutes"). Countdowns go stale between syncs.
|
||||
|
||||
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't ended yet — for finding the next event, pick the earliest upcoming one; if all have passed, treat as clear.
|
||||
|
||||
If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research.
|
||||
|
||||
If nothing remains today, output exactly: Clear for the rest of the day.
|
||||
|
||||
Plain markdown prose only — no calendar block, no email block, no headings.`,
|
||||
eventMatchCriteria:
|
||||
`Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`,
|
||||
`In a section titled "Overview" at the top of the note: 2–3 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from calendar_sync/ and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Skip the update if the prior content is still suitable and less than 24h old. VERY IMPORTANT: Ensure that image is wide / low-height!`,
|
||||
active: true,
|
||||
},
|
||||
triggers: [
|
||||
// Three windows give the user a fresh ranking morning, midday, and
|
||||
// post-lunch even with no events landing in between.
|
||||
{ type: 'window', startTime: '08:00', endTime: '12:00' },
|
||||
{ type: 'window', startTime: '12:00', endTime: '15:00' },
|
||||
{ type: 'window', startTime: '15:00', endTime: '18:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '## Calendar',
|
||||
track: {
|
||||
trackId: 'calendar',
|
||||
icon: 'calendar-days',
|
||||
id: 'calendar',
|
||||
instruction:
|
||||
`Emit today's meetings as a calendar block titled "Today's Meetings".
|
||||
|
||||
Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended — only include meetings that haven't ended yet.
|
||||
|
||||
This section refreshes on calendar changes, not on a clock tick — the "drop ended meetings" rule applies on each refresh, so an ended meeting disappears the next time any calendar event changes (not exactly on the clock hour). That's fine.
|
||||
|
||||
Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink.
|
||||
|
||||
After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight — one line each, only when meaningful. Skip routine/recurring meetings.`,
|
||||
eventMatchCriteria:
|
||||
`In a section titled "Calendar", emit today's meetings as a \`calendar\` block titled "Today's Meetings". Read calendar_sync/ via workspace-readdir → workspace-readFile each .json. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a conferenceLink.`,
|
||||
active: true,
|
||||
triggers: [{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
|
||||
active: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
heading: '## Emails',
|
||||
track: {
|
||||
trackId: 'emails',
|
||||
icon: 'mail',
|
||||
id: 'emails',
|
||||
instruction:
|
||||
`Maintain a digest of email threads worth the user's attention today. Output everything as a single fenced code block with language "emails" (plural) — never individual "email" (singular) blocks. The content must be a JSON object: {"title":"Today's Emails","emails":[...]} where each entry has threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response.
|
||||
`In a section titled "Emails", maintain a digest of email threads worth attention today. Output everything as a **single** fenced code block with language \`emails\` (plural — never individual \`email\` blocks per thread). The body must be JSON shaped \`{"title":"Today's Emails","emails":[...]}\`.
|
||||
|
||||
Event-driven path (primary): the agent message will include a "Gmail sync update" digest payload describing one or more freshly-synced threads from a single sync run. The digest lists each thread with its subject, sender, date, threadId, and body. Iterate over every thread in the payload and decide per thread whether it warrants surfacing. Skip marketing, auto-notifications, closed-out threads, and other low-signal mail. For threads that are attention-worthy, integrate them into the existing digest: add a new entry for a new threadId, or update the existing entry if the threadId is already shown. If NONE of the threads in the payload are attention-worthy, skip the update — do NOT call update-track-content. Emit at most one update-track-content call that covers the full set of changes from this event.
|
||||
Each entry in the array: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads that need a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`.
|
||||
|
||||
Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads.
|
||||
Skip marketing, auto-notifications, and closed threads. Without an event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/), prioritizing threads with frontmatter action = "reply" or "respond". With an event payload, integrate any qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if the threadId is already shown). Do not re-list threads the user has already seen unless their state changed.
|
||||
|
||||
If there is genuinely nothing to surface, output the single line: No new emails.
|
||||
|
||||
Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`,
|
||||
eventMatchCriteria:
|
||||
If nothing qualifies: "No new emails."`,
|
||||
active: true,
|
||||
triggers: [{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
|
||||
active: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
heading: '## What You Missed',
|
||||
track: {
|
||||
trackId: 'what-you-missed',
|
||||
icon: 'history',
|
||||
id: 'what-you-missed',
|
||||
instruction:
|
||||
`Short markdown summary of what happened yesterday that matters this morning.
|
||||
|
||||
Data sources:
|
||||
- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md — use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments.
|
||||
- gmail_sync/ — skim for threads from yesterday that went unresolved or still need a reply.
|
||||
|
||||
Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them.
|
||||
|
||||
Write concise markdown — a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today.
|
||||
|
||||
If nothing notable happened, output exactly: Quiet day yesterday — nothing to flag.
|
||||
|
||||
Do NOT manufacture content to fill the section.`,
|
||||
`In a section titled "What you missed", write a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from knowledge/Meetings/<source>/<yesterday>/ (workspace-readdir recursive on knowledge/Meetings, filter folders matching yesterday's date, read each file). Skim gmail_sync/ for threads that went unresolved. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."`,
|
||||
active: true,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '0 7 * * *',
|
||||
},
|
||||
},
|
||||
triggers: [
|
||||
// Three windows give the user a fresh ranking morning, midday, and
|
||||
// post-lunch even with no events landing in between.
|
||||
{ type: 'window', startTime: '08:00', endTime: '12:00' },
|
||||
{ type: 'window', startTime: '12:00', endTime: '15:00' },
|
||||
{ type: 'window', startTime: '15:00', endTime: '18:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '## Today\'s Priorities',
|
||||
track: {
|
||||
trackId: 'priorities',
|
||||
icon: 'list-todo',
|
||||
id: 'priorities',
|
||||
instruction:
|
||||
`Ranked markdown list of the real, actionable items the user should focus on today.
|
||||
`In a section titled "Priorities", a ranked markdown list of actionable items the user should focus on today.
|
||||
|
||||
Data sources:
|
||||
- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ — action items assigned to the user are often the most important source.
|
||||
- knowledge/ — use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups.
|
||||
- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section — useful for alignment.
|
||||
Sources: yesterday's meeting action items (knowledge/Meetings/<source>/<yesterday>/), open follow-ups across knowledge/ (workspace-grep for "- [ ]"), the "What you missed" section.
|
||||
|
||||
Rules:
|
||||
- Do NOT list calendar events as tasks — they're already in the Calendar section.
|
||||
- Do NOT list trivial admin (filing small invoices, archiving spam).
|
||||
- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review").
|
||||
- Add a brief reason for each item when it's not self-evident.
|
||||
Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline.
|
||||
|
||||
If nothing genuinely needs attention, output exactly: No pressing tasks today — good day to make progress on bigger items.
|
||||
With an event payload (gmail or calendar): re-emit the full list only if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). Otherwise skip the update.
|
||||
|
||||
Do NOT invent busywork.`,
|
||||
If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."`,
|
||||
active: true,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '30 7 * * *',
|
||||
triggers: [
|
||||
// Three windows give the user a fresh ranking morning, midday, and
|
||||
// post-lunch even with no events landing in between.
|
||||
{ type: 'window', startTime: '08:00', endTime: '12:00' },
|
||||
{ type: 'window', startTime: '12:00', endTime: '15:00' },
|
||||
{ type: 'window', startTime: '15:00', endTime: '18:00' },
|
||||
{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`New or updated email threads that may shift today's priorities — urgent reply requests, deadline-bearing items, escalations from people the user cares about.`,
|
||||
},
|
||||
{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`Calendar changes today that may shift priorities — a meeting moved to clash with a deadline, an unexpected event added, a key meeting cancelled freeing up time.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function buildDailyNoteContent(): string {
|
||||
const parts: string[] = ['# Today', ''];
|
||||
for (const { heading, track } of SECTIONS) {
|
||||
const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd();
|
||||
parts.push(
|
||||
heading,
|
||||
'',
|
||||
'```track',
|
||||
yaml,
|
||||
'```',
|
||||
'',
|
||||
`<!--track-target:${track.trackId}-->`,
|
||||
`<!--/track-target:${track.trackId}-->`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return parts.join('\n');
|
||||
function buildDailyNoteContent(body: string = '# Today\n'): string {
|
||||
const fm = stringifyYaml(
|
||||
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, track: TRACKS },
|
||||
{ lineWidth: 0, blockQuote: 'literal' },
|
||||
).trimEnd();
|
||||
return `---\n${fm}\n---\n${body}`;
|
||||
}
|
||||
|
||||
function migrateEmojiHeadings(): void {
|
||||
if (!fs.existsSync(DAILY_NOTE_PATH)) return;
|
||||
let content = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8');
|
||||
const original = content;
|
||||
const replacements: [string, string][] = [
|
||||
['## ⏱ Up Next', '## Up Next'],
|
||||
['## 📅 Calendar', '## Calendar'],
|
||||
['## 📧 Emails', '## Emails'],
|
||||
['## 📰 What You Missed', '## What You Missed'],
|
||||
["## ✅ Today's Priorities", "## Today's Priorities"],
|
||||
];
|
||||
for (const [from, to] of replacements) {
|
||||
content = content.split(from).join(to);
|
||||
}
|
||||
if (content !== original) {
|
||||
fs.writeFileSync(DAILY_NOTE_PATH, content, 'utf-8');
|
||||
console.log('[DailyNote] Migrated emoji headings');
|
||||
}
|
||||
}
|
||||
|
||||
function migrateTrackIcons(): void {
|
||||
if (!fs.existsSync(DAILY_NOTE_PATH)) return;
|
||||
let content = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8');
|
||||
const original = content;
|
||||
|
||||
const iconMap = new Map<string, string>(
|
||||
SECTIONS.flatMap(({ track }) => track.icon ? [[track.trackId, track.icon]] : [])
|
||||
);
|
||||
|
||||
content = content.replace(/```track\n([\s\S]*?)\n```/g, (match, yaml) => {
|
||||
try {
|
||||
const parsed = parseYaml(yaml) as Record<string, unknown>;
|
||||
if (!parsed.trackId || parsed.icon) return match;
|
||||
const icon = iconMap.get(parsed.trackId as string);
|
||||
if (!icon) return match;
|
||||
const updated = yaml.replace(/^(trackId: .+)$/m, `$1\nicon: ${icon}`);
|
||||
return '```track\n' + updated + '\n```';
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
if (content !== original) {
|
||||
fs.writeFileSync(DAILY_NOTE_PATH, content, 'utf-8');
|
||||
console.log('[DailyNote] Migrated track icons');
|
||||
}
|
||||
function readCurrentTemplateVersion(): number {
|
||||
if (!fs.existsSync(DAILY_NOTE_PATH)) return -1;
|
||||
const raw = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8');
|
||||
const { frontmatter } = splitFrontmatter(raw);
|
||||
const v = frontmatter.templateVersion;
|
||||
return typeof v === 'number' ? v : 0;
|
||||
}
|
||||
|
||||
export function ensureDailyNote(): void {
|
||||
migrateEmojiHeadings();
|
||||
migrateTrackIcons();
|
||||
if (fs.existsSync(DAILY_NOTE_PATH)) return;
|
||||
// Fresh install — no existing file.
|
||||
if (!fs.existsSync(DAILY_NOTE_PATH)) {
|
||||
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
|
||||
console.log('[DailyNote] Created today.md');
|
||||
console.log(`[DailyNote] Created Today.md (v${CANONICAL_DAILY_NOTE_VERSION})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Up-to-date — nothing to do.
|
||||
const currentVersion = readCurrentTemplateVersion();
|
||||
if (currentVersion >= CANONICAL_DAILY_NOTE_VERSION) return;
|
||||
|
||||
// Migrate aggressively: rename existing → backup, write a fresh canonical
|
||||
// template (no body carried over). Today.md is a flagship demo whose
|
||||
// content is meant to be regenerated by the tracks anyway — preserving the
|
||||
// old body just leaves orphan sections behind on rename/restructure. The
|
||||
// .bkp file is the recovery path; its name doesn't end in `.md`, so the
|
||||
// scheduler and event router naturally skip it. Pre-rewrite inline-fence
|
||||
// notes are caught by this same path.
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`;
|
||||
fs.renameSync(DAILY_NOTE_PATH, backupPath);
|
||||
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
|
||||
console.log(
|
||||
`[DailyNote] Migrated v${currentVersion} → v${CANONICAL_DAILY_NOTE_VERSION}; ` +
|
||||
`previous version saved to ${backupPath}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TrackEventType } from '@x/shared/dist/track-block.js';
|
||||
import type { TrackEventType } from '@x/shared/dist/track.js';
|
||||
|
||||
type Handler = (event: TrackEventType) => void;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PrefixLogger, trackBlock } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||
import { PrefixLogger, track } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import * as workspace from '../../workspace/workspace.js';
|
||||
import { fetchAll } from './fileops.js';
|
||||
|
|
@ -59,10 +59,17 @@ async function listAllTracks(): Promise<ParsedTrack[]> {
|
|||
continue;
|
||||
}
|
||||
for (const t of parsedTracks) {
|
||||
const eventCriteria = (t.track.triggers ?? [])
|
||||
.filter(trig => trig.type === 'event')
|
||||
.map(trig => trig.matchCriteria)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
// Skip tracks with no event triggers — they're not event-eligible.
|
||||
if (!eventCriteria) continue;
|
||||
tracks.push({
|
||||
trackId: t.track.trackId,
|
||||
trackId: t.track.id,
|
||||
filePath,
|
||||
eventMatchCriteria: t.track.eventMatchCriteria ?? '',
|
||||
eventMatchCriteria: eventCriteria,
|
||||
instruction: t.track.instruction,
|
||||
active: t.track.active,
|
||||
});
|
||||
|
|
@ -89,7 +96,7 @@ async function processOneEvent(filename: string): Promise<void> {
|
|||
try {
|
||||
const raw = fs.readFileSync(pendingPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
event = trackBlock.KnowledgeEventSchema.parse(parsed);
|
||||
event = track.KnowledgeEventSchema.parse(parsed);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.log(`Malformed event ${filename}, moving to done with error:`, msg);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
import { TrackSchema } from '@x/shared/dist/track.js';
|
||||
import { TrackStateSchema } from './types.js';
|
||||
import { withFileLock } from '../file-lock.js';
|
||||
import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
|
|
@ -13,6 +14,29 @@ function absPath(filePath: string): string {
|
|||
return path.join(KNOWLEDGE_DIR, filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track-array helpers (read/write the `track:` key in a parsed frontmatter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTrackArray(fm: Record<string, unknown>): unknown[] {
|
||||
const raw = fm.track;
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
|
||||
function setTrackArray(fm: Record<string, unknown>, tracks: unknown[]): Record<string, unknown> {
|
||||
const next = { ...fm };
|
||||
if (tracks.length === 0) {
|
||||
delete next.track;
|
||||
} else {
|
||||
next.track = tracks;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
|
||||
let content: string;
|
||||
try {
|
||||
|
|
@ -20,56 +44,118 @@ export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackSt
|
|||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const blocks: z.infer<typeof TrackStateSchema>[] = [];
|
||||
let i = 0;
|
||||
const contentFenceStartMatcher = /<!--track-target:(.+)-->/;
|
||||
const contentFenceEndMatcher = /<!--\/track-target:(.+)-->/;
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim() === '```track') {
|
||||
const fenceStart = i;
|
||||
i++;
|
||||
const blockLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '```') {
|
||||
blockLines.push(lines[i]);
|
||||
i++;
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
const tracks: z.infer<typeof TrackStateSchema>[] = [];
|
||||
for (const raw of getTrackArray(frontmatter)) {
|
||||
const result = TrackSchema.safeParse(raw);
|
||||
if (result.success) tracks.push({ track: result.data });
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
export async function fetch(filePath: string, id: string): Promise<z.infer<typeof TrackStateSchema> | null> {
|
||||
const all = await fetchAll(filePath);
|
||||
return all.find(t => t.track.id === id) ?? null;
|
||||
}
|
||||
|
||||
export async function fetchYaml(filePath: string, id: string): Promise<string | null> {
|
||||
const t = await fetch(filePath, id);
|
||||
if (!t) return null;
|
||||
return stringifyYaml(t.track).trimEnd();
|
||||
}
|
||||
|
||||
export async function readNoteBody(filePath: string): Promise<string> {
|
||||
let content: string;
|
||||
try {
|
||||
const data = parseYaml(blockLines.join('\n'));
|
||||
const result = TrackBlockSchema.safeParse(data);
|
||||
if (result.success) {
|
||||
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
|
||||
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
} 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;
|
||||
return splitFrontmatter(content).body;
|
||||
}
|
||||
|
||||
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;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findRawIndex(rawTracks: unknown[], id: string): number {
|
||||
return rawTracks.findIndex(
|
||||
(raw) => raw && typeof raw === 'object' && (raw as Record<string, unknown>).id === id,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTrack(
|
||||
filePath: string,
|
||||
id: string,
|
||||
updates: Partial<z.infer<typeof TrackSchema>>,
|
||||
): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
const idx = findRawIndex(rawTracks, id);
|
||||
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
|
||||
const next = [...rawTracks];
|
||||
next[idx] = { ...(rawTracks[idx] as Record<string, unknown>), ...updates };
|
||||
const nextFm = setTrackArray(frontmatter, next);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceTrackYaml(
|
||||
filePath: string,
|
||||
id: string,
|
||||
newYaml: string,
|
||||
): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const parsed = TrackSchema.safeParse(parseYaml(newYaml));
|
||||
if (!parsed.success) throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||
if (parsed.data.id !== id) {
|
||||
throw new Error(`id cannot be changed (was "${id}", got "${parsed.data.id}")`);
|
||||
}
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
const idx = findRawIndex(rawTracks, id);
|
||||
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
|
||||
const next = [...rawTracks];
|
||||
next[idx] = parsed.data;
|
||||
const nextFm = setTrackArray(frontmatter, next);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTrack(filePath: string, id: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
const idx = findRawIndex(rawTracks, id);
|
||||
if (idx === -1) return; // already gone
|
||||
const next = [...rawTracks];
|
||||
next.splice(idx, 1);
|
||||
const nextFm = setTrackArray(frontmatter, next);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the note's body. Frontmatter is preserved (including the `track:`
|
||||
* array). Used by the runner to commit the agent's body edits without granting
|
||||
* the agent write access to its own runtime state.
|
||||
*/
|
||||
export async function writeNoteBody(filePath: string, newBody: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(frontmatter, newBody), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note-level summaries (tracks-list view)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TrackNoteSummary = {
|
||||
path: string;
|
||||
trackCount: number;
|
||||
|
|
@ -112,24 +198,19 @@ export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> {
|
|||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const childRelPath = relativeDir
|
||||
? path.posix.join(relativeDir, entry.name)
|
||||
: entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await walk(childRelPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
files.push(childRelPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
|
|
@ -156,160 +237,31 @@ export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function setNoteTracksActive(filePath: string, active: boolean): Promise<TrackNoteSummary | null> {
|
||||
export async function setNoteTracksActive(
|
||||
filePath: string,
|
||||
active: boolean,
|
||||
): Promise<TrackNoteSummary | null> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const blocks = await fetchAll(filePath);
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
const alreadyMatches = blocks.every(({ track }) => (track.active !== false) === active);
|
||||
if (alreadyMatches) {
|
||||
return summarizeTrackNote(filePath, blocks);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const updatedBlocks = blocks
|
||||
.map((block) => ({
|
||||
...block,
|
||||
track: { ...block.track, active },
|
||||
}))
|
||||
.sort((a, b) => b.fenceStart - a.fenceStart);
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
if (rawTracks.length === 0) return null;
|
||||
|
||||
for (const block of updatedBlocks) {
|
||||
const yaml = stringifyYaml(block.track).trimEnd();
|
||||
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
const allMatch = rawTracks.every(
|
||||
(raw) => raw && typeof raw === 'object'
|
||||
&& ((raw as Record<string, unknown>).active !== false) === active,
|
||||
);
|
||||
if (!allMatch) {
|
||||
const updated = rawTracks.map((raw) =>
|
||||
raw && typeof raw === 'object'
|
||||
? { ...(raw as Record<string, unknown>), active }
|
||||
: raw,
|
||||
);
|
||||
const nextFm = setTrackArray(frontmatter, updated);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
}
|
||||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
return summarizeTrackNote(filePath, updatedBlocks);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a track block and return its canonical YAML string (or null if not found).
|
||||
* Useful for IPC handlers that need to return the fresh YAML without taking a
|
||||
* dependency on the `yaml` package themselves.
|
||||
*/
|
||||
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) return null;
|
||||
return stringifyYaml(block.track).trimEnd();
|
||||
}
|
||||
|
||||
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
const openIdx = content.indexOf(openTag);
|
||||
const closeIdx = content.indexOf(closeTag);
|
||||
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
|
||||
} else {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
|
||||
const contentFence = [openTag, newContent, closeTag];
|
||||
lines.splice(insertAt, 0, ...contentFence);
|
||||
content = lines.join('\n');
|
||||
}
|
||||
await fs.writeFile(absPath(filePath), content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
|
||||
return withFileLock(absPath(filepath), async () => {
|
||||
const block = await fetch(filepath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filepath}`);
|
||||
}
|
||||
block.track = { ...block.track, ...updates };
|
||||
|
||||
// read file contents
|
||||
let content = await fs.readFile(absPath(filepath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yaml = stringifyYaml(block.track).trimEnd();
|
||||
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
content = lines.join('\n');
|
||||
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire YAML of a track block on disk with a new string.
|
||||
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim —
|
||||
* used when the user explicitly edits raw YAML in the modal.
|
||||
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
|
||||
* otherwise the write is rejected.
|
||||
*/
|
||||
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||
}
|
||||
if (parsed.data.trackId !== trackId) {
|
||||
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yamlLines = newYaml.trimEnd().split('\n');
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track block and its sibling target region from the file.
|
||||
*/
|
||||
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
// Already gone — treat as success.
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
|
||||
// Find target region (may not exist)
|
||||
let targetStart = -1;
|
||||
let targetEnd = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(openTag)) { targetStart = i; }
|
||||
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
|
||||
}
|
||||
|
||||
// Build a list of [start, end] ranges to remove, sorted descending so
|
||||
// indices stay valid as we splice.
|
||||
const ranges: Array<[number, number]> = [];
|
||||
ranges.push([block.fenceStart, block.fenceEnd]);
|
||||
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
|
||||
ranges.push([targetStart, targetEnd]);
|
||||
}
|
||||
ranges.sort((a, b) => b[0] - a[0]);
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
lines.splice(start, end - start + 1);
|
||||
// Also drop a trailing blank line if the removal left two in a row.
|
||||
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
|
||||
lines.splice(start, 1);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
const validated = await fetchAll(filePath);
|
||||
return summarizeTrackNote(filePath, validated);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { generateObject } from 'ai';
|
||||
import { trackBlock, PrefixLogger } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||
import { track, PrefixLogger } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { captureLlmUsage } from '../../analytics/usage.js';
|
||||
|
|
@ -19,12 +19,12 @@ export interface ParsedTrack {
|
|||
|
||||
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
|
||||
|
||||
You will receive an event (something that happened — an email, meeting, message, etc.) and a list of track blocks. Each track block has:
|
||||
You will receive an event (something that happened — an email, meeting, message, etc.) and a list of tracks. Each track has:
|
||||
- trackId: an identifier (only unique within its file)
|
||||
- filePath: the note file the track lives in
|
||||
- eventMatchCriteria: a description of what kinds of signals are relevant to this track
|
||||
- matchCriteria: a description of what kinds of signals are relevant to this track (collected from the track's event triggers)
|
||||
|
||||
Your job is to identify which track blocks MIGHT be relevant to this event.
|
||||
Your job is to identify which tracks MIGHT be relevant to this event.
|
||||
|
||||
Rules:
|
||||
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
|
||||
|
|
@ -47,7 +47,7 @@ async function resolveModel() {
|
|||
|
||||
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
|
||||
const trackList = batch
|
||||
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n eventMatchCriteria: ${t.eventMatchCriteria}`)
|
||||
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n matchCriteria: ${t.eventMatchCriteria}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `## Event
|
||||
|
|
@ -58,7 +58,7 @@ Time: ${event.createdAt}
|
|||
|
||||
${event.payload}
|
||||
|
||||
## Track Blocks
|
||||
## Tracks
|
||||
|
||||
${trackList}`;
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ export async function findCandidates(
|
|||
model,
|
||||
system: ROUTING_SYSTEM_PROMPT,
|
||||
prompt: buildRoutingPrompt(event, batch),
|
||||
schema: trackBlock.Pass1OutputSchema,
|
||||
schema: track.Pass1OutputSchema,
|
||||
});
|
||||
captureLlmUsage({
|
||||
useCase: 'track_block',
|
||||
|
|
|
|||
|
|
@ -3,32 +3,29 @@ import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
|
|||
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
|
||||
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date.
|
||||
const TRACK_RUN_INSTRUCTIONS = `You are a track runner — a background agent that keeps a live note in the user's personal knowledge base up to date.
|
||||
|
||||
Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others — optimize for **information density and scannability**, not conversational prose.
|
||||
Your goal on each run: update the body of the note so that, given the track's instruction, the content is the most useful and up-to-date version it can be. The user is maintaining a personal knowledge base and will scan this note alongside many others — optimize for **information density and scannability**, not conversational prose.
|
||||
|
||||
# Background Mode
|
||||
|
||||
You are running as a scheduled or event-triggered background task — **there is no user present** to clarify, approve, or watch.
|
||||
- Do NOT ask clarifying questions — make the most reasonable interpretation of the instruction and proceed.
|
||||
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
|
||||
- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line.
|
||||
- Do NOT produce chat-style output. The user sees only the changes you make to the note plus your final summary line.
|
||||
|
||||
# Message Anatomy
|
||||
|
||||
Every run message has this shape:
|
||||
|
||||
Update track **<trackId>** in \`<filePath>\`.
|
||||
Update track **<id>** in \`<filePath>\`.
|
||||
|
||||
**Time:** <localized datetime> (<timezone>)
|
||||
|
||||
**Instruction:**
|
||||
<the user-authored track instruction — usually 1-3 sentences describing what to produce>
|
||||
|
||||
**Current content:**
|
||||
<the existing contents of the target region, or "(empty — first run)">
|
||||
|
||||
Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`.
|
||||
Start by calling \`workspace-readFile\` on \`<filePath>\` to read the current note (frontmatter + body). Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file.
|
||||
|
||||
For **manual** runs, an optional trailing block may appear:
|
||||
|
||||
|
|
@ -40,20 +37,49 @@ Apply context for this run only — it is not a permanent edit to the instructio
|
|||
For **event-triggered** runs, a trailing block appears instead:
|
||||
|
||||
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
|
||||
**Event match criteria for this track:** <from the track's YAML>
|
||||
**Event match criteria for this track:** <from the track's frontmatter>
|
||||
**Event payload:** <the event body — e.g., an email>
|
||||
**Decision:** ... skip if not relevant ...
|
||||
|
||||
On event runs you are the Pass 2 judge — see "The No-Update Decision" below.
|
||||
|
||||
# Editing the Note
|
||||
|
||||
You have full read/write access to the note body via the standard workspace tools:
|
||||
- \`workspace-readFile\` — read the current state of the note (frontmatter included; you can ignore the frontmatter).
|
||||
- \`workspace-edit\` — apply patches.
|
||||
- \`workspace-writeFile\` — replace the entire file (use sparingly; prefer \`workspace-edit\`).
|
||||
|
||||
**Do NOT modify the YAML frontmatter at the top of the file** (the \`---\`-delimited block). It contains the track configuration and runtime state owned by the user and the runtime. Editing it can corrupt the track's schedule, history, or the note's metadata.
|
||||
|
||||
# Section Placement
|
||||
|
||||
Each track's instruction may name a **section** in the note where its content lives — e.g. *"in a section titled 'Overview' at the top"* or *"in a section titled 'Photo' right after Overview"*. You own that section and only that section.
|
||||
|
||||
How to handle sections:
|
||||
|
||||
- Sections are H2 headings (\`## Section Name\`). Match by exact heading text.
|
||||
- **If the named section exists**: replace its content (everything between that heading and the next H2 — or end of file) with your new output. Heading itself stays intact.
|
||||
- **If the section is missing**: create it. Use the placement hint to decide where:
|
||||
- "at the top" → just below the H1 title (or first line if there's none).
|
||||
- "after X" → immediately after section X. If X doesn't exist either, fall back to natural reading order.
|
||||
- no hint → append to the end of the body.
|
||||
- **Never modify another track's section content.** Other agents own those.
|
||||
- **Never duplicate a section.** If two H2 headings match yours, consolidate into the first.
|
||||
- The user may rename your section's heading. If you can't find it by exact name on a later run, recreate it per the placement hint.
|
||||
|
||||
After writing your section, **re-check its position**. The first time tracks run on a fresh note, sections land in firing order rather than reading order, so the file ends up out of sequence. If your section is now in the wrong place relative to your placement hint (e.g. your "Photo" section is meant to sit right after "Overview" but ended up at the bottom), **move your own section block** (your H2 heading + its content, no surrounding blank lines lost) to the correct position. Cut-and-paste only — never rewrite or reorder *other* tracks' sections; they will self-correct on their own next runs.
|
||||
|
||||
A section can hold prose, lists, or rich blocks (calendar/email/image/etc.) per the instruction. You always write a **complete** replacement for the section you own — not a diff.
|
||||
|
||||
# What Good Output Looks Like
|
||||
|
||||
This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
|
||||
This is a personal knowledge tracker. The user scans many such notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
|
||||
|
||||
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
|
||||
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative — do not improvise a different layout.
|
||||
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape.
|
||||
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
|
||||
- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know.
|
||||
- **No commentary or caveats** unless the data itself is genuinely uncertain.
|
||||
- **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately.
|
||||
|
||||
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events → \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
|
||||
|
|
@ -62,7 +88,7 @@ If the instruction does not specify a format, pick the tightest shape that fits:
|
|||
|
||||
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
|
||||
|
||||
Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
|
||||
Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
|
||||
|
||||
## \`table\` — tabular data (JSON)
|
||||
|
||||
|
|
@ -178,7 +204,7 @@ Use for: linking to a video or design that should render inline.
|
|||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form.
|
||||
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`.
|
||||
|
||||
## \`iframe\` — arbitrary embedded webpage (JSON)
|
||||
|
||||
|
|
@ -227,43 +253,26 @@ The instruction was authored in a prior conversation you cannot see. Treat it as
|
|||
|
||||
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") — these are decoration.
|
||||
|
||||
# Current Content Handling
|
||||
|
||||
The **Current content** block shows what lives in the target region right now. Three cases:
|
||||
|
||||
1. **"(empty — first run)"** — produce the content from scratch.
|
||||
2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep.
|
||||
3. **Content that does NOT match the instruction's format** — the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch.
|
||||
|
||||
You always write a **complete** replacement, not a diff.
|
||||
|
||||
# The No-Update Decision
|
||||
|
||||
You may finish a run without calling \`update-track-content\`. Two legitimate cases:
|
||||
You may finish a run without writing anything. Two legitimate cases:
|
||||
|
||||
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update.
|
||||
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically.
|
||||
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information, skip the update.
|
||||
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system records "no update" automatically.
|
||||
|
||||
When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*.
|
||||
|
||||
# Writing the Result
|
||||
|
||||
Call \`update-track-content\` **at most once per run**:
|
||||
- Pass \`filePath\` and \`trackId\` exactly as given in the message.
|
||||
- Pass the **complete** new content as \`content\` — the entire replacement for the target region.
|
||||
- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those.
|
||||
- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only.
|
||||
|
||||
# Tools
|
||||
|
||||
You have the full workspace toolkit. Quick reference for common cases:
|
||||
|
||||
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off.
|
||||
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
|
||||
- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data.
|
||||
- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data.
|
||||
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
|
||||
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
|
||||
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
|
||||
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view).
|
||||
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes.
|
||||
|
||||
# The Knowledge Graph
|
||||
|
||||
|
|
@ -284,7 +293,7 @@ Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync
|
|||
|
||||
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
|
||||
- Do **not** fabricate or speculate.
|
||||
- Do **not** write partial or placeholder content into the target region — leave existing content intact by not calling \`update-track-content\`.
|
||||
- Do **not** write partial or placeholder content — leave the existing body intact by skipping the edit.
|
||||
- Explain the failure in the summary line.
|
||||
|
||||
# Final Summary
|
||||
|
|
@ -310,7 +319,7 @@ export function buildTrackRunAgent(): z.infer<typeof Agent> {
|
|||
|
||||
return {
|
||||
name: 'track-run',
|
||||
description: 'Background agent that updates track block content',
|
||||
description: 'Background agent that keeps a track-driven note up to date',
|
||||
instructions: TRACK_RUN_INSTRUCTIONS,
|
||||
tools,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import z from 'zod';
|
||||
import { fetchAll, updateTrackBlock } from './fileops.js';
|
||||
import { fetchAll, updateTrack, readNoteBody } from './fileops.js';
|
||||
import { createRun, createMessage } from '../../runs/runs.js';
|
||||
import { getTrackBlockModel } from '../../models/defaults.js';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||
|
|
@ -31,30 +31,41 @@ function buildMessage(
|
|||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
|
||||
// Workspace-relative path the agent's tools (workspace-readFile,
|
||||
// workspace-edit) expect. Internal fileops storage is knowledge/-relative,
|
||||
// so always prefix here when handing it to the agent.
|
||||
const wsPath = `knowledge/${filePath}`;
|
||||
|
||||
let msg = `Update track **${track.track.id}** in \`${wsPath}\`.
|
||||
|
||||
**Time:** ${localNow} (${tz})
|
||||
|
||||
**Instruction:**
|
||||
${track.track.instruction}
|
||||
|
||||
**Current content:**
|
||||
${track.content || '(empty — first run)'}
|
||||
|
||||
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
|
||||
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
|
||||
|
||||
if (trigger === 'event') {
|
||||
const eventCriteria = (track.track.triggers ?? [])
|
||||
.filter(t => t.type === 'event')
|
||||
.map(t => t.matchCriteria)
|
||||
.filter(Boolean);
|
||||
const criteriaText = eventCriteria.length === 0
|
||||
? '(none — should not happen for event-triggered runs)'
|
||||
: eventCriteria.length === 1
|
||||
? eventCriteria[0]
|
||||
: eventCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
||||
msg += `
|
||||
|
||||
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
|
||||
|
||||
**Event match criteria for this track:**
|
||||
${track.track.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)'}
|
||||
${criteriaText}
|
||||
|
||||
**Event payload:**
|
||||
${context ?? '(no payload)'}
|
||||
|
||||
**Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call \`update-track-content\`. Only call the tool if the event provides new or changed information that should be reflected in the track.`;
|
||||
**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that should be reflected in the note.`;
|
||||
} else if (context) {
|
||||
msg += `\n\n**Context:**\n${context}`;
|
||||
}
|
||||
|
|
@ -73,7 +84,7 @@ const runningTracks = new Set<string>();
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trigger an update for a specific track block.
|
||||
* Trigger an update for a specific track.
|
||||
* Can be called by any trigger system (manual, cron, event matching).
|
||||
*/
|
||||
export async function triggerTrackUpdate(
|
||||
|
|
@ -94,17 +105,14 @@ export async function triggerTrackUpdate(
|
|||
try {
|
||||
const tracks = await fetchAll(filePath);
|
||||
logger.log('fetched tracks from file', tracks);
|
||||
const track = tracks.find(t => t.track.trackId === trackId);
|
||||
const track = tracks.find(t => t.track.id === trackId);
|
||||
if (!track) {
|
||||
logger.log('track not found', trackId, filePath, trigger, context);
|
||||
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
|
||||
}
|
||||
|
||||
const contentBefore = track.content;
|
||||
const bodyBefore = await readNoteBody(filePath);
|
||||
|
||||
// Per-track model/provider overrides win when set; otherwise fall back
|
||||
// to the configured trackBlockModel default and the run-creation
|
||||
// provider default (signed-in: rowboat; BYOK: active provider).
|
||||
const model = track.track.model ?? await getTrackBlockModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'track-run',
|
||||
|
|
@ -116,7 +124,7 @@ export async function triggerTrackUpdate(
|
|||
|
||||
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
||||
// the scheduler's next poll won't re-trigger this track.
|
||||
await updateTrackBlock(filePath, trackId, {
|
||||
await updateTrack(filePath, trackId, {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastRunId: agentRun.id,
|
||||
});
|
||||
|
|
@ -134,12 +142,11 @@ export async function triggerTrackUpdate(
|
|||
await waitForRunCompletion(agentRun.id);
|
||||
const summary = await extractAgentResponse(agentRun.id);
|
||||
|
||||
const updatedTracks = await fetchAll(filePath);
|
||||
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
|
||||
const didUpdate = contentAfter !== contentBefore;
|
||||
const bodyAfter = await readNoteBody(filePath);
|
||||
const didUpdate = bodyAfter !== bodyBefore;
|
||||
|
||||
// Update summary on completion
|
||||
await updateTrackBlock(filePath, trackId, {
|
||||
// Patch summary into frontmatter on completion.
|
||||
await updateTrack(filePath, trackId, {
|
||||
lastRunSummary: summary ?? undefined,
|
||||
});
|
||||
|
||||
|
|
@ -155,8 +162,8 @@ export async function triggerTrackUpdate(
|
|||
trackId,
|
||||
runId: agentRun.id,
|
||||
action: didUpdate ? 'replace' : 'no_update',
|
||||
contentBefore: contentBefore ?? null,
|
||||
contentAfter: contentAfter ?? null,
|
||||
contentBefore: bodyBefore,
|
||||
contentAfter: bodyAfter,
|
||||
summary,
|
||||
};
|
||||
} catch (err) {
|
||||
|
|
@ -170,7 +177,7 @@ export async function triggerTrackUpdate(
|
|||
error: msg,
|
||||
});
|
||||
|
||||
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
|
||||
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg };
|
||||
}
|
||||
} finally {
|
||||
runningTracks.delete(key);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { CronExpressionParser } from 'cron-parser';
|
||||
import type { TrackSchedule } from '@x/shared/dist/track-block.js';
|
||||
import type { Trigger } from '@x/shared/dist/track.js';
|
||||
|
||||
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/** Subset of Trigger that fires on a clock — the schedulable types. */
|
||||
export type TimedTrigger = Extract<Trigger, { type: 'cron' | 'window' | 'once' }>;
|
||||
|
||||
/**
|
||||
* Determine if a scheduled track is due to run.
|
||||
* All schedule types enforce a 2-minute grace period — if the scheduled time
|
||||
* was more than 2 minutes ago, it's considered a miss and skipped.
|
||||
* Determine if a timed trigger is due to fire.
|
||||
*
|
||||
* - `cron` and `once` enforce a 2-minute grace window — if the scheduled time
|
||||
* was more than 2 minutes ago, it's considered a miss and skipped (avoids
|
||||
* replay storms after the app was offline at the trigger time).
|
||||
* - `window` is forgiving: it fires at most once per day, anywhere inside the
|
||||
* configured time-of-day band. The day's cycle is anchored at `startTime` —
|
||||
* once a fire lands at-or-after today's startTime, the trigger is done for
|
||||
* the day. Use this for tracks that should "happen sometime in the morning"
|
||||
* rather than "at exactly 8:00am."
|
||||
*/
|
||||
export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string | null): boolean {
|
||||
export function isTriggerDue(schedule: TimedTrigger, lastRunAt: string | null): boolean {
|
||||
const now = new Date();
|
||||
|
||||
switch (schedule.type) {
|
||||
|
|
@ -34,7 +44,7 @@ export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string |
|
|||
}
|
||||
}
|
||||
case 'window': {
|
||||
// Time-of-day filter (applies regardless of lastRunAt state).
|
||||
// Must be inside the time-of-day band.
|
||||
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
|
|
@ -43,16 +53,17 @@ export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string |
|
|||
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
|
||||
|
||||
if (!lastRunAt) return true;
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(schedule.cron, {
|
||||
currentDate: now,
|
||||
});
|
||||
const prevRun = interval.prev().toDate();
|
||||
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
|
||||
return now.getTime() <= prevRun.getTime() + GRACE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Daily cycle anchored at startTime. If we've already fired
|
||||
// strictly after today's startTime, skip until tomorrow. The
|
||||
// strict comparison (>, not >=) means a fire happening exactly
|
||||
// at a window boundary belongs to the earlier window — so two
|
||||
// adjacent windows sharing an endpoint (e.g. 08–12 and 12–15)
|
||||
// each still get their own fire on the same day.
|
||||
const cycleStart = new Date(now);
|
||||
cycleStart.setHours(startHour, startMin, 0, 0);
|
||||
if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false;
|
||||
return true;
|
||||
}
|
||||
case 'once': {
|
||||
if (lastRunAt) return false; // Already ran
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { PrefixLogger } from '@x/shared';
|
|||
import * as workspace from '../../workspace/workspace.js';
|
||||
import { fetchAll } from './fileops.js';
|
||||
import { triggerTrackUpdate } from './runner.js';
|
||||
import { isTrackScheduleDue } from './schedule-utils.js';
|
||||
import { isTriggerDue, type TimedTrigger } from './schedule-utils.js';
|
||||
|
||||
const log = new PrefixLogger('TrackScheduler');
|
||||
const POLL_INTERVAL_MS = 15_000; // 15 seconds
|
||||
|
|
@ -33,17 +33,23 @@ async function processScheduledTracks(): Promise<void> {
|
|||
for (const trackState of tracks) {
|
||||
const { track } = trackState;
|
||||
if (!track.active) continue;
|
||||
if (!track.schedule) continue;
|
||||
if (!track.triggers || track.triggers.length === 0) continue;
|
||||
|
||||
const due = isTrackScheduleDue(track.schedule, track.lastRunAt ?? null);
|
||||
log.log(`Track "${track.trackId}" in ${relativePath}: schedule=${track.schedule.type}, lastRunAt=${track.lastRunAt ?? 'never'}, due=${due}`);
|
||||
const timed: TimedTrigger[] = track.triggers.filter(
|
||||
(t): t is TimedTrigger => t.type !== 'event',
|
||||
);
|
||||
if (timed.length === 0) continue;
|
||||
|
||||
if (due) {
|
||||
log.log(`Triggering "${track.trackId}" in ${relativePath}`);
|
||||
triggerTrackUpdate(track.trackId, relativePath, undefined, 'timed').catch(err => {
|
||||
log.log(`Error running ${track.trackId}:`, err);
|
||||
});
|
||||
const dueTrigger = timed.find(t => isTriggerDue(t, track.lastRunAt ?? null));
|
||||
if (!dueTrigger) {
|
||||
log.log(`Track "${track.id}" in ${relativePath}: ${timed.length} timed trigger(s), none due`);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.log(`Triggering "${track.id}" in ${relativePath} (matched ${dueTrigger.type})`);
|
||||
triggerTrackUpdate(track.id, relativePath, undefined, 'timed').catch(err => {
|
||||
log.log(`Error running ${track.id}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import z from "zod";
|
||||
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
|
||||
import { TrackSchema } from "@x/shared/dist/track.js";
|
||||
|
||||
export const TrackStateSchema = z.object({
|
||||
track: TrackBlockSchema,
|
||||
fenceStart: z.number(),
|
||||
fenceEnd: z.number(),
|
||||
content: z.string(),
|
||||
track: TrackSchema,
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
|
|||
export * as serviceEvents from './service-events.js'
|
||||
export * as inlineTask from './inline-task.js';
|
||||
export * as blocks from './blocks.js';
|
||||
export * as trackBlock from './track-block.js';
|
||||
export * as track from './track.js';
|
||||
export * as promptBlock from './prompt-block.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js';
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { TrackEvent } from './track-block.js';
|
||||
import { TrackEvent } from './track.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
|
|
@ -614,7 +614,7 @@ const ipcSchemas = {
|
|||
// Track channels
|
||||
'track:run': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
|
|
@ -625,22 +625,22 @@ const ipcSchemas = {
|
|||
},
|
||||
'track:get': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
// Fresh, authoritative YAML of the track block from disk.
|
||||
// Renderer should use this for display/edit — never its Tiptap node attr.
|
||||
// Fresh, authoritative YAML of the track from frontmatter.
|
||||
// Renderer should use this for display/edit — never a stale cached copy.
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:update': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
// Partial TrackBlock updates — merged into the block's YAML on disk.
|
||||
// Partial Track updates — merged into the entry on disk.
|
||||
// Backend is the sole writer; avoids races with scheduler/runner writes.
|
||||
updates: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
|
|
@ -652,7 +652,7 @@ const ipcSchemas = {
|
|||
},
|
||||
'track:replaceYaml': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
yaml: z.string(),
|
||||
}),
|
||||
|
|
@ -664,7 +664,7 @@ const ipcSchemas = {
|
|||
},
|
||||
'track:delete': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
|
|
|
|||
|
|
@ -1,33 +1,54 @@
|
|||
import z from 'zod';
|
||||
|
||||
export const TrackScheduleSchema = z.discriminatedUnion('type', [
|
||||
// ---------------------------------------------------------------------------
|
||||
// Triggers — when a track fires
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A track can carry zero or more triggers under the `triggers:` key.
|
||||
// Each trigger is one of:
|
||||
// - cron: exact time, recurring
|
||||
// - window: once per day, anywhere inside a time-of-day band
|
||||
// - once: one-shot at a future time
|
||||
// - event: driven by incoming signals (emails, calendar events, etc.)
|
||||
//
|
||||
// A track can have multiple triggers — e.g. a daily cron trigger AND an event
|
||||
// trigger. Omit `triggers` (or pass an empty array) for a manual-only track.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TriggerSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('cron').describe('Fires at exact cron times'),
|
||||
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
|
||||
}).describe('Recurring at exact times'),
|
||||
z.object({
|
||||
type: z.literal('window').describe('Fires at most once per cron occurrence, only within a time-of-day window'),
|
||||
cron: z.string().describe('5-field cron expression, quoted'),
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
|
||||
}).describe('Recurring within a time-of-day window'),
|
||||
type: z.literal('window').describe('Fires once per day, anywhere inside a time-of-day band'),
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the track fires after this time, it won\'t fire again until the next day.'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
|
||||
}).describe('Recurring within a daily time-of-day window'),
|
||||
z.object({
|
||||
type: z.literal('once').describe('Fires once and never again'),
|
||||
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
|
||||
}).describe('One-shot future run'),
|
||||
]).describe('Optional schedule. Omit entirely for manual-only tracks.');
|
||||
z.object({
|
||||
type: z.literal('event').describe('Fires when a matching event arrives'),
|
||||
matchCriteria: z.string().describe('Describe the kinds of events that should consider this track for an update (e.g. "Emails about Q3 planning"). Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload.'),
|
||||
}).describe('Event-driven'),
|
||||
]);
|
||||
|
||||
export type TrackSchedule = z.infer<typeof TrackScheduleSchema>;
|
||||
export type Trigger = z.infer<typeof TriggerSchema>;
|
||||
|
||||
export const TrackBlockSchema = z.object({
|
||||
trackId: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track entity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackSchema = z.object({
|
||||
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
|
||||
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
|
||||
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||
schedule: TrackScheduleSchema.optional(),
|
||||
triggers: z.array(TriggerSchema).optional().describe('When this track fires. A track can have multiple triggers — e.g. an hourly cron AND an event trigger. Omit (or use an empty array) for a manual-only track.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
|
||||
icon: z.string().optional().describe('Lucide icon name for the chip (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'),
|
||||
icon: z.string().optional().describe('Lucide icon name for status display (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
|
|
@ -59,7 +80,7 @@ export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
|||
|
||||
export const Pass1OutputSchema = z.object({
|
||||
candidates: z.array(z.object({
|
||||
trackId: z.string().describe('The track block identifier'),
|
||||
trackId: z.string().describe('The track identifier'),
|
||||
filePath: z.string().describe('The note file path the track lives in'),
|
||||
})).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'),
|
||||
});
|
||||
|
|
@ -86,5 +107,5 @@ export const TrackRunCompleteEvent = z.object({
|
|||
|
||||
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
|
||||
|
||||
export type TrackBlock = z.infer<typeof TrackBlockSchema>;
|
||||
export type Track = z.infer<typeof TrackSchema>;
|
||||
export type TrackEventType = z.infer<typeof TrackEvent>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue