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:
Ramnique Singh 2026-05-07 18:00:20 +05:30
parent 4709e6eb89
commit db6757514c
36 changed files with 2043 additions and 2275 deletions

View file

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

View file

@ -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,
},
{

View file

@ -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".
`;

View file

@ -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
instruction: |
Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
expression: "0 * * * *"
### Strong signals act, then confirm
Just build the track. Don't ask permission. Confirm in one line at the end.
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
- **Direct**: "track X" the user used the word; you can too in your reply
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
### Medium signals answer the one-off, then offer
Answer the user's actual question first. Then add a single-line offer to keep it updated. If they say yes, build the track. If they don't engage, leave it don't push twice.
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" especially when in a note context
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
Offer line shape (one line, concrete):
> "I can keep this updated here, refreshing every morning — want that?"
### Anti-signals do NOT track
- Definitional questions ("what is X?")
- One-off lookups ("look up X for me")
- Manual document work ("help me write…", "edit this paragraph…")
- General how-to ("how do I do Y?")
## What to say to the user
The user knows the feature as **tracks** and finds them in the **Track sidebar**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "instruction" in user-facing prose unless the user uses them first.
After creating a track, surface where it lives:
> "Done — I've set up a track here that refreshes every morning. Running it once now so you see content right away. You can manage it from the Track sidebar (Radio icon, top-right of the editor)."
After editing one:
> "Updated. Re-running now so you can see the new output."
When skipping a re-run (because the user said not to or "later"):
> "Updated — I'll let it run on its next trigger."
## What Is a Track (concretely)
**Concrete example** a note that shows the current Chicago time, refreshed hourly:
` + "```" + `markdown
---
track:
- id: chicago-time
instruction: |
Show the current time in Chicago, IL in 12-hour format.
active: true
triggers:
- type: cron
expression: "0 * * * *"
---
# Chicago time
(empty the agent will fill this in on the first run)
` + "```" + `
<!--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
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: |
Emails from the finance team about Q3 budget or OKRs.
track:
- id: world-clock
instruction: |
Show current local time for India, Chicago, and Indianapolis as a
3-column markdown table: Location | Local Time | Offset vs India.
One row per location, 24-hour time (HH:MM), no extra prose.
active: true
triggers:
- type: cron
expression: "0 * * * *"
- type: event
matchCriteria: |
Emails from the finance team about Q3 budget or OKRs.
` + "```" + `
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs all literal. No escaping needed.
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `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
instruction: "Show the current time in Chicago, IL in 12-hour format."
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
track:
- id: chicago-time
instruction: "Show the current time in Chicago, IL in 12-hour format."
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
expression: "0 * * * *"
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"
startTime: "09:00"
endTime: "17:00"
triggers:
- type: window
startTime: "09: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
runAt: "2026-04-14T09:00:00"
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>
instruction: |
<instruction, indented 2 spaces, may span multiple lines>
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `markdown
---
track:
- id: <kebab-id>
instruction: |
<instruction, indented 2 spaces, may span multiple lines>
active: true
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>
instruction: |
<what to produce always use ` + "`" + `|` + "`" + `, indented 2 spaces>
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `yaml
track:
- id: <kebab-id>
instruction: |
<what to produce always use ` + "`" + `|` + "`" + `, indented 2 spaces>
active: true
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;

View file

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

View file

@ -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;
@ -24,4 +25,55 @@ export function parseFrontmatter(input: string): {
frontmatter: null,
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}`;
}

View file

@ -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',
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.`,
active: true,
},
id: 'overview',
instruction:
`In a section titled "Overview" at the top of the note: 23 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from calendar_sync/ and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Skip the update if the prior content is still suitable and less than 24h old. VERY IMPORTANT: Ensure that image is wide / low-height!`,
active: true,
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
],
},
{
heading: '## Calendar',
track: {
trackId: 'calendar',
icon: 'calendar-days',
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:
id: 'calendar',
instruction:
`In a section titled "Calendar", emit today's meetings as a \`calendar\` block titled "Today's Meetings". Read calendar_sync/ via workspace-readdir → workspace-readFile each .json. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a conferenceLink.`,
active: true,
triggers: [{
type: 'event',
matchCriteria:
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
active: true,
},
}],
},
{
heading: '## Emails',
track: {
trackId: 'emails',
icon: 'mail',
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.
id: 'emails',
instruction:
`In a section titled "Emails", maintain a digest of email threads worth attention today. Output everything as a **single** fenced code block with language \`emails\` (plural — never individual \`email\` blocks per thread). The body must be JSON shaped \`{"title":"Today's Emails","emails":[...]}\`.
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',
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.`,
active: true,
schedule: {
type: 'cron',
expression: '0 7 * * *',
},
},
id: 'what-you-missed',
instruction:
`In a section titled "What you missed", write a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from knowledge/Meetings/<source>/<yesterday>/ (workspace-readdir recursive on knowledge/Meetings, filter folders matching yesterday's date, read each file). Skim gmail_sync/ for threads that went unresolved. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."`,
active: true,
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
],
},
{
heading: '## Today\'s Priorities',
track: {
trackId: 'priorities',
icon: 'list-todo',
instruction:
`Ranked markdown list of the real, actionable items the user should focus on today.
id: 'priorities',
instruction:
`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.`,
active: true,
schedule: {
type: 'cron',
expression: '30 7 * * *',
If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."`,
active: true,
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
{
type: 'event',
matchCriteria:
`New or updated email threads that may shift today's priorities — urgent reply requests, deadline-bearing items, escalations from people the user cares about.`,
},
},
{
type: 'event',
matchCriteria:
`Calendar changes today that may shift priorities — a meeting moved to clash with a deadline, an unexpected event added, a key meeting cancelled freeing up time.`,
},
],
},
];
function buildDailyNoteContent(): 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 (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] Created today.md');
console.log(
`[DailyNote] Migrated v${currentVersion} → v${CANONICAL_DAILY_NOTE_VERSION}; ` +
`previous version saved to ${backupPath}`,
);
}

View file

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

View file

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

View file

@ -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++;
}
try {
const data = parseYaml(blockLines.join('\n'));
const result = TrackBlockSchema.safeParse(data);
if (result.success) {
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
}
} catch { /* skip */ }
} else if (contentFenceStartMatcher.test(lines[i])) {
const match = contentFenceStartMatcher.exec(lines[i]);
if (match) {
const trackId = match[1];
// have we already collected this track block?
const existingBlock = blocks.find(b => b.track.trackId === trackId);
if (!existingBlock) {
i++;
continue;
}
const contentStart = i + 1;
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
i++;
}
const contentEnd = i;
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
}
}
i++;
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 blocks;
return tracks;
}
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;
export async function fetch(filePath: string, id: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const all = await fetchAll(filePath);
return all.find(t => t.track.id === id) ?? null;
}
export async function fetchYaml(filePath: string, id: string): Promise<string | null> {
const t = await fetch(filePath, id);
if (!t) return null;
return stringifyYaml(t.track).trimEnd();
}
export async function readNoteBody(filePath: string): Promise<string> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return '';
}
return splitFrontmatter(content).body;
}
// ---------------------------------------------------------------------------
// Write
// ---------------------------------------------------------------------------
function findRawIndex(rawTracks: unknown[], id: string): number {
return rawTracks.findIndex(
(raw) => raw && typeof raw === 'object' && (raw as Record<string, unknown>).id === id,
);
}
export async function updateTrack(
filePath: string,
id: string,
updates: Partial<z.infer<typeof TrackSchema>>,
): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
const idx = findRawIndex(rawTracks, id);
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
const next = [...rawTracks];
next[idx] = { ...(rawTracks[idx] as Record<string, unknown>), ...updates };
const nextFm = setTrackArray(frontmatter, next);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
export async function replaceTrackYaml(
filePath: string,
id: string,
newYaml: string,
): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const parsed = TrackSchema.safeParse(parseYaml(newYaml));
if (!parsed.success) throw new Error(`Invalid track YAML: ${parsed.error.message}`);
if (parsed.data.id !== id) {
throw new Error(`id cannot be changed (was "${id}", got "${parsed.data.id}")`);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
const idx = findRawIndex(rawTracks, id);
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
const next = [...rawTracks];
next[idx] = parsed.data;
const nextFm = setTrackArray(frontmatter, next);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
export async function deleteTrack(filePath: string, id: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
const idx = findRawIndex(rawTracks, id);
if (idx === -1) return; // already gone
const next = [...rawTracks];
next.splice(idx, 1);
const nextFm = setTrackArray(frontmatter, next);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
});
}
/**
* Replace the note's body. Frontmatter is preserved (including the `track:`
* array). Used by the runner to commit the agent's body edits without granting
* the agent write access to its own runtime state.
*/
export async function writeNoteBody(filePath: string, newBody: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const content = await fs.readFile(absPath(filePath), 'utf-8');
const { frontmatter } = splitFrontmatter(content);
await fs.writeFile(absPath(filePath), joinFrontmatter(frontmatter, newBody), 'utf-8');
});
}
// ---------------------------------------------------------------------------
// Note-level summaries (tracks-list view)
// ---------------------------------------------------------------------------
type TrackNoteSummary = {
path: string;
trackCount: number;
@ -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);
});
}

View file

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

View file

@ -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,
};

View file

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

View file

@ -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. 0812 and 1215)
// each still get their own fire on the same day.
const cycleStart = new Date(now);
cycleStart.setHours(startHour, startMin, 0, 0);
if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false;
return true;
}
case 'once': {
if (lastRunAt) return false; // Already ran

View file

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

View file

@ -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,
});

View file

@ -9,7 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
export * as serviceEvents from './service-events.js'
export * as inlineTask from './inline-task.js';
export * as blocks from './blocks.js';
export * as 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';

View file

@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { TrackEvent } from './track-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({

View file

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