mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 08:12:38 +02:00
feat: live notes — single objective per note replaces multi-track model
Folds the multi-`track:`-array model into one `live:` block per note: a single persistent objective the live-note agent maintains, plus an optional triggers object (`cronExpr` / `windows` / `eventMatchCriteria`, each independently optional). A note is now passive or live — no per-track scopes, no section ownership contract, no `once` trigger. The agent owns the whole body and makes patch-style incremental edits per run. Highlights: - Schema: `track:` array → single `live:` object (`packages/shared/src/live-note.ts`). - Runtime: scheduler / event processor / runner under `core/knowledge/live-note/`, with split `lastAttemptAt` (every run, drives 5-min backoff) vs `lastRunAt` (success only, anchors cycles). `throwOnError` on agent runs surfaces LLM / billing failures into `lastRunError`. - Today.md: regenerated by template v2 (single objective covering overview / calendar / emails / what-you-missed / priorities; existing files renamed to `Today.md.bkp.<stamp>`). - Renderer: `LiveNoteSidebar` mounts inside the editor row (no chat overlap, auto-closes on note switch); toolbar Radio button becomes a status pill; `LiveNotesView` replaces background-agents view. - Copilot: new `live-note` skill with act-first stance, default folder/cadence pickers, and a non-negotiable rule to extend an existing objective rather than add a second one. Shared `KNOWLEDGE_NOTE_STYLE_GUIDE` enforces terse-and-scannable writing across `doc-collab` and the live-note agent. - Analytics: `track_block` use-case → `live_note_agent`; trigger (`manual` / `cron` / `window` / `event`) becomes the Pass-2 sub-use-case, alongside `routing` for Pass 1. Legacy run files with the old value are read-mapped via `LegacyStartEvent` so they stay openable in the runs list. Hard cutover — no back-compat shims for legacy `track:` frontmatter arrays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0bf7a55611
commit
dabca3da19
59 changed files with 3816 additions and 3212 deletions
|
|
@ -11,7 +11,7 @@ import { execTool } from "../application/lib/exec-tool.js";
|
|||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||
import { buildTrackRunAgent } from "../knowledge/track/run-agent.js";
|
||||
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
|
|
@ -401,8 +401,8 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return buildCopilotAgent();
|
||||
}
|
||||
|
||||
if (id === "track-run") {
|
||||
return buildTrackRunAgent();
|
||||
if (id === "live-note-agent") {
|
||||
return buildLiveNoteAgent();
|
||||
}
|
||||
|
||||
if (id === 'note_creation') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type UseCase = 'copilot_chat' | 'track_block' | 'meeting_note' | 'knowledge_sync';
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'meeting_note' | 'knowledge_sync';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
|
|
|
|||
|
|
@ -78,19 +78,19 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
|
||||
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
|
||||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
|
||||
|
||||
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
|
||||
|
||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||
|
||||
**Tracks (Auto-Updating Notes):** A note's body can be partially or fully agent-maintained — *living* notes that refresh on a schedule or react to incoming emails / calendar events. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the word "track" — load the \`tracks\` skill the moment you spot one.
|
||||
**Live Notes (Self-Updating Knowledge):** A note's body can be agent-maintained — a *live* note refreshes on a schedule and/or reacts to incoming emails / calendar events to satisfy a single persistent **objective**. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the words "live" or "track" — load the \`live-note\` skill the moment you spot one.
|
||||
|
||||
*Strong signals (load the skill, act without asking):* "every morning / daily / hourly…", "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "pin live updates of…", "track / follow X", "whenever a relevant email comes in…".
|
||||
|
||||
*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard").
|
||||
*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), **"what's the latest [news/update/situation] on X" / "what's happening with X" / "any updates on X" / "catch me up on X"** about a person, company, project, or topic, note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic for the catch-all case:** if you reach for \`web-search\` or a news tool to answer a topic-following question, the answer is exactly the kind of thing a live note would refresh on a schedule — load the skill and offer at the end.
|
||||
|
||||
A track is a directive in a note's frontmatter (\`track:\` array entry) with one or more triggers (cron / window / once / event). Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor). When you set one up, tell them where to find it.
|
||||
A live note is a single \`live:\` block in a note's frontmatter — one objective, plus an optional \`triggers\` object (\`cronExpr\` / \`windows\` / \`eventMatchCriteria\`, each independently optional). Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor). **If the note is already live**, extend its existing \`objective\` in natural language to absorb the new ask — never create a second objective. When you make a passive note live (or extend an objective), tell the user where to manage it.
|
||||
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
|
||||
|
||||
**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../../lib/knowledge-note-style.js';
|
||||
|
||||
export const skill = String.raw`
|
||||
# Document Collaboration Skill
|
||||
|
||||
You are an expert document assistant helping the user create, edit, and refine documents in their knowledge base.
|
||||
|
||||
` + KNOWLEDGE_NOTE_STYLE_GUIDE + String.raw`
|
||||
|
||||
> The writing style above is non-negotiable for any content you author or edit in the knowledge base — even small one-off edits. The user's whole knowledge base is built on it. The rest of this skill covers the *workflow* of collaboration; the style guide above covers the *output*.
|
||||
|
||||
|
||||
## FIRST: Ask About Edit Mode
|
||||
|
||||
**Before doing anything else, ask the user:**
|
||||
|
|
@ -237,10 +244,7 @@ Renders a styled table from structured data.
|
|||
|
||||
## Best Practices
|
||||
|
||||
**Writing style:**
|
||||
- Match the user's tone and style in the document
|
||||
- Be concise but complete
|
||||
- Use markdown formatting (headers, bullets, bold, etc.)
|
||||
**Writing style:** see "Knowledge-note writing style" at the top of this skill — that's the canonical guide. Match the user's tone for prose-shaped content (their own narrative writing); for everything else apply the terse-and-scannable rules.
|
||||
|
||||
**Editing:**
|
||||
- Make surgical edits - change only what's needed
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ import appNavigationSkill from "./app-navigation/skill.js";
|
|||
import browserControlSkill from "./browser-control/skill.js";
|
||||
import codeWithAgentsSkill from "./code-with-agents/skill.js";
|
||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||
import tracksSkill from "./tracks/skill.js";
|
||||
import liveNoteSkill from "./live-note/skill.js";
|
||||
import notifyUserSkill from "./notify-user/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
||||
// console.log(tracksSkill);
|
||||
// console.log(liveNoteSkill);
|
||||
|
||||
type SkillDefinition = {
|
||||
id: string; // Also used as folder name
|
||||
|
|
@ -102,10 +102,10 @@ const definitions: SkillDefinition[] = [
|
|||
content: codeWithAgentsSkill,
|
||||
},
|
||||
{
|
||||
id: "tracks",
|
||||
title: "Tracks",
|
||||
summary: "Create and manage tracks — frontmatter directives that keep a note's body auto-updated on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).",
|
||||
content: tracksSkill,
|
||||
id: "live-note",
|
||||
title: "Live Notes",
|
||||
summary: "Make notes self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).",
|
||||
content: liveNoteSkill,
|
||||
},
|
||||
{
|
||||
id: "browser-control",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,639 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(LiveNoteSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The live-note agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, mention it in the objective so the agent doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render the leaderboard as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Plot the rate as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render the dependency map as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Show the agenda as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render the cover photo as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render the demo as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Embed the status page as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render the transcript as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output inside the objective and the live-note agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Live Notes Skill
|
||||
|
||||
A *live note* is a regular markdown note whose body is kept current by a background agent. The user expresses intent via a single \`live:\` block in the note's YAML frontmatter — one persistent **objective** plus an optional \`triggers\` object that says when the agent should fire (cron, time-of-day windows, and/or matching events). A note with no \`live:\` key is just static; adding one makes it live. Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor).
|
||||
|
||||
When this skill is loaded, your job is: make a passive note live (or extend the objective on an already-live note), run the agent once so the user immediately sees content, and tell them where to manage it.
|
||||
|
||||
## Mode: act-first (non-negotiable on strong signals)
|
||||
|
||||
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
|
||||
|
||||
What you must NOT do on a strong-signal ask:
|
||||
- Don't ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic doc editing, not live notes.
|
||||
- Don't ask "where should this live?" — pick a default folder (see below) and proceed.
|
||||
- Don't say "I'll create knowledge/Notes/X.md" without the action attached. Either say "Done — created…" or just do it.
|
||||
- Don't open with an explanation of what a live note is. The user already asked for one.
|
||||
- **Don't ask "should I do this?" — when the request is unambiguous, just do it.** A clarifying question is reserved for *genuine* ambiguity (see "When to ask one short question" below), not as a politeness gate.
|
||||
|
||||
If a previous skill or earlier turn was waiting on edit-mode permission, treat the live-note request as implicit "direct mode" and proceed.
|
||||
|
||||
The two **panel-driven** flows in "Exceptions" at the bottom of this skill are the only places where a first-turn explanation is wanted. Don't bleed that posture into normal asks.
|
||||
|
||||
## Reading the user's intent
|
||||
|
||||
You're loaded any time the user might be asking for something dynamic. Three postures, depending on signal strength:
|
||||
|
||||
### Strong signals — act, then confirm (default behaviour)
|
||||
|
||||
The user used unambiguous language asking for something to be tracked. **Just do it** — pick a default folder, look for an existing matching note, then either extend its objective or create a new live note. Run it once. Confirm in one line. No "should I?" gate.
|
||||
|
||||
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
|
||||
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
|
||||
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
|
||||
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
|
||||
- **Direct**: "set up a [feed / tracker / dashboard / live note] for X", "track X" / "make this live"
|
||||
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
|
||||
|
||||
### Default folder picker (when no note is named)
|
||||
|
||||
When a strong signal lands without a specific note attached, pick the folder by topic shape. Don't ask the user — pick.
|
||||
|
||||
| Topic shape | Default folder |
|
||||
|---|---|
|
||||
| News, headlines, market prices, weather, status pages, reference dashboards | \`knowledge/Notes/\` |
|
||||
| Tasks, monitors, daily briefings, recurring digests of the user's own data, "background agent"-style work | \`knowledge/Tasks/\` |
|
||||
| A specific person (e.g. "track everything about Sarah Chen") | \`knowledge/People/\` |
|
||||
| A specific company / org | \`knowledge/Organizations/\` |
|
||||
| A specific project or workstream | \`knowledge/Projects/\` |
|
||||
| A topic / theme | \`knowledge/Topics/\` |
|
||||
|
||||
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
|
||||
|
||||
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
|
||||
|
||||
### Default cadence picker (when the user didn't specify timing)
|
||||
|
||||
When the user names a topic but doesn't say *how often*, **pick a cadence** — don't ask. Use judgment based on the topic shape. The user can tweak it later in the panel.
|
||||
|
||||
| Topic shape | Default cadence |
|
||||
|---|---|
|
||||
| News / market summary / topic-following / weather / status | One morning **window** \`06:00\`–\`12:00\`. Add an \`eventMatchCriteria\` when the topic could also surface in synced Gmail/Calendar. |
|
||||
| Stock / crypto prices when the user says "real-time" or "throughout the day" | \`cronExpr\` hourly or every 15 min, depending on phrasing. |
|
||||
| Daily briefings / dashboards | Two or three **windows** spanning the workday (morning, midday, post-lunch). |
|
||||
| Email / calendar-driven topics (Q3 emails, customer reschedules) | \`eventMatchCriteria\` only — schedule is "when a relevant signal arrives". Add a single morning window if a fallback baseline refresh feels right. |
|
||||
|
||||
**When in doubt, default to a single morning window \`06:00\`–\`12:00\`.** It's forgiving (fires whenever the user opens the app in the morning) and matches the casual "I'll check this in the morning" expectation.
|
||||
|
||||
Reach for a precise \`cronExpr\` only when the user explicitly demands a clock time ("at 9am sharp", "every 15 minutes"). Casual asks ("every morning", "daily") get windows.
|
||||
|
||||
### When to ask one short question
|
||||
|
||||
Only when the request is **genuinely** ambiguous — not as a politeness gate. Examples:
|
||||
|
||||
- The user named a specific note that doesn't exist AND your search for similar names returned multiple plausible candidates → ask "Did you mean A or B?"
|
||||
- The new ask in an already-live note conflicts with the existing objective (replace, not extend) → ask "Replace the existing objective, or add this on top?"
|
||||
- The topic is too vague to derive a sensible filename or folder ("track stuff for me") → ask one focusing question.
|
||||
|
||||
Pick a single question, get to the action on the next turn. Never stack questions.
|
||||
|
||||
### Medium signals — answer the one-off, then offer
|
||||
|
||||
Answer the user's actual question first. Then add a single-line offer to keep it updated. **The offer is not optional on a medium signal — if you don't add it, you're failing the skill.** If the user says yes, make the note live. If they don't engage, leave it — don't push twice.
|
||||
|
||||
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
|
||||
- **News / updates on a topic**: "what's the latest news on Coinbase?", "what's happening with the Q3 launch?", "any updates on Project Apollo?", "what's new with [person/company]?"
|
||||
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" — especially when in a note context
|
||||
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
|
||||
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
|
||||
|
||||
**Catch-all heuristic:** if you reached for \`web-search\` or a news tool to answer a question about a person, company, project, or topic, the answer is exactly the kind of thing a live note would refresh on a schedule — **always offer** at the end. Same goes for any time-decaying lookup (prices, weather, status).
|
||||
|
||||
Offer line shape (one line, concrete):
|
||||
> "Want me to keep this in a live note that refreshes every morning?"
|
||||
|
||||
Or, when there's a sensible default file already implied (e.g. a topic name):
|
||||
> "I can drop this in \`knowledge/Notes/Coinbase News.md\` and refresh it every morning — want that?"
|
||||
|
||||
The offer goes at the **very end** of your response, on its own line, after the answer is fully delivered.
|
||||
|
||||
### Anti-signals — do NOT make a note live
|
||||
|
||||
- Definitional questions ("what is X?")
|
||||
- One-off lookups ("look up X for me")
|
||||
- Manual document work ("help me write…", "edit this paragraph…")
|
||||
- General how-to ("how do I do Y?")
|
||||
|
||||
## Already-live notes — extend, don't fork
|
||||
|
||||
**This is the most important rule of the skill.** When the user asks you to track something *new* in a note that **already has a \`live:\` block**, edit the existing \`objective\` in natural language to absorb the new ask. Do **not** create a second \`live:\` block. Do **not** introduce some other key. There is exactly one objective per note.
|
||||
|
||||
- The user says "also keep an eye on Hacker News stories about this" → read the current \`objective\`, append/integrate the new ask in natural-language prose, write it back.
|
||||
- The objective ends up longer over time. That's fine. The agent treats it as one coherent intent.
|
||||
- If the new ask conflicts with the old (e.g. user wants to *replace* what the note tracks), ask one short question to confirm before overwriting.
|
||||
|
||||
## What to say to the user
|
||||
|
||||
The user knows the feature as **live notes** and finds them in the **Live notes view**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "objective" in user-facing prose unless the user uses them first.
|
||||
|
||||
**Use past tense.** All of these messages are sent *after* the action — no future-tense "I'll do this" or "I'm going to set this up". The action already happened.
|
||||
|
||||
After making a passive note live (or creating a new live note from scratch):
|
||||
> Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view (Radio icon in the sidebar).
|
||||
|
||||
After extending the objective on an already-live note:
|
||||
> Updated the objective to also cover that. Re-running now so the new output shows up.
|
||||
|
||||
When skipping a re-run (because the user said not to or "later"):
|
||||
> Updated. I'll let it run on its next trigger.
|
||||
|
||||
**Anti-patterns** — don't write any of these:
|
||||
- "I'll set up a live note for you. Should I create knowledge/Notes/News Feed.md?" (future tense, asking permission)
|
||||
- "I need one thing to proceed: which note should this live in?" (asking when default-folder picker tells you the answer)
|
||||
- "That's a live note use case! Here's what I can set up: ..." (preamble + lecture instead of action)
|
||||
- "Here's a comprehensive setup..." or "I've prepared the following..." (decorative framing)
|
||||
|
||||
## Worked example — strong signal, no note named
|
||||
|
||||
**User:** "i want to set up a news feed to track news for India and the world."
|
||||
|
||||
**Right behaviour** (one turn):
|
||||
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
|
||||
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
|
||||
3. No match found → create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
|
||||
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
|
||||
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
|
||||
|
||||
**Wrong behaviour:** running 2 lookup tools, then surfacing a paragraph saying "That's a live note use case, so the clean setup is a self-updating news note with: India headlines, world headlines, a refresh cadence like every morning. I need one thing to proceed: which note should this live in? If you don't already have one, I'll create knowledge/Notes/News Feed.md and make it live there." The user already gave you everything you need. Act.
|
||||
|
||||
## What is a live note (concretely)
|
||||
|
||||
**Concrete example** — a note that shows the current Chicago time, refreshed hourly:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
Show the current time in Chicago, IL in 12-hour format. Keep it as one
|
||||
short line, no extra prose.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
---
|
||||
|
||||
# Chicago time
|
||||
|
||||
(empty — the agent will fill this in on the first run)
|
||||
` + "```" + `
|
||||
|
||||
After the first run, the body might become:
|
||||
|
||||
` + "```" + `markdown
|
||||
# Chicago time
|
||||
|
||||
2:30 PM, Central Time
|
||||
` + "```" + `
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
- News digests or headlines
|
||||
- Stock or crypto prices
|
||||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Living summaries fed by incoming events (emails, meeting notes)
|
||||
- Any recurring content that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
A live note lives entirely in the note's frontmatter — there is no inline marker in the body. The agent owns the entire body below the H1 and writes whatever content the objective demands.
|
||||
|
||||
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
<what this note should keep being>
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
---
|
||||
|
||||
# Note body
|
||||
` + "```" + `
|
||||
|
||||
A note has **at most one** \`live:\` block. Each block has exactly one \`objective\`. The objective can be long and cover several sub-topics — the agent reads it holistically. Omit \`triggers\` (or all three trigger fields) for a manual-only live note.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a \`live:\` block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
` + "```" + `
|
||||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
|
||||
|
||||
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for live-note runs; setting per-note values bypasses that and is almost always wrong.
|
||||
|
||||
The only time these belong on a note:
|
||||
|
||||
- The user **explicitly** named a model or provider for *this specific note* in their request ("use Claude Opus for this one", "force this onto OpenAI"). Quote the user's wording back when confirming.
|
||||
|
||||
Things that are **not** reasons to set these:
|
||||
|
||||
- "It should be fast" / "I want a small model" — that's a global preference, not a per-note one. Leave it; the global default exists.
|
||||
- "This note is complex" — write a clearer objective; don't reach for a different model.
|
||||
- "Just to be safe" / "in case it matters" — antipattern. Leave them out.
|
||||
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
|
||||
|
||||
## Writing a Good Objective
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Live-note output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to keep up to date, what to source from, and what shape the output should take.
|
||||
- **Multi-faceted is OK.** Unlike the old per-track model, a single objective can cover several related sub-topics — list them inside the objective text and let the agent organize the body. Don't fork a second objective.
|
||||
- **Imperative voice.** "Keep this note updated with…", "Show…", "Maintain a section titled…".
|
||||
- **Specify output shape when shape matters.** "One line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items", or pick a rich block (see "Rich block render" below).
|
||||
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
The objective runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"). The live-note agent only sees the objective — not this chat, not what it produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the note is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Per-trigger guidance (advanced)
|
||||
|
||||
**Default behaviour:** one objective serves all triggers — cron, window, event, and manual runs all see the same intent. **Don't reach for per-trigger branching unless the run actually needs to behave differently.**
|
||||
|
||||
The agent always receives a \`**Trigger:**\` line in its run message telling it which trigger fired:
|
||||
- \`Manual run (user-triggered)\` — Run button or Copilot tool.
|
||||
- \`Scheduled refresh — the cron expression \\\`<expr>\\\` matched\` — exact-time refresh.
|
||||
- \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh.
|
||||
- \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive.
|
||||
|
||||
**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window — pull a full snapshot from local data) and a *reactive* update (event — integrate one new signal). The flagship case is the **Today.md emails section**: on a window run it scans \`gmail_sync/\` for everything worth attention; on an event run with an incoming email payload it integrates that one thread into the existing digest without re-listing previously-seen threads. Same objective, two branches.
|
||||
|
||||
How to write it — use plain conditional language inside the objective:
|
||||
|
||||
\`\`\`yaml
|
||||
live:
|
||||
objective: |
|
||||
Maintain a digest of email threads worth attention today, as a single \`emails\` block.
|
||||
|
||||
Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the
|
||||
full digest from scratch.
|
||||
|
||||
With an event payload (event run): integrate the new thread into the existing digest —
|
||||
add it if new, update its entry if the threadId is already shown — and don't re-list
|
||||
threads the user has already seen unless their state changed.
|
||||
\`\`\`
|
||||
|
||||
Notice: the objective doesn't mention "cron" or "window" by name, just describes the conditions. The agent reads its \`**Trigger:**\` line and matches the right branch.
|
||||
|
||||
**Don't branch for stylistic reasons** ("on cron be terse, on event be verbose"). Branching is for *what data to look at* and *whether to do an incremental vs full update*, not for tone.
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **A second \`live:\` block** when one already exists — extend the existing objective instead.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
|
||||
## YAML String Style (critical — read before writing the ` + "`" + `objective` + "`" + ` or ` + "`" + `triggers.eventMatchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — \`objective\` and \`triggers.eventMatchCriteria\` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes \`lastRunAt\`, \`lastRunSummary\`, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the field gets truncated.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `objective` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.**
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
eventMatchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line.
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields:
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: "Show the current time in Chicago, IL in 12-hour format."
|
||||
active: true
|
||||
` + "```" + `
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
\`lastRunAt\`, \`lastRunId\`, \`lastRunSummary\` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Triggers
|
||||
|
||||
The \`triggers\` object has three optional sub-fields. Mix freely; presence of a field is the marker that the note should fire on that channel.
|
||||
|
||||
- \`cronExpr\` — fires at an exact recurring time (5-field cron string).
|
||||
- \`windows\` — list of \`{ startTime, endTime }\` bands; the agent fires once per day per window, anywhere inside the band.
|
||||
- \`eventMatchCriteria\` — natural-language description of which incoming events (emails, calendar changes) should wake the note.
|
||||
|
||||
Omit ` + "`" + `triggers` + "`" + ` entirely (or omit all three sub-fields) for a **manual-only** live note — the user runs it from the Run button in the panel.
|
||||
|
||||
### \`cronExpr\`
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Always quote the cron expression — it contains spaces and ` + "`" + `*` + "`" + `.
|
||||
|
||||
### \`windows\`
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
windows:
|
||||
- { startTime: "09:00", endTime: "12:00" }
|
||||
- { startTime: "13:00", endTime: "15:00" }
|
||||
` + "```" + `
|
||||
|
||||
Each window fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at \`startTime\` — once a fire lands at-or-after today's start, that window is done for the day. Use windows when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
|
||||
|
||||
### \`eventMatchCriteria\`
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How event triggering works:
|
||||
1. When a new event arrives, a fast LLM classifier checks each live note's \`eventMatchCriteria\` (and its objective) against the event content.
|
||||
2. If it might match, the live-note agent receives both the event payload and the existing note body, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
### Combining trigger fields
|
||||
|
||||
Mix freely. Example — a note that refreshes weekday mornings AND on incoming Q3 emails:
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: |
|
||||
Maintain a running summary of decisions and open questions about Q3 planning.
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 9 * * 1-5"
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
- ` + "`" + `"*/15 * * * *"` + "`" + ` — every 15 minutes
|
||||
- ` + "`" + `"0 * * * *"` + "`" + ` — every hour on the hour
|
||||
- ` + "`" + `"0 8 * * *"` + "`" + ` — daily at 8am
|
||||
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` — weekdays at 9am
|
||||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Making a passive note live (no \`live:\` block yet)
|
||||
|
||||
1. \`workspace-readFile({ path })\` — re-read fresh.
|
||||
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
|
||||
3. \`workspace-edit\`:
|
||||
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
|
||||
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
|
||||
|
||||
### Extending an already-live note
|
||||
|
||||
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`.
|
||||
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
|
||||
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should this be in?"
|
||||
3. Apply the workflow above (extend if already live, create if passive).
|
||||
|
||||
### No note context at all
|
||||
|
||||
If the user used a strong signal but didn't name a specific note: **don't ask** "which note?" — use the Default folder picker (above) and proceed. Create the file with a sensible filename derived from the topic.
|
||||
|
||||
If the user used a medium signal with no note: answer the one-off, then offer to make it live somewhere (and pick the folder when they say yes).
|
||||
|
||||
## Exceptions — first-turn confirmation only when…
|
||||
|
||||
The two flows below are the **only** exceptions to the act-first default. They have explicit panel/card context that wants a brief explanation before the user commits. Don't bleed this posture into normal asks — outside these flows, strong signals get acted on, not explained.
|
||||
|
||||
### Exception 1: Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel with a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as \`knowledge/Topics/\` or \`knowledge/People/\`
|
||||
|
||||
This is a *browse* gesture, not a commit gesture — the user might back out. So:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the live note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it (extend objective if already live; make it live otherwise).
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The \`live:\` block should be the core of the note.
|
||||
|
||||
### Exception 2: New-live-note panel flow (panel-driven, no note named)
|
||||
|
||||
The user clicks the "New live note" button in the **Live notes** view and the opening message is the canned "I want to set up a Live note / task." (no specific topic, no note named). This is the only case where you ask before acting — but the ask is minimal.
|
||||
|
||||
On the first turn, reply with **just** a one-line prompt and 2-3 concrete examples. **Do not** explain what a live note is. **Do not** ask about cadence, folder, or format — you'll pick those yourself once they name a topic. Examples to draw from (pick 2-3 that span different shapes):
|
||||
|
||||
- A daily news feed for a topic ("AI coding agents", "India + world news")
|
||||
- A market summary ("BTC, ETH, SPY each morning")
|
||||
- A weekly Q3-emails digest from your inbox
|
||||
- A morning weather + commute-conditions briefing
|
||||
- A live dashboard for an ongoing project
|
||||
|
||||
Shape your reply roughly like:
|
||||
|
||||
> What would you like to track? A few examples to spark ideas:
|
||||
> - A daily news feed for a topic
|
||||
> - A market summary
|
||||
> - A digest of relevant emails
|
||||
|
||||
Once the user names a topic, **drop into the strong-signal flow**: use the Default folder picker for location, the Default cadence picker for timing, search for an existing match, extend or create, run once, confirm in one line. Don't bounce back with "great — and how often should it refresh?" — pick.
|
||||
|
||||
**The trigger for Exception 2 is specifically the generic "I want to set up a Live note / task." opening.** A user asking "set up a news feed for India and the world" is *not* in this flow — that's a strong signal, act on it.
|
||||
|
||||
## The Exact Frontmatter Shape
|
||||
|
||||
For a brand-new live note:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
live:
|
||||
objective: |
|
||||
<objective, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
---
|
||||
|
||||
# <Note title>
|
||||
` + "```" + `
|
||||
|
||||
**Rules:**
|
||||
- \`live:\` is at the top level of the frontmatter, never nested under other keys.
|
||||
- There is **at most one** \`live:\` block per note.
|
||||
- 2-space YAML indent throughout. No tabs.
|
||||
- \`triggers:\` is an object, not an array. Each sub-field (\`cronExpr\`, \`windows\`, \`eventMatchCriteria\`) is independently optional. Omit \`triggers\` entirely for manual-only.
|
||||
- **Always use the literal block scalar (\`|\`)** for \`objective\` and \`eventMatchCriteria\`.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and \`*\`.
|
||||
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The live-note agent edits the body on its first run.
|
||||
|
||||
## After Creating or Editing a Live Note
|
||||
|
||||
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the \`run-live-note-agent\` tool — same as the user clicking Run in the panel.
|
||||
|
||||
Why default-on:
|
||||
- For event-driven live notes (with \`eventMatchCriteria\`), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
|
||||
- For notes that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill \`context\` (see below) seeds rich initial content.
|
||||
- After an edit, the user expects to see the updated output without an extra round-trip.
|
||||
|
||||
Confirm in one line and tell the user where to find it:
|
||||
> "Done — this note is live, refreshing hourly. Running it once now so you see content right away. You can manage it from the Live Note panel."
|
||||
|
||||
For an objective extension on an already-live note:
|
||||
> "Updated the objective. Re-running now so you see the new output."
|
||||
|
||||
If you skipped the re-run (user said not to):
|
||||
> "Updated — I'll let it run on its next trigger."
|
||||
|
||||
**Do not** write content into the note body yourself — that's the live-note agent's job, delegated via \`run-live-note-agent\`.
|
||||
|
||||
## Using the \`run-live-note-agent\` tool
|
||||
|
||||
\`run-live-note-agent\` triggers a single run right now. You can pass an optional \`context\` string to bias *this run only* without modifying the objective — the difference between a stock refresh and a smart backfill.
|
||||
|
||||
### Backfill \`context\` examples
|
||||
|
||||
- A newly-live note watching Q3 emails → run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
|
||||
- A new note tracking this week's customer calls → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
- Plain refresh (user said "run it now"): **omit \`context\`**. Don't invent it.
|
||||
|
||||
### Reading the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- \`action: 'replace'\` → body changed. Confirm in one line; optionally cite the first line of \`contentAfter\`.
|
||||
- \`action: 'no_update'\` → agent decided nothing needed to change. Tell the user briefly; \`summary\` usually explains why.
|
||||
- \`error: 'Already running'\` → another run is in flight; tell the user to retry shortly.
|
||||
- Other \`error\` → surface concisely.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't run more than once** per user-facing action — one tool call per turn.
|
||||
- **Don't pass \`context\`** for a plain refresh — it can mislead the agent.
|
||||
- **Don't write content into the note body yourself** — always delegate via \`run-live-note-agent\`.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't create a second \`live:\` block** when one already exists — extend the existing \`objective\`.
|
||||
- **Don't add \`triggers\`** if the user explicitly wants manual-only.
|
||||
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Live Note
|
||||
|
||||
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar).
|
||||
|
||||
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object.
|
||||
|
||||
**Pause without removing:** flip \`active: false\`.
|
||||
|
||||
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template (frontmatter only):
|
||||
|
||||
` + "```" + `yaml
|
||||
live:
|
||||
objective: |
|
||||
<objective — always use \`|\`, indented 2 spaces>
|
||||
active: true
|
||||
triggers:
|
||||
cronExpr: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Top cron expressions: \`"0 * * * *"\` (hourly), \`"0 8 * * *"\` (daily 8am), \`"0 9 * * 1-5"\` (weekdays 9am), \`"*/15 * * * *"\` (every 15m).
|
||||
|
||||
YAML style reminder: \`objective\` and \`eventMatchCriteria\` are **always** \`|\` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackSchema } from '@x/shared/dist/track.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The track agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Tracks Skill
|
||||
|
||||
A track is a directive in a note's YAML frontmatter (under the ` + "`" + `track:` + "`" + ` array) that turns the note's body into a *living* document — refreshed on a schedule or reactively when a matching email / calendar event arrives. A note with no ` + "`" + `track:` + "`" + ` key is just static; one or more entries under it make it live. Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor).
|
||||
|
||||
When this skill is loaded, your job is: set up (or update) a track, run it once so the user immediately sees content, and tell them where to manage it.
|
||||
|
||||
## Mode: act-first
|
||||
|
||||
Track creation and editing are action-first. Read the file, update the frontmatter via ` + "`" + `workspace-edit` + "`" + `, run the track once. Do not ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
|
||||
- If another skill or earlier turn was waiting on edit-mode permission, treat the track request as implicit "direct mode" and proceed.
|
||||
- You may ask **one** short clarifying question only when genuinely ambiguous (e.g. *which* note). Never ask about permission to edit.
|
||||
- The Suggested Topics and Background Agent setup flows below are first-turn-confirmation exceptions — leave those intact.
|
||||
|
||||
## Reading the user's intent
|
||||
|
||||
You're loaded any time the user might be asking for something dynamic. Two postures, depending on signal strength:
|
||||
|
||||
### Strong signals — act, then confirm
|
||||
|
||||
Just build the track. Don't ask permission. Confirm in one line at the end.
|
||||
|
||||
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
|
||||
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
|
||||
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
|
||||
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
|
||||
- **Direct**: "track X" — the user used the word; you can too in your reply
|
||||
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
|
||||
|
||||
### Medium signals — answer the one-off, then offer
|
||||
|
||||
Answer the user's actual question first. Then add a single-line offer to keep it updated. If they say yes, build the track. If they don't engage, leave it — don't push twice.
|
||||
|
||||
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
|
||||
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" — especially when in a note context
|
||||
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
|
||||
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
|
||||
|
||||
Offer line shape (one line, concrete):
|
||||
> "I can keep this updated here, refreshing every morning — want that?"
|
||||
|
||||
### Anti-signals — do NOT track
|
||||
|
||||
- Definitional questions ("what is X?")
|
||||
- One-off lookups ("look up X for me")
|
||||
- Manual document work ("help me write…", "edit this paragraph…")
|
||||
- General how-to ("how do I do Y?")
|
||||
|
||||
## What to say to the user
|
||||
|
||||
The user knows the feature as **tracks** and finds them in the **Track sidebar**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "instruction" in user-facing prose unless the user uses them first.
|
||||
|
||||
After creating a track, surface where it lives:
|
||||
> "Done — I've set up a track here that refreshes every morning. Running it once now so you see content right away. You can manage it from the Track sidebar (Radio icon, top-right of the editor)."
|
||||
|
||||
After editing one:
|
||||
> "Updated. Re-running now so you can see the new output."
|
||||
|
||||
When skipping a re-run (because the user said not to or "later"):
|
||||
> "Updated — I'll let it run on its next trigger."
|
||||
|
||||
## What Is a Track (concretely)
|
||||
|
||||
**Concrete example** — a note that shows the current Chicago time, refreshed hourly:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
track:
|
||||
- id: chicago-time
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
---
|
||||
|
||||
# Chicago time
|
||||
|
||||
(empty — the agent will fill this in on the first run)
|
||||
` + "```" + `
|
||||
|
||||
After the first run, the body might become:
|
||||
|
||||
` + "```" + `markdown
|
||||
# Chicago time
|
||||
|
||||
2:30 PM, Central Time
|
||||
` + "```" + `
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
- News digests or headlines
|
||||
- Stock or crypto prices
|
||||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Living summaries fed by incoming events (emails, meeting notes)
|
||||
- Any recurring content that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
A track lives entirely in the note's frontmatter — there is no inline marker in the body. The agent writes whatever content the instruction demands into the body itself, choosing where to place it based on the existing structure.
|
||||
|
||||
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
track:
|
||||
- id: <kebab-id>
|
||||
instruction: |
|
||||
<what the agent should produce>
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
---
|
||||
|
||||
# Note body
|
||||
` + "```" + `
|
||||
|
||||
A note may have multiple entries under ` + "`" + `track:` + "`" + ` — they run independently. Each entry can have multiple triggers (e.g. an hourly cron AND an event trigger). Omit ` + "`" + `triggers` + "`" + ` for a manual-only track.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a single track entry (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
` + "```" + `
|
||||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
|
||||
|
||||
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for tracks; setting per-track values bypasses that and is almost always wrong.
|
||||
|
||||
The only time these belong on a track:
|
||||
|
||||
- The user **explicitly** named a model or provider for *this specific track* in their request ("use Claude Opus for this one", "force this track onto OpenAI"). Quote the user's wording back when confirming.
|
||||
|
||||
Things that are **not** reasons to set these:
|
||||
|
||||
- "Tracks should be fast" / "I want a small model" — that's a global preference, not a per-track one. Leave it; the global default exists.
|
||||
- "This track is complex" — write a clearer instruction; don't reach for a different model.
|
||||
- "Just to be safe" / "in case it matters" — this is the antipattern. Leave them out.
|
||||
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
|
||||
|
||||
## Choosing an ` + "`" + `id` + "`" + `
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
- **Must be unique within the note's ` + "`" + `track:` + "`" + ` array.** Before inserting, read the file and check existing ` + "`" + `id:` + "`" + ` values.
|
||||
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
|
||||
- Don't reuse an old ID even if a previous entry was deleted — pick a fresh one.
|
||||
|
||||
## Writing a Good Instruction
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to fetch or compute.
|
||||
- **Single-focus.** One track = one purpose. Split "weather + news + stocks" into three tracks, don't bundle.
|
||||
- **Imperative voice, 1-3 sentences.**
|
||||
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
|
||||
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction — not this chat, not what you produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the user is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **Bundling multiple purposes** into one instruction — split into separate tracks.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or event-trigger ` + "`" + `matchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `, every time.**
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: world-clock
|
||||
instruction: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
- type: event
|
||||
matchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line.
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields:
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: chicago-time
|
||||
instruction: "Show the current time in Chicago, IL in 12-hour format."
|
||||
active: true
|
||||
` + "```" + `
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Triggers
|
||||
|
||||
A track has zero or more **triggers** under a single ` + "`" + `triggers:` + "`" + ` array. Each trigger is one of four types:
|
||||
|
||||
- ` + "`" + `cron` + "`" + ` — fires at an exact time, recurring
|
||||
- ` + "`" + `window` + "`" + ` — once per day, anywhere inside a time-of-day band
|
||||
- ` + "`" + `once` + "`" + ` — one-shot at a future time
|
||||
- ` + "`" + `event` + "`" + ` — fires when a matching event arrives (emails, calendar, etc.)
|
||||
|
||||
A track can carry **multiple triggers** of any mix. Omit ` + "`" + `triggers` + "`" + ` (or use an empty array) for a **manual-only** track — the user triggers it via the Run button in the sidebar.
|
||||
|
||||
### ` + "`" + `cron` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
### ` + "`" + `window` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
- type: window
|
||||
startTime: "09:00"
|
||||
endTime: "12:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at ` + "`" + `startTime` + "`" + ` — once a fire lands at-or-after today's start, the trigger is done for the day. Use this when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
|
||||
|
||||
### ` + "`" + `once` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
- type: once
|
||||
runAt: "2026-04-14T09:00:00"
|
||||
` + "```" + `
|
||||
|
||||
Local time, no ` + "`" + `Z` + "`" + ` suffix.
|
||||
|
||||
### ` + "`" + `event` + "`" + ` trigger
|
||||
|
||||
` + "```" + `yaml
|
||||
triggers:
|
||||
- type: event
|
||||
matchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How event triggers work:
|
||||
1. When a new event arrives, a fast LLM classifier checks each event trigger's ` + "`" + `matchCriteria` + "`" + ` against the event content.
|
||||
2. If it might match, the track-run agent receives both the event payload and the existing note body, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
### Combining multiple triggers
|
||||
|
||||
A single track can have any combination — e.g. an hourly cron AND an event trigger:
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: q3-emails
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3 planning.
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 9 * * 1-5"
|
||||
- type: event
|
||||
matchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
This track refreshes on schedule (weekdays at 9am) AND on every relevant incoming email.
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
- ` + "`" + `"*/15 * * * *"` + "`" + ` — every 15 minutes
|
||||
- ` + "`" + `"0 * * * *"` + "`" + ` — every hour on the hour
|
||||
- ` + "`" + `"0 8 * * *"` + "`" + ` — daily at 8am
|
||||
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` — weekdays at 9am
|
||||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Adding a track to an existing note
|
||||
|
||||
1. ` + "`" + `workspace-readFile({ path })` + "`" + ` — re-read fresh.
|
||||
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any). Note the existing ` + "`" + `track:` + "`" + ` ids if present.
|
||||
3. Construct the new track entry as YAML.
|
||||
4. ` + "`" + `workspace-edit` + "`" + `:
|
||||
- **If the note has frontmatter and a ` + "`" + `track:` + "`" + ` array already**: anchor on a unique line in/near the array and splice your new entry in.
|
||||
- **If the note has frontmatter but no ` + "`" + `track:` + "`" + ` array**: anchor on the closing ` + "`" + `---` + "`" + ` of the frontmatter, and insert ` + "`" + `track:\n - id: ...` + "`" + ` etc. just before it.
|
||||
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (` + "`" + `---\n` + "`" + ` ... ` + "`" + `\n---\n` + "`" + ` followed by the original first line).
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should I add the track to?"
|
||||
3. Update the note's frontmatter ` + "`" + `track:` + "`" + ` array using the workflow above.
|
||||
|
||||
### No note context at all
|
||||
|
||||
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
|
||||
|
||||
### Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track entry should be the core of the note.
|
||||
|
||||
### Background agent setup flow
|
||||
|
||||
Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet.
|
||||
|
||||
In this flow, treat "background agent" and "track" as the same feature. The user-facing term can stay "background agent", but the implementation is a track in a note's frontmatter. Do **not** claim these are different systems.
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run.
|
||||
2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder.
|
||||
3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder.
|
||||
4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists.
|
||||
5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup.
|
||||
6. Keep the surrounding note scaffolding minimal but useful.
|
||||
|
||||
## The Exact Frontmatter Shape
|
||||
|
||||
For a brand-new note:
|
||||
|
||||
` + "```" + `markdown
|
||||
---
|
||||
track:
|
||||
- id: <kebab-id>
|
||||
instruction: |
|
||||
<instruction, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
---
|
||||
|
||||
# <Note title>
|
||||
` + "```" + `
|
||||
|
||||
**Rules:**
|
||||
- ` + "`" + `track:` + "`" + ` is at the top level of the frontmatter, never nested.
|
||||
- Each entry is a list item starting with ` + "`" + `- id:` + "`" + `. 2-space YAML indent. No tabs.
|
||||
- ` + "`" + `triggers:` + "`" + ` is an array. Omit it for a manual-only track. Multiple entries are allowed (any mix of cron / window / once / event).
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `.
|
||||
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The track agent will edit the body on its first run.
|
||||
|
||||
## After Creating or Editing a Track
|
||||
|
||||
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the ` + "`" + `run-track` + "`" + ` tool — same as the user clicking Run in the sidebar.
|
||||
|
||||
Why default-on:
|
||||
- For event-driven tracks (with ` + "`" + `event` + "`" + ` triggers), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
|
||||
- For tracks that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill ` + "`" + `context` + "`" + ` (see below) seeds rich initial content.
|
||||
- After an edit, the user expects to see the updated output without an extra round-trip.
|
||||
|
||||
Confirm in one line and tell the user where to find it:
|
||||
> "Done — I've set up a track refreshing hourly. Running it once now so you see content right away. You can manage it from the Track sidebar."
|
||||
|
||||
For an edit:
|
||||
> "Updated. Re-running now so you can see the new output."
|
||||
|
||||
If you skipped the re-run (user said not to):
|
||||
> "Updated — I'll let it run on its next trigger."
|
||||
|
||||
**Do not** write content into the note body yourself — that's the track agent's job, delegated via ` + "`" + `run-track` + "`" + `.
|
||||
|
||||
## Using the ` + "`" + `run-track` + "`" + ` tool
|
||||
|
||||
` + "`" + `run-track` + "`" + ` triggers a single run right now. You can pass an optional ` + "`" + `context` + "`" + ` string to bias *this run only* without modifying the track's instruction — the difference between a stock refresh and a smart backfill.
|
||||
|
||||
### Backfill ` + "`" + `context` + "`" + ` examples
|
||||
|
||||
- New event-driven track on Q3 emails → run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
|
||||
- New track on this week's customer calls → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
- Plain refresh (user said "run it now"): **omit ` + "`" + `context` + "`" + `**. Don't invent it.
|
||||
|
||||
### Reading the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- ` + "`" + `action: 'replace'` + "`" + ` → body changed. Confirm in one line; optionally cite the first line of ` + "`" + `contentAfter` + "`" + `.
|
||||
- ` + "`" + `action: 'no_update'` + "`" + ` → agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` usually explains why.
|
||||
- ` + "`" + `error: 'Already running'` + "`" + ` → another run is in flight; tell the user to retry shortly.
|
||||
- Other ` + "`" + `error` + "`" + ` → surface concisely.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't run more than once** per user-facing action — one tool call per turn.
|
||||
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — it can mislead the agent.
|
||||
- **Don't write content into the note body yourself** — always delegate via ` + "`" + `run-track` + "`" + `.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't reuse** an existing ` + "`" + `id` + "`" + ` in the same note's ` + "`" + `track:` + "`" + ` array.
|
||||
- **Don't add ` + "`" + `triggers` + "`" + `** if the user explicitly wants a manual-only track.
|
||||
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` — runtime-managed.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` — local time only.
|
||||
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file — always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Track
|
||||
|
||||
**Change triggers or instruction:** ` + "`" + `workspace-edit` + "`" + ` the relevant fields inside the ` + "`" + `track:` + "`" + ` array. Anchor on the unique ` + "`" + `id: <id>` + "`" + ` line plus a few surrounding lines.
|
||||
|
||||
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
|
||||
|
||||
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full track entry (from its ` + "`" + `- id:` + "`" + ` line down to just before the next ` + "`" + `- id:` + "`" + ` line or the closing ` + "`" + `---` + "`" + ` of the frontmatter), ` + "`" + `newString` + "`" + ` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template (frontmatter only):
|
||||
|
||||
` + "```" + `yaml
|
||||
track:
|
||||
- id: <kebab-id>
|
||||
instruction: |
|
||||
<what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces>
|
||||
active: true
|
||||
triggers:
|
||||
- type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
|
||||
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -1550,25 +1550,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
isAvailable: async () => isComposioConfigured(),
|
||||
},
|
||||
'run-track': {
|
||||
description: "Manually trigger a track to run now on its host note. Equivalent to the user clicking the Run button on the track in the sidebar, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking track from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
|
||||
'run-live-note-agent': {
|
||||
description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
|
||||
id: z.string().describe("The track's id (must exist in the note's frontmatter `track:` array)"),
|
||||
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md'). The note must already have a `live:` block in its frontmatter."),
|
||||
context: z.string().optional().describe(
|
||||
"Optional extra context for the track agent to consider for THIS run only — does not modify the track's instruction. " +
|
||||
"Optional extra context for the live-note agent to consider for THIS run only — does not modify the note's objective. " +
|
||||
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
|
||||
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
|
||||
"Omit for a plain refresh."
|
||||
),
|
||||
}),
|
||||
execute: async ({ filePath, id, context }: { filePath: string; id: string; context?: string }) => {
|
||||
execute: async ({ filePath, context }: { filePath: string; context?: string }) => {
|
||||
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
|
||||
try {
|
||||
// Lazy import to break a module-init cycle:
|
||||
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
|
||||
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
|
||||
const result = await triggerTrackUpdate(id, knowledgeRelativePath, context, 'manual');
|
||||
// builtin-tools → live-note/runner → runs/runs → agents/runtime → builtin-tools
|
||||
const { runLiveNoteAgent } = await import("../../knowledge/live-note/runner.js");
|
||||
const result = await runLiveNoteAgent(knowledgeRelativePath, 'manual', context);
|
||||
return {
|
||||
success: !result.error,
|
||||
runId: result.runId,
|
||||
|
|
|
|||
141
apps/x/packages/core/src/application/lib/knowledge-note-style.ts
Normal file
141
apps/x/packages/core/src/application/lib/knowledge-note-style.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* The canonical writing style for content written into the user's knowledge
|
||||
* base. Imported by both the `doc-collab` skill (so Copilot picks it up on
|
||||
* note edits) and the live-note run-agent prompt (so background runs use the
|
||||
* same rules without having to load the skill on every fire). One source of
|
||||
* truth, two consumers.
|
||||
*
|
||||
* If you change this guide, restart the dev server / rebuild — both consumers
|
||||
* inline it at module load.
|
||||
*/
|
||||
export const KNOWLEDGE_NOTE_STYLE_GUIDE = `# Knowledge-note writing style — terse and scannable
|
||||
|
||||
The user's knowledge base is a place they **scan**, not read. Every note competes for attention against many others. Optimize aggressively for **information density and signal-per-line**. These rules apply whether you're authoring a new note, refreshing a live note, or making a one-off edit — they are not optional.
|
||||
|
||||
## The frame
|
||||
|
||||
- The reader wants the answer to "what's current / what changed?" in the fewest words that carry real information.
|
||||
- A reader scanning ten notes in a row will give each one ~2 seconds. Format for that.
|
||||
- Prose is the wrong shape for almost everything. Reach for it only when the content genuinely is a paragraph (user-written analysis, meeting reflection, qualitative narrative). Informational content — facts, lists, status, news, prices, weather — uses tighter shapes.
|
||||
|
||||
## Tightest shape that fits — pick from this ladder
|
||||
|
||||
**1. Single line** when the answer is one fact.
|
||||
- Weather: \`24°, Cloudy · NE 8mph · 12% PoP\`
|
||||
- Price: \`BTC: $67,432 (+1.2% 24h)\`
|
||||
- Time: \`2:30 PM IST\`
|
||||
- Status: \`✓ All systems operational\` or \`⚠ db: degraded\`
|
||||
|
||||
**2. Compact table** for 2+ parallel items with the same shape.
|
||||
\`\`\`
|
||||
| Symbol | Price | Δ24h |
|
||||
|--------|------:|------:|
|
||||
| BTC | $67k | +1.2% |
|
||||
| ETH | $3.2k | −0.8% |
|
||||
\`\`\`
|
||||
|
||||
**3. Short bullets** for digests and lists. One line per item, ≤80 chars when possible. Lead with the value, push metadata to the end.
|
||||
- News: \`- <headline> · <source> · <time>\`
|
||||
- Tasks: \`- [ ] <task> · <due>\`
|
||||
- HN: \`- <title> · 842 pts · 312 comments\`
|
||||
|
||||
**4. Status line + per-component bullets** when there's a top-level state plus details worth surfacing.
|
||||
\`\`\`
|
||||
⚠ db degraded
|
||||
- api: 240ms p95 (vs 80ms baseline)
|
||||
- db: connection pool saturated
|
||||
\`\`\`
|
||||
|
||||
**5. Rich block** (\`table\`, \`chart\`, \`calendar\`, \`email\`, \`mermaid\`, etc.) when the data has a natural visual form. Don't render a calendar or chart in plain markdown when the rich block exists.
|
||||
|
||||
## Hard "no" list
|
||||
|
||||
- **No prose paragraphs** for informational content. Even if the topic is something a magazine would write 200 words about, the note version is bullets or a table.
|
||||
- **No decorative adjectives**: "comprehensive", "balanced", "polished", "detailed", "high-quality", "carefully curated". They tell the reader nothing concrete.
|
||||
- **No framing prose**: skip "Here's the latest update on…", "Below is a summary of…", "I've gathered the following…", "Quick rundown:". Get to the data on the first line.
|
||||
- **No self-reference**: don't write "I updated this section at X" — the system records timestamps. Don't write "This note refreshes hourly" — the user already knows.
|
||||
- **No caveats unless the data is genuinely uncertain**: "Note: this is approximate", "As of last refresh", "Subject to change" are noise. If freshness matters, encode it inline: \`BTC: $67,432 (as of 14:05 IST)\`.
|
||||
- **No preamble** — no "Sure, here's…", "Got it, will do — here's the result." Just the result.
|
||||
- **No filler headers** — a note whose content is a single fact doesn't need a \`## Summary\` heading. Headings exist to break up content, not announce it.
|
||||
|
||||
## Bullet rules
|
||||
|
||||
- One line per bullet. No nesting beyond 2 levels — if you reach for a third level, it should be a new section or a table.
|
||||
- **Lead with the value.** "BTC at $67k" not "The current BTC price is approximately $67k".
|
||||
- Use \`·\` (middle dot) as a separator for related fields when stacking 2+ items inline. \`<headline> · <source> · <time>\` reads better than \`(<source>, <time>)\`.
|
||||
- Push metadata (time, source, status, score) to the **end** of the bullet, after a separator.
|
||||
|
||||
## Table rules
|
||||
|
||||
- Use a markdown table (or a \`table\` rich block) for ≥3 parallel items. For 1-2 items, use a single line or two bullets — a 2-row table is overhead with no benefit.
|
||||
- Aim for ≤4 columns. More and the reader can't scan it.
|
||||
- Right-align numeric columns when possible.
|
||||
- No "Notes" column full of prose; if a row needs annotation, footnote it below the table.
|
||||
|
||||
## Sources and links — make destinations clickable
|
||||
|
||||
Knowledge notes are entry points, not dead ends. **If the user might want to click through and read more, give them the link.** This applies to anything you pulled from outside the user's own data — news, papers, blog posts, GitHub issues, status pages, search results, social posts, dashboards.
|
||||
|
||||
**Required when you have a URL:**
|
||||
- Source attribution is non-negotiable for any item pulled from the web. Name the source (CNBC, Reuters, "GitHub", "company blog", "@<author> on X", etc.) **and** give a link to the canonical URL.
|
||||
- Research / reference bullets that summarize external content.
|
||||
- HN / front-page lists, paper digests, ranked items.
|
||||
|
||||
**Format:** make the **headline** the link — that's what the user reaches for first.
|
||||
|
||||
- Preferred: \`- [<headline>](<url>) · <source> · <when>\`
|
||||
- Acceptable: \`- <headline> · [<source>](<url>) · <when>\` when the headline isn't itself an article (e.g. a one-line insight you derived from the source).
|
||||
|
||||
If the bullet also carries a short description, the link still goes on the headline:
|
||||
\`- [<headline>](<url>) · <source> · <when> · <one-line description>\`
|
||||
|
||||
**Not required:**
|
||||
- Items pulled from the user's own data (calendar events, sent emails, meeting notes the user authored) — the natural reference (event id, sender name, meeting filename) is enough.
|
||||
- Pure point-in-time facts the user wouldn't drill into ("BTC: $67,432", "24°, Cloudy", "✓ All systems operational"). No link.
|
||||
|
||||
**Internal references:** use \`[[Note Name]]\` to link other knowledge-base notes. The editor renders these as clickable wiki-links — preferable to a flat path string.
|
||||
|
||||
**When you don't have a URL but it would be useful:** drop the link, keep the source name. Don't fabricate URLs. Don't write \`(link unavailable)\` — that's noise. If the source is a known publication, the source name alone is still informative.
|
||||
|
||||
## Genres cookbook
|
||||
|
||||
Common note types and the target shape for each:
|
||||
|
||||
- **Weather**: single line \`T°, Conditions · Wind · Precip\`. A 3-day micro-forecast as 3 lines if the user asks for it.
|
||||
- **News digest**: bulleted list. Source attribution + link **required** when you have a URL — see "Sources and links" above. Shape: \`- [<headline>](<url>) · <source> · <date>\` (optionally append \` · <one-line takeaway>\` when the headline alone isn't enough). Group by topic only when >10 items.
|
||||
- **Stock / crypto prices**: table with \`Symbol | Price | Δ24h | Δ7d\`. Add a \`chart\` block for time series only when the user asks for trends. No links — these are point-in-time facts.
|
||||
- **Service status**: a single status line; per-component bullets *only* when something is degraded. Link the status page when surfacing the top-level status (\`[✓ All systems operational](<status_url>)\`).
|
||||
- **Calendar / agenda**: \`calendar\` rich block. Never plain markdown.
|
||||
- **Email digest**: \`emails\` rich block (multi-thread) or \`email\` block (single thread). Plain markdown only for one-line summaries when there are >20 threads.
|
||||
- **HN / front-page lists**: bullets — \`- [<title>](<url>) · <points> pts · <comments> comments\`. Title is always the link.
|
||||
- **Tasks / priorities**: ranked bullets with priority tag — \`- [P0] <task> · <due>\`. \`[[wiki-link]]\` to a source note when one exists (e.g. the task came from a meeting note).
|
||||
- **Research notes / search results**: bullets with **link**, source, 1-line gist — \`- [<title>](<url>) · <source> · <gist>\`. Link is required when you found this via search. Don't synthesize into prose.
|
||||
- **GitHub / issue digests**: \`- [<title>](<issue_url>) · <repo> · <state> · <updated>\`.
|
||||
- **Tweets / social digests**: \`- [<truncated text or topic>](<post_url>) · @<author> · <when>\`.
|
||||
|
||||
## When prose IS appropriate
|
||||
|
||||
- A **1-3 sentence opening summary** at the top of a complex note (a "lede") — concise enough to scan.
|
||||
- A section the user explicitly authored as narrative (a journal entry, meeting reflection, qualitative analysis).
|
||||
- The **user's own writing** — never restructure it into bullets unless they ask.
|
||||
|
||||
For everything else: bullets, tables, single lines.
|
||||
|
||||
## A worked example
|
||||
|
||||
**Bad** — wall of prose, decorative adjectives, framing, caveats:
|
||||
> Here's a comprehensive update on today's most important news from India and around the world. The geopolitical landscape continues to evolve rapidly, with several significant developments worth highlighting. In India, the markets had a notable session today, with the Sensex closing higher on positive sentiment around the upcoming budget. Meanwhile, in global news, there have been important shifts in technology and finance.
|
||||
|
||||
**Good** — bullets, lead with value, metadata at the end, no framing, **headline is a link to the source article**:
|
||||
> ## India
|
||||
> - [Sensex closes +0.6% at 73,420](https://www.livemint.com/...) · Mint · 4 PM
|
||||
> - [Budget speech draft sets fiscal-deficit target at 4.5%](https://www.reuters.com/...) · Reuters · 2 PM
|
||||
> - [Cabinet clears semiconductor mission Phase 2](https://economictimes.indiatimes.com/...) · ET · 11 AM
|
||||
>
|
||||
> ## World
|
||||
> - [OpenAI launches GPT-5 mini for free tier](https://techcrunch.com/...) · TechCrunch · 9 AM PT
|
||||
> - [Fed minutes signal one more cut this year](https://www.bloomberg.com/...) · Bloomberg · 2 PM ET
|
||||
> - [EU passes AI Act amendment on training data](https://www.politico.eu/...) · Politico · 3 PM CET
|
||||
|
||||
Same information, ~80% fewer words, scannable in 5 seconds.
|
||||
`;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackSchema } from '@x/shared/dist/track.js';
|
||||
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { splitFrontmatter } from '../application/lib/parse-frontmatter.js';
|
||||
import z from 'zod';
|
||||
|
|
@ -9,108 +9,47 @@ import z from 'zod';
|
|||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
||||
|
||||
// Bump this whenever the canonical Today.md template changes (TRACKS list,
|
||||
// instructions, default body, etc.). On app start, ensureDailyNote() compares
|
||||
// the on-disk `templateVersion` against this constant — if older or missing,
|
||||
// the existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with
|
||||
// the new template, preserving the body byte-for-byte.
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 1;
|
||||
// Bump this whenever the canonical Today.md template changes (objective,
|
||||
// triggers, default body, etc.). On app start, ensureDailyNote() compares the
|
||||
// on-disk `templateVersion` against this constant — if older or missing, the
|
||||
// existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with the
|
||||
// new template. v2 is the live-note rewrite (single objective, no `track:`).
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 2;
|
||||
|
||||
// Window triggers below fire once per day, anywhere inside their time-of-day
|
||||
// band — so the user opening the app late in the morning still gets the
|
||||
// morning run. See schedule-utils.ts for the exact semantics.
|
||||
const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
|
||||
objective:
|
||||
`Keep Today.md current as a living dashboard for the day. Maintain these H2 sections in this order:
|
||||
|
||||
const TRACKS: z.infer<typeof TrackSchema>[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
instruction:
|
||||
`In a section titled "Overview" at the top of the note: 2–3 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from calendar_sync/ and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Skip the update if the prior content is still suitable and less than 24h old. VERY IMPORTANT: Ensure that image is wide / low-height!`,
|
||||
active: true,
|
||||
triggers: [
|
||||
// Three windows give the user a fresh ranking morning, midday, and
|
||||
// post-lunch even with no events landing in between.
|
||||
{ type: 'window', startTime: '08:00', endTime: '12:00' },
|
||||
{ type: 'window', startTime: '12:00', endTime: '15:00' },
|
||||
{ type: 'window', startTime: '15:00', endTime: '18:00' },
|
||||
1. **Overview** — 2-3 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from \`calendar_sync/\` and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Keep the image **wide / low-height**. Skip refreshing this section if its content is still suitable and less than 24h old.
|
||||
|
||||
2. **Calendar** — today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\` → \`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`.
|
||||
|
||||
3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
|
||||
|
||||
4. **What you missed** — a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings/<source>/<yesterday>/\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."
|
||||
|
||||
5. **Priorities** — a ranked markdown list of actionable items the user should focus on today. Sources: yesterday's meeting action items (\`knowledge/Meetings/<source>/<yesterday>/\`), open follow-ups across \`knowledge/\` (\`workspace-grep\` for "- [ ]"), the **What you missed** section above. Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline. With an event payload (gmail or calendar), only re-emit the full list if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."
|
||||
|
||||
Treat the note as a coherent artifact. Make small, incremental edits — one section at a time — rather than rewriting the whole body each run.`,
|
||||
active: true,
|
||||
triggers: {
|
||||
// Three windows give the user a fresh dashboard morning, midday, and
|
||||
// post-lunch even with no calendar/email events landing in between.
|
||||
windows: [
|
||||
{ startTime: '08:00', endTime: '12:00' },
|
||||
{ startTime: '12:00', endTime: '15:00' },
|
||||
{ startTime: '15:00', endTime: '18:00' },
|
||||
],
|
||||
// Event-driven updates handle in-day shifts (new email threads worth
|
||||
// attention, calendar reshuffles, urgent escalations).
|
||||
eventMatchCriteria:
|
||||
`Email or calendar events that may shift today's dashboard: new or updated email threads needing the user's attention, urgent reply requests, deadline-bearing items, escalations from people the user cares about, calendar additions/cancellations/reschedules affecting today, or anything that changes the user's near-term priorities. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
instruction:
|
||||
`In a section titled "Calendar", emit today's meetings as a \`calendar\` block titled "Today's Meetings". Read calendar_sync/ via workspace-readdir → workspace-readFile each .json. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a conferenceLink.`,
|
||||
active: true,
|
||||
triggers: [{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'emails',
|
||||
instruction:
|
||||
`In a section titled "Emails", maintain a digest of email threads worth attention today. Output everything as a **single** fenced code block with language \`emails\` (plural — never individual \`email\` blocks per thread). The body must be JSON shaped \`{"title":"Today's Emails","emails":[...]}\`.
|
||||
|
||||
Each entry in the array: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads that need a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`.
|
||||
|
||||
Skip marketing, auto-notifications, and closed threads. Without an event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/), prioritizing threads with frontmatter action = "reply" or "respond". With an event payload, integrate any qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if the threadId is already shown). Do not re-list threads the user has already seen unless their state changed.
|
||||
|
||||
If nothing qualifies: "No new emails."`,
|
||||
active: true,
|
||||
triggers: [{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'what-you-missed',
|
||||
instruction:
|
||||
`In a section titled "What you missed", write a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from knowledge/Meetings/<source>/<yesterday>/ (workspace-readdir recursive on knowledge/Meetings, filter folders matching yesterday's date, read each file). Skim gmail_sync/ for threads that went unresolved. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."`,
|
||||
active: true,
|
||||
triggers: [
|
||||
// Three windows give the user a fresh ranking morning, midday, and
|
||||
// post-lunch even with no events landing in between.
|
||||
{ type: 'window', startTime: '08:00', endTime: '12:00' },
|
||||
{ type: 'window', startTime: '12:00', endTime: '15:00' },
|
||||
{ type: 'window', startTime: '15:00', endTime: '18:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'priorities',
|
||||
instruction:
|
||||
`In a section titled "Priorities", a ranked markdown list of actionable items the user should focus on today.
|
||||
|
||||
Sources: yesterday's meeting action items (knowledge/Meetings/<source>/<yesterday>/), open follow-ups across knowledge/ (workspace-grep for "- [ ]"), the "What you missed" section.
|
||||
|
||||
Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline.
|
||||
|
||||
With an event payload (gmail or calendar): re-emit the full list only if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). Otherwise skip the update.
|
||||
|
||||
If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."`,
|
||||
active: true,
|
||||
triggers: [
|
||||
// Three windows give the user a fresh ranking morning, midday, and
|
||||
// post-lunch even with no events landing in between.
|
||||
{ type: 'window', startTime: '08:00', endTime: '12:00' },
|
||||
{ type: 'window', startTime: '12:00', endTime: '15:00' },
|
||||
{ type: 'window', startTime: '15:00', endTime: '18:00' },
|
||||
{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`New or updated email threads that may shift today's priorities — urgent reply requests, deadline-bearing items, escalations from people the user cares about.`,
|
||||
},
|
||||
{
|
||||
type: 'event',
|
||||
matchCriteria:
|
||||
`Calendar changes today that may shift priorities — a meeting moved to clash with a deadline, an unexpected event added, a key meeting cancelled freeing up time.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function buildDailyNoteContent(body: string = '# Today\n'): string {
|
||||
const fm = stringifyYaml(
|
||||
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, track: TRACKS },
|
||||
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, live: TODAY_LIVE_NOTE },
|
||||
{ lineWidth: 0, blockQuote: 'literal' },
|
||||
).trimEnd();
|
||||
return `---\n${fm}\n---\n${body}`;
|
||||
|
|
@ -138,11 +77,11 @@ export function ensureDailyNote(): void {
|
|||
|
||||
// Migrate aggressively: rename existing → backup, write a fresh canonical
|
||||
// template (no body carried over). Today.md is a flagship demo whose
|
||||
// content is meant to be regenerated by the tracks anyway — preserving the
|
||||
// old body just leaves orphan sections behind on rename/restructure. The
|
||||
// .bkp file is the recovery path; its name doesn't end in `.md`, so the
|
||||
// scheduler and event router naturally skip it. Pre-rewrite inline-fence
|
||||
// notes are caught by this same path.
|
||||
// content is meant to be regenerated by the live-note agent anyway —
|
||||
// preserving the old body just leaves orphan sections behind on
|
||||
// restructure. The .bkp file is the recovery path; its name doesn't end
|
||||
// in `.md`, so the scheduler and event router naturally skip it. Pre-v2
|
||||
// notes (with the old `track:` array) are caught by this same path.
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`;
|
||||
fs.renameSync(DAILY_NOTE_PATH, backupPath);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import z from 'zod';
|
||||
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
|
||||
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
|
||||
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../application/lib/knowledge-note-style.js';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
|
||||
const TRACK_RUN_INSTRUCTIONS = `You are a track runner — a background agent that keeps a live note in the user's personal knowledge base up to date.
|
||||
export const LIVE_NOTE_AGENT_INSTRUCTIONS = `You are the live-note agent — a background agent that keeps a *live note* in the user's personal knowledge base current with its objective.
|
||||
|
||||
Your goal on each run: update the body of the note so that, given the track's instruction, the content is the most useful and up-to-date version it can be. The user is maintaining a personal knowledge base and will scan this note alongside many others — optimize for **information density and scannability**, not conversational prose.
|
||||
Your goal on each run: bring the body of the note in line with the user's persistent **objective** for that note. The user is maintaining a personal knowledge base and will scan this note alongside many others — optimize for **information density and scannability**, not conversational prose.
|
||||
|
||||
# Background Mode
|
||||
|
||||
You are running as a scheduled or event-triggered background task — **there is no user present** to clarify, approve, or watch.
|
||||
- Do NOT ask clarifying questions — make the most reasonable interpretation of the instruction and proceed.
|
||||
- Do NOT ask clarifying questions — make the most reasonable interpretation of the objective and proceed.
|
||||
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
|
||||
- Do NOT produce chat-style output. The user sees only the changes you make to the note plus your final summary line.
|
||||
|
||||
|
|
@ -18,75 +19,72 @@ You are running as a scheduled or event-triggered background task — **there is
|
|||
|
||||
Every run message has this shape:
|
||||
|
||||
Update track **<id>** in \`<filePath>\`.
|
||||
Update the live note at \`<filePath>\`.
|
||||
|
||||
**Time:** <localized datetime> (<timezone>)
|
||||
|
||||
**Instruction:**
|
||||
<the user-authored track instruction — usually 1-3 sentences describing what to produce>
|
||||
**Objective:**
|
||||
<the user-authored objective — usually 1-3 sentences describing what the note should keep being>
|
||||
|
||||
Start by calling \`workspace-readFile\` on \`<filePath>\` to read the current note (frontmatter + body). Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file.
|
||||
Start by calling \`workspace-readFile\` on \`<filePath>\` ... patch-style edits ...
|
||||
|
||||
For **manual** runs, an optional trailing block may appear:
|
||||
|
||||
**Context:**
|
||||
<extra one-run-only guidance — a backfill hint, a focus window, extra data>
|
||||
|
||||
Apply context for this run only — it is not a permanent edit to the instruction.
|
||||
Apply context for this run only — it is not a permanent edit to the objective.
|
||||
|
||||
For **event-triggered** runs, a trailing block appears instead:
|
||||
|
||||
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
|
||||
**Event match criteria for this track:** <from the track's frontmatter>
|
||||
**Trigger:** Event match (Pass 1 routing flagged this note)
|
||||
**Event match criteria for this note:** <from the note's frontmatter>
|
||||
**Event payload:** <the event body — e.g., an email>
|
||||
**Decision:** ... skip if not relevant ...
|
||||
|
||||
On event runs you are the Pass 2 judge — see "The No-Update Decision" below.
|
||||
|
||||
# Editing the Note
|
||||
# Editing the Note (patch-style)
|
||||
|
||||
You have full read/write access to the note body via the standard workspace tools:
|
||||
- \`workspace-readFile\` — read the current state of the note (frontmatter included; you can ignore the frontmatter).
|
||||
- \`workspace-edit\` — apply patches.
|
||||
- \`workspace-writeFile\` — replace the entire file (use sparingly; prefer \`workspace-edit\`).
|
||||
You own the **entire body below the H1** — you may freely add, edit, reorganize, dedupe, and trim its content to satisfy the objective. The frontmatter (the \`---\`-delimited block at the top) is owned by the user and the runtime — **never modify it**.
|
||||
|
||||
**Do NOT modify the YAML frontmatter at the top of the file** (the \`---\`-delimited block). It contains the track configuration and runtime state owned by the user and the runtime. Editing it can corrupt the track's schedule, history, or the note's metadata.
|
||||
**Make incremental, patch-style edits — not one-shot rewrites.**
|
||||
|
||||
# Section Placement
|
||||
The right pattern on every run:
|
||||
1. \`workspace-readFile\` to fetch the current note.
|
||||
2. Decide on the *first* change you need to make (add a section, replace a stale figure, dedupe entries, fix an out-of-date paragraph).
|
||||
3. \`workspace-edit\` to make that one change.
|
||||
4. \`workspace-readFile\` again to confirm the result.
|
||||
5. Decide the *next* change. Repeat.
|
||||
|
||||
Each track's instruction may name a **section** in the note where its content lives — e.g. *"in a section titled 'Overview' at the top"* or *"in a section titled 'Photo' right after Overview"*. You own that section and only that section.
|
||||
Why patch-style:
|
||||
- It preserves user-added content you didn't account for. The user may have written prose between your sections; whole-body rewrites destroy it.
|
||||
- It makes diffs reviewable — the user can scan a few small changes far more easily than a wall-of-replacement.
|
||||
- It lets you abort partway if a tool call fails, leaving the note in a consistent partial state instead of a clobbered one.
|
||||
|
||||
How to handle sections:
|
||||
Avoid:
|
||||
- Calling \`workspace-writeFile\` to replace the entire body. That's the no-go path.
|
||||
- Building up the entire new body in your head and emitting it in a single \`workspace-edit\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps.
|
||||
|
||||
- Sections are H2 headings (\`## Section Name\`). Match by exact heading text.
|
||||
- **If the named section exists**: replace its content (everything between that heading and the next H2 — or end of file) with your new output. Heading itself stays intact.
|
||||
- **If the section is missing**: create it. Use the placement hint to decide where:
|
||||
- "at the top" → just below the H1 title (or first line if there's none).
|
||||
- "after X" → immediately after section X. If X doesn't exist either, fall back to natural reading order.
|
||||
- no hint → append to the end of the body.
|
||||
- **Never modify another track's section content.** Other agents own those.
|
||||
- **Never duplicate a section.** If two H2 headings match yours, consolidate into the first.
|
||||
- The user may rename your section's heading. If you can't find it by exact name on a later run, recreate it per the placement hint.
|
||||
# Body Structure (defaults)
|
||||
|
||||
After writing your section, **re-check its position**. The first time tracks run on a fresh note, sections land in firing order rather than reading order, so the file ends up out of sequence. If your section is now in the wrong place relative to your placement hint (e.g. your "Photo" section is meant to sit right after "Overview" but ended up at the bottom), **move your own section block** (your H2 heading + its content, no surrounding blank lines lost) to the correct position. Cut-and-paste only — never rewrite or reorder *other* tracks' sections; they will self-correct on their own next runs.
|
||||
Unless the objective explicitly specifies a different structure, follow this default shape:
|
||||
|
||||
A section can hold prose, lists, or rich blocks (calendar/email/image/etc.) per the instruction. You always write a **complete** replacement for the section you own — not a diff.
|
||||
- **H1** stays the note title (the first \`# ...\` line). Don't touch it.
|
||||
- **Top:** a short rolling summary (1-3 sentences) capturing the current state of whatever the note is tracking. Update or replace this on each run.
|
||||
- **Below:** content organized by sub-topic under H2 headings (\`## ...\`), with the freshest / most-important sections first.
|
||||
- **Tightness over decoration.** Tables, bullets, one-line statuses. Not paragraphs. No "Here's your update" prose.
|
||||
- **Dedupe** as you go — if you're adding a new item that's already present in another section, consolidate rather than duplicate.
|
||||
|
||||
# What Good Output Looks Like
|
||||
If the objective says something specific about layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly and ignore the defaults.
|
||||
|
||||
This is a personal knowledge tracker. The user scans many such notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
|
||||
${KNOWLEDGE_NOTE_STYLE_GUIDE}
|
||||
|
||||
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
|
||||
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape.
|
||||
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
|
||||
- **No commentary or caveats** unless the data itself is genuinely uncertain.
|
||||
- **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately.
|
||||
|
||||
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events → \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
|
||||
The style guide above is the canonical writing style for everything you emit into the body. The objective may specify a particular shape ("3-column markdown table: Location | Local Time | Offset") — when it does, follow it exactly. When it doesn't, walk the ladder above and pick the tightest shape that fits the data.
|
||||
|
||||
# Output Block Types
|
||||
|
||||
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
|
||||
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the objective asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
|
||||
|
||||
Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
|
||||
|
||||
|
|
@ -243,15 +241,15 @@ instruction: |
|
|||
|
||||
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
|
||||
|
||||
# Interpreting the Instruction
|
||||
# Interpreting the Objective
|
||||
|
||||
The instruction was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
|
||||
The objective was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
|
||||
- "Top 5" is a target — fewer is acceptable if that's all that exists.
|
||||
- "Current" means as of now (use the **Time** block).
|
||||
- Unspecified units → standard for the domain (USD for US markets, metric for scientific, the user's locale if inferable from the timezone).
|
||||
- Unspecified sources → your best reliable source (web-search for public data, workspace for user data).
|
||||
|
||||
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") — these are decoration.
|
||||
Do **not** invent parts of the objective the user did not write ("also include a fun fact", "summarize trends") — these are decoration.
|
||||
|
||||
# The No-Update Decision
|
||||
|
||||
|
|
@ -266,11 +264,11 @@ When skipping, still end with a summary line (see "Final Summary" below) so the
|
|||
|
||||
You have the full workspace toolkit. Quick reference for common cases:
|
||||
|
||||
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off.
|
||||
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
|
||||
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off. Prefer many small \`workspace-edit\` calls over one giant \`workspace-writeFile\`.
|
||||
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the objective needs information beyond the workspace.
|
||||
- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data.
|
||||
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
|
||||
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
|
||||
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if the objective references attached files.
|
||||
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when the objective needs structured data from a connected service the user has authorized.
|
||||
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
|
||||
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes.
|
||||
|
||||
|
|
@ -282,7 +280,7 @@ The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organ
|
|||
- **Projects/** — initiatives
|
||||
- **Topics/** — recurring themes
|
||||
|
||||
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when an instruction references emails, meetings, or calendar events.
|
||||
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when the objective references emails, meetings, or calendar events.
|
||||
|
||||
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
|
||||
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
|
||||
|
|
@ -291,7 +289,7 @@ Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync
|
|||
|
||||
# Failure & Fallback
|
||||
|
||||
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
|
||||
If you cannot complete the objective (network failure, missing data source, unparseable response, disconnected integration):
|
||||
- Do **not** fabricate or speculate.
|
||||
- Do **not** write partial or placeholder content — leave the existing body intact by skipping the edit.
|
||||
- Explain the failure in the summary line.
|
||||
|
|
@ -307,10 +305,10 @@ State the action and the substance. Good examples:
|
|||
- "Skipped — event was a calendar invite unrelated to Q3 planning."
|
||||
- "Failed — web-search returned no results for the query."
|
||||
|
||||
Avoid: "I updated the track.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
|
||||
Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
|
||||
`;
|
||||
|
||||
export function buildTrackRunAgent(): z.infer<typeof Agent> {
|
||||
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
if (name === 'executeCommand') continue;
|
||||
|
|
@ -318,9 +316,9 @@ export function buildTrackRunAgent(): z.infer<typeof Agent> {
|
|||
}
|
||||
|
||||
return {
|
||||
name: 'track-run',
|
||||
description: 'Background agent that keeps a track-driven note up to date',
|
||||
instructions: TRACK_RUN_INSTRUCTIONS,
|
||||
name: 'live-note-agent',
|
||||
description: 'Background agent that keeps a live note up to date with its objective',
|
||||
instructions: LIVE_NOTE_AGENT_INSTRUCTIONS,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import type { TrackEventType } from '@x/shared/dist/track.js';
|
||||
import type { LiveNoteAgentEventType } from '@x/shared/dist/live-note.js';
|
||||
|
||||
type Handler = (event: TrackEventType) => void;
|
||||
type Handler = (event: LiveNoteAgentEventType) => void;
|
||||
|
||||
class TrackBus {
|
||||
class LiveNoteBus {
|
||||
private subs: Handler[] = [];
|
||||
|
||||
publish(event: TrackEventType): void {
|
||||
publish(event: LiveNoteAgentEventType): void {
|
||||
for (const handler of this.subs) {
|
||||
handler(event);
|
||||
}
|
||||
|
|
@ -20,4 +20,4 @@ class TrackBus {
|
|||
}
|
||||
}
|
||||
|
||||
export const trackBus = new TrackBus();
|
||||
export const liveNoteBus = new LiveNoteBus();
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PrefixLogger, track } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
|
||||
import { PrefixLogger, liveNote } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/live-note.js';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import * as workspace from '../../workspace/workspace.js';
|
||||
import { fetchAll } from './fileops.js';
|
||||
import { triggerTrackUpdate } from './runner.js';
|
||||
import { findCandidates, type ParsedTrack } from './routing.js';
|
||||
import { fetchLiveNote } from './fileops.js';
|
||||
import { runLiveNoteAgent } from './runner.js';
|
||||
import { findCandidates, type ParsedLiveNote } from './routing.js';
|
||||
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
|
||||
import container from '../../di/container.js';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ const EVENTS_DIR = path.join(WorkDir, 'events');
|
|||
const PENDING_DIR = path.join(EVENTS_DIR, 'pending');
|
||||
const DONE_DIR = path.join(EVENTS_DIR, 'done');
|
||||
|
||||
const log = new PrefixLogger('EventProcessor');
|
||||
const log = new PrefixLogger('LiveNote:Events');
|
||||
|
||||
/**
|
||||
* Write a KnowledgeEvent to the events/pending/ directory.
|
||||
|
|
@ -39,43 +39,38 @@ function ensureDirs(): void {
|
|||
fs.mkdirSync(DONE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function listAllTracks(): Promise<ParsedTrack[]> {
|
||||
const tracks: ParsedTrack[] = [];
|
||||
async function listEventEligibleLiveNotes(): Promise<ParsedLiveNote[]> {
|
||||
const out: ParsedLiveNote[] = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await workspace.readdir('knowledge', { recursive: true });
|
||||
} catch {
|
||||
return tracks;
|
||||
return out;
|
||||
}
|
||||
const mdFiles = entries
|
||||
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
|
||||
.map(e => e.path.replace(/^knowledge\//, ''));
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
let parsedTracks;
|
||||
let live;
|
||||
try {
|
||||
parsedTracks = await fetchAll(filePath);
|
||||
live = await fetchLiveNote(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const t of parsedTracks) {
|
||||
const eventCriteria = (t.track.triggers ?? [])
|
||||
.filter(trig => trig.type === 'event')
|
||||
.map(trig => trig.matchCriteria)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
// Skip tracks with no event triggers — they're not event-eligible.
|
||||
if (!eventCriteria) continue;
|
||||
tracks.push({
|
||||
trackId: t.track.id,
|
||||
filePath,
|
||||
eventMatchCriteria: eventCriteria,
|
||||
instruction: t.track.instruction,
|
||||
active: t.track.active,
|
||||
});
|
||||
}
|
||||
if (!live) continue;
|
||||
if (live.active === false) continue;
|
||||
|
||||
const eventMatchCriteria = live.triggers?.eventMatchCriteria;
|
||||
if (!eventMatchCriteria) continue; // not event-eligible
|
||||
|
||||
out.push({
|
||||
filePath,
|
||||
objective: live.objective,
|
||||
eventMatchCriteria,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
return out;
|
||||
}
|
||||
|
||||
function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
|
||||
|
|
@ -85,7 +80,7 @@ function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
|
|||
try {
|
||||
fs.unlinkSync(pendingPath);
|
||||
} catch (err) {
|
||||
log.log(`Failed to remove pending event ${filename}:`, err);
|
||||
log.log(`failed to remove pending event ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,10 +91,10 @@ async function processOneEvent(filename: string): Promise<void> {
|
|||
try {
|
||||
const raw = fs.readFileSync(pendingPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
event = track.KnowledgeEventSchema.parse(parsed);
|
||||
event = liveNote.KnowledgeEventSchema.parse(parsed);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.log(`Malformed event ${filename}, moving to done with error:`, msg);
|
||||
log.log(`event:${filename} — malformed, moving to done with error: ${msg}`);
|
||||
const stub: KnowledgeEvent = {
|
||||
id: filename.replace(/\.json$/, ''),
|
||||
source: 'unknown',
|
||||
|
|
@ -113,36 +108,48 @@ async function processOneEvent(filename: string): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
log.log(`Processing event ${event.id} (source=${event.source}, type=${event.type})`);
|
||||
log.log(`event:${event.id} — received source=${event.source} type=${event.type}`);
|
||||
|
||||
const allTracks = await listAllTracks();
|
||||
const candidates = await findCandidates(event, allTracks);
|
||||
const eligible = await listEventEligibleLiveNotes();
|
||||
const candidates = await findCandidates(event, eligible);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
log.log(`event:${event.id} — no candidates (${eligible.length} eligible note${eligible.length === 1 ? '' : 's'})`);
|
||||
} else {
|
||||
log.log(`event:${event.id} — dispatching to ${candidates.length} candidate${candidates.length === 1 ? '' : 's'}: ${candidates.map(c => c.filePath).join(', ')}`);
|
||||
}
|
||||
|
||||
const runIds: string[] = [];
|
||||
let processingError: string | undefined;
|
||||
let okCount = 0;
|
||||
let errCount = 0;
|
||||
|
||||
// Sequential — preserves total ordering
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await triggerTrackUpdate(
|
||||
candidate.trackId,
|
||||
candidate.filePath,
|
||||
event.payload,
|
||||
'event',
|
||||
);
|
||||
const result = await runLiveNoteAgent(candidate.filePath, 'event', event.payload);
|
||||
if (result.runId) runIds.push(result.runId);
|
||||
log.log(`Candidate ${candidate.trackId}: ${result.action}${result.error ? ` (${result.error})` : ''}`);
|
||||
if (result.error) {
|
||||
errCount++;
|
||||
} else {
|
||||
okCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
errCount++;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.log(`Error triggering candidate ${candidate.trackId}:`, msg);
|
||||
processingError = (processingError ? processingError + '; ' : '') + `${candidate.trackId}: ${msg}`;
|
||||
log.log(`event:${event.id} — candidate ${candidate.filePath} threw: ${msg}`);
|
||||
processingError = (processingError ? processingError + '; ' : '') + `${candidate.filePath}: ${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
log.log(`event:${event.id} — processed ok=${okCount} errors=${errCount}`);
|
||||
}
|
||||
|
||||
const enriched: KnowledgeEvent = {
|
||||
...event,
|
||||
processedAt: new Date().toISOString(),
|
||||
candidates: candidates.map(c => ({ trackId: c.trackId, filePath: c.filePath })),
|
||||
candidateFilePaths: candidates.map(c => c.filePath),
|
||||
runIds,
|
||||
...(processingError ? { error: processingError } : {}),
|
||||
};
|
||||
|
|
@ -157,7 +164,7 @@ async function processPendingEvents(): Promise<void> {
|
|||
try {
|
||||
filenames = fs.readdirSync(PENDING_DIR).filter(f => f.endsWith('.json'));
|
||||
} catch (err) {
|
||||
log.log('Failed to read pending dir:', err);
|
||||
log.log(`failed to read pending dir: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -166,23 +173,24 @@ async function processPendingEvents(): Promise<void> {
|
|||
// FIFO: monotonic IDs are lexicographically sortable
|
||||
filenames.sort();
|
||||
|
||||
log.log(`Processing ${filenames.length} pending event(s)`);
|
||||
if (filenames.length > 1) {
|
||||
log.log(`tick — ${filenames.length} pending events`);
|
||||
}
|
||||
|
||||
for (const filename of filenames) {
|
||||
try {
|
||||
await processOneEvent(filename);
|
||||
} catch (err) {
|
||||
log.log(`Unhandled error processing ${filename}:`, err);
|
||||
log.log(`event:${filename} — unhandled error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Keep the loop alive — don't move file, will retry on next tick
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
|
||||
log.log(`starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
|
||||
ensureDirs();
|
||||
|
||||
// Initial run
|
||||
await processPendingEvents();
|
||||
|
||||
while (true) {
|
||||
|
|
@ -190,7 +198,7 @@ export async function init(): Promise<void> {
|
|||
try {
|
||||
await processPendingEvents();
|
||||
} catch (err) {
|
||||
log.log('Error in main loop:', err);
|
||||
log.log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
202
apps/x/packages/core/src/knowledge/live-note/fileops.ts
Normal file
202
apps/x/packages/core/src/knowledge/live-note/fileops.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { LiveNoteSchema, type LiveNote } from '@x/shared/dist/live-note.js';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { withFileLock } from '../file-lock.js';
|
||||
import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
function absPath(filePath: string): string {
|
||||
return path.join(KNOWLEDGE_DIR, filePath);
|
||||
}
|
||||
|
||||
function getLiveBlock(fm: Record<string, unknown>): unknown {
|
||||
return fm.live ?? null;
|
||||
}
|
||||
|
||||
function setLiveBlock(fm: Record<string, unknown>, live: unknown): Record<string, unknown> {
|
||||
const next = { ...fm };
|
||||
if (live === null || live === undefined) {
|
||||
delete next.live;
|
||||
} else {
|
||||
next.live = live;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchLiveNote(filePath: string): Promise<LiveNote | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
const raw = getLiveBlock(frontmatter);
|
||||
if (!raw) return null;
|
||||
const parsed = LiveNoteSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export async function readNoteBody(filePath: string): Promise<string> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
return splitFrontmatter(content).body;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace (or create) the entire `live:` block. The renderer's structured
|
||||
* editor calls this with the complete object; runtime patches go through
|
||||
* {@link patchLiveNote}.
|
||||
*/
|
||||
export async function setLiveNote(filePath: string, live: LiveNote): Promise<void> {
|
||||
const validated = LiveNoteSchema.parse(live);
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const nextFm = setLiveBlock(frontmatter, validated);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a partial update into the `live:` block. Used by the runner to
|
||||
* write `lastRunAt` / `lastRunId` / `lastRunSummary` without round-tripping
|
||||
* the rest of the user-authored config through schema validation.
|
||||
*/
|
||||
export async function patchLiveNote(
|
||||
filePath: string,
|
||||
updates: Partial<LiveNote>,
|
||||
): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const existing = getLiveBlock(frontmatter);
|
||||
if (!existing || typeof existing !== 'object') {
|
||||
throw new Error(`No live: block in ${filePath}`);
|
||||
}
|
||||
const merged = { ...(existing as Record<string, unknown>), ...updates };
|
||||
const nextFm = setLiveBlock(frontmatter, merged);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLiveNote(filePath: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
if (!getLiveBlock(frontmatter)) return; // already passive
|
||||
const nextFm = setLiveBlock(frontmatter, null);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function setLiveNoteActive(
|
||||
filePath: string,
|
||||
active: boolean,
|
||||
): Promise<LiveNoteSummary | null> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const existing = getLiveBlock(frontmatter);
|
||||
if (!existing || typeof existing !== 'object') return null;
|
||||
|
||||
const current = existing as Record<string, unknown>;
|
||||
const currentlyActive = current.active !== false;
|
||||
if (currentlyActive !== active) {
|
||||
const merged = { ...current, active };
|
||||
const nextFm = setLiveBlock(frontmatter, merged);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
}
|
||||
|
||||
const validated = await fetchLiveNote(filePath);
|
||||
return validated ? buildSummary(filePath, validated) : null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note-level summaries (background-agents view)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LiveNoteSummary = {
|
||||
path: string;
|
||||
createdAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
isActive: boolean;
|
||||
objective: string;
|
||||
};
|
||||
|
||||
function buildSummaryFromStat(filePath: string, live: LiveNote, createdMs: number): LiveNoteSummary {
|
||||
return {
|
||||
path: `knowledge/${filePath}`,
|
||||
createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null,
|
||||
lastRunAt: live.lastRunAt ?? null,
|
||||
isActive: live.active !== false,
|
||||
objective: live.objective,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildSummary(filePath: string, live: LiveNote): Promise<LiveNoteSummary> {
|
||||
const stats = await fs.stat(absPath(filePath));
|
||||
const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs;
|
||||
return buildSummaryFromStat(filePath, live, createdMs);
|
||||
}
|
||||
|
||||
export async function listLiveNotes(): Promise<LiveNoteSummary[]> {
|
||||
async function walk(relativeDir = ''): Promise<string[]> {
|
||||
const dirPath = absPath(relativeDir);
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const childRelPath = relativeDir
|
||||
? path.posix.join(relativeDir, entry.name)
|
||||
: entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await walk(childRelPath));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
files.push(childRelPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const markdownFiles = await walk();
|
||||
const summaries = await Promise.all(markdownFiles.map(async (relativePath) => {
|
||||
try {
|
||||
const live = await fetchLiveNote(relativePath);
|
||||
if (!live) return null;
|
||||
return await buildSummary(relativePath, live);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
return summaries
|
||||
.filter((note): note is LiveNoteSummary => note !== null)
|
||||
.sort((a, b) => {
|
||||
const aName = path.basename(a.path, '.md').toLowerCase();
|
||||
const bName = path.basename(b.path, '.md').toLowerCase();
|
||||
if (aName !== bName) return aName.localeCompare(bName);
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
}
|
||||
111
apps/x/packages/core/src/knowledge/live-note/routing.ts
Normal file
111
apps/x/packages/core/src/knowledge/live-note/routing.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { generateObject } from 'ai';
|
||||
import { liveNote, PrefixLogger } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/live-note.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { getDefaultModelAndProvider, getLiveNoteAgentModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { captureLlmUsage } from '../../analytics/usage.js';
|
||||
|
||||
const log = new PrefixLogger('LiveNote:Routing');
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
export interface ParsedLiveNote {
|
||||
filePath: string;
|
||||
objective: string;
|
||||
eventMatchCriteria: string;
|
||||
}
|
||||
|
||||
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a personal knowledge base.
|
||||
|
||||
You will receive an event (something that happened — an email, meeting, message, etc.) and a list of *live notes*. Each live note has:
|
||||
- filePath: the path of the note file
|
||||
- objective: the persistent intent of the note (what it should keep being / containing)
|
||||
- matchCriteria: an explicit description of which kinds of incoming signals should wake this note
|
||||
|
||||
Your job is to identify which live notes MIGHT be relevant to this event.
|
||||
|
||||
Rules:
|
||||
- Be LIBERAL in your selections. Include any note that is even moderately relevant.
|
||||
- Prefer false positives over false negatives — it is much better to include a note that turns out to be irrelevant than to miss one that was relevant.
|
||||
- Only exclude notes that are CLEARLY and OBVIOUSLY irrelevant to the event.
|
||||
- Do not attempt to judge whether the event contains enough information to act on. That is handled by the live-note agent in a later stage.
|
||||
- Return an empty list only if no notes are relevant at all.
|
||||
- Return each candidate's filePath exactly as given.`;
|
||||
|
||||
async function resolveModel() {
|
||||
const modelId = await getLiveNoteAgentModel();
|
||||
const { provider } = await getDefaultModelAndProvider();
|
||||
const config = await resolveProviderConfig(provider);
|
||||
return {
|
||||
model: createProvider(config).languageModel(modelId),
|
||||
modelId,
|
||||
providerName: provider,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedLiveNote[]): string {
|
||||
const noteList = batch
|
||||
.map((n, i) => `${i + 1}. filePath: ${n.filePath}\n objective: ${n.objective}\n matchCriteria: ${n.eventMatchCriteria}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `## Event
|
||||
|
||||
Source: ${event.source}
|
||||
Type: ${event.type}
|
||||
Time: ${event.createdAt}
|
||||
|
||||
${event.payload}
|
||||
|
||||
## Live notes
|
||||
|
||||
${noteList}`;
|
||||
}
|
||||
|
||||
export async function findCandidates(
|
||||
event: KnowledgeEvent,
|
||||
allLiveNotes: ParsedLiveNote[],
|
||||
): Promise<ParsedLiveNote[]> {
|
||||
// Short-circuit for targeted re-runs — skip LLM routing entirely
|
||||
if (event.targetFilePath) {
|
||||
const target = allLiveNotes.find(n => n.filePath === event.targetFilePath);
|
||||
return target ? [target] : [];
|
||||
}
|
||||
|
||||
if (allLiveNotes.length === 0) {
|
||||
log.log(`event:${event.id} — no event-eligible live notes`);
|
||||
return [];
|
||||
}
|
||||
|
||||
log.log(`event:${event.id} — routing against ${allLiveNotes.length} live note${allLiveNotes.length === 1 ? '' : 's'}`);
|
||||
|
||||
const { model, modelId, providerName } = await resolveModel();
|
||||
const candidatePaths = new Set<string>();
|
||||
|
||||
for (let i = 0; i < allLiveNotes.length; i += BATCH_SIZE) {
|
||||
const batch = allLiveNotes.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: ROUTING_SYSTEM_PROMPT,
|
||||
prompt: buildRoutingPrompt(event, batch),
|
||||
schema: liveNote.Pass1OutputSchema,
|
||||
});
|
||||
captureLlmUsage({
|
||||
useCase: 'live_note_agent',
|
||||
subUseCase: 'routing',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
for (const fp of result.object.filePaths) {
|
||||
candidatePaths.add(fp);
|
||||
}
|
||||
} catch (err) {
|
||||
log.log(`event:${event.id} — Pass1 batch ${Math.floor(i / BATCH_SIZE)} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = allLiveNotes.filter(n => candidatePaths.has(n.filePath));
|
||||
log.log(`event:${event.id} — Pass1 → ${candidates.length} candidate${candidates.length === 1 ? '' : 's'}${candidates.length > 0 ? `: ${candidates.map(c => c.filePath).join(', ')}` : ''}`);
|
||||
return candidates;
|
||||
}
|
||||
235
apps/x/packages/core/src/knowledge/live-note/runner.ts
Normal file
235
apps/x/packages/core/src/knowledge/live-note/runner.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js';
|
||||
import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js';
|
||||
import { createRun, createMessage } from '../../runs/runs.js';
|
||||
import { getLiveNoteAgentModel } from '../../models/defaults.js';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||
import { liveNoteBus } from './bus.js';
|
||||
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
|
||||
|
||||
const log = new PrefixLogger('LiveNote:Agent');
|
||||
|
||||
export interface LiveNoteAgentResult {
|
||||
filePath: string;
|
||||
runId: string | null;
|
||||
action: 'replace' | 'no_update';
|
||||
contentBefore: string | null;
|
||||
contentAfter: string | null;
|
||||
summary: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SUMMARY_LOG_LIMIT = 120;
|
||||
|
||||
function truncate(s: string | null | undefined, n = SUMMARY_LOG_LIMIT): string {
|
||||
if (!s) return '';
|
||||
return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent run message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function describeWindow(triggers: LiveNote['triggers']): string {
|
||||
const ws = triggers?.windows;
|
||||
if (!ws || ws.length === 0) return 'a configured window';
|
||||
return ws.map(w => `${w.startTime}–${w.endTime}`).join(', ');
|
||||
}
|
||||
|
||||
function buildTriggerBlock(
|
||||
live: LiveNote,
|
||||
trigger: LiveNoteTriggerType,
|
||||
context: string | undefined,
|
||||
): string {
|
||||
if (trigger === 'event') {
|
||||
const criteria = live.triggers?.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)';
|
||||
return `
|
||||
|
||||
**Trigger:** Event match — Pass 1 routing flagged this note as potentially relevant to the event below.
|
||||
|
||||
**Event match criteria for this note:**
|
||||
${criteria}
|
||||
|
||||
**Event payload:**
|
||||
${context ?? '(no payload)'}
|
||||
|
||||
**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that the objective implies should be reflected.`;
|
||||
}
|
||||
|
||||
if (trigger === 'cron') {
|
||||
const expr = live.triggers?.cronExpr ?? '(unknown)';
|
||||
return `
|
||||
|
||||
**Trigger:** Scheduled refresh — the cron expression \`${expr}\` matched. This is a baseline refresh; if your objective specifies different behavior for cron vs window vs event runs, follow the cron branch.${context ? `\n\n**Context:**\n${context}` : ''}`;
|
||||
}
|
||||
|
||||
if (trigger === 'window') {
|
||||
return `
|
||||
|
||||
**Trigger:** Scheduled refresh — fired inside the configured window (${describeWindow(live.triggers)}). This is a forgiving baseline refresh that runs once per day per window; reactive updates are handled by event triggers (when configured). If your objective specifies different behavior for cron vs window vs event runs, follow the window branch.${context ? `\n\n**Context:**\n${context}` : ''}`;
|
||||
}
|
||||
|
||||
// manual
|
||||
return `
|
||||
|
||||
**Trigger:** Manual run (user-triggered — either the Run button in the Live Note panel or the \`run-live-note-agent\` tool).${context ? `\n\n**Context:**\n${context}` : ''}`;
|
||||
}
|
||||
|
||||
function buildMessage(
|
||||
filePath: string,
|
||||
live: LiveNote,
|
||||
trigger: LiveNoteTriggerType,
|
||||
context?: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Workspace-relative path the agent's tools (workspace-readFile,
|
||||
// workspace-edit) expect. Internal storage is knowledge/-relative.
|
||||
const wsPath = `knowledge/${filePath}`;
|
||||
|
||||
const baseMessage = `Update the live note at \`${wsPath}\`.
|
||||
|
||||
**Time:** ${localNow} (${tz})
|
||||
|
||||
**Objective:**
|
||||
${live.objective}
|
||||
|
||||
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then make small, incremental edits with \`workspace-edit\` to bring the body in line with the objective: edit one region, re-read to verify, then edit the next region. Avoid one-shot rewrites of the whole body. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
|
||||
|
||||
return baseMessage + buildTriggerBlock(live, trigger, context);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency guard — keyed by filePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const runningLiveNotes = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the live-note agent on a specific note.
|
||||
* Called by the scheduler ('cron' | 'window'), the event processor ('event'),
|
||||
* the renderer panel Run button ('manual'), or the `run-live-note-agent`
|
||||
* builtin tool ('manual').
|
||||
*/
|
||||
export async function runLiveNoteAgent(
|
||||
filePath: string,
|
||||
trigger: LiveNoteTriggerType = 'manual',
|
||||
context?: string,
|
||||
): Promise<LiveNoteAgentResult> {
|
||||
if (runningLiveNotes.has(filePath)) {
|
||||
log.log(`${filePath} — skip: already running`);
|
||||
return { filePath, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Already running' };
|
||||
}
|
||||
runningLiveNotes.add(filePath);
|
||||
|
||||
try {
|
||||
const live = await fetchLiveNote(filePath);
|
||||
if (!live) {
|
||||
log.log(`${filePath} — skip: note is not live (no \`live:\` block)`);
|
||||
return { filePath, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Note is not live' };
|
||||
}
|
||||
|
||||
const bodyBefore = await readNoteBody(filePath);
|
||||
|
||||
const model = live.model ?? await getLiveNoteAgentModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'live-note-agent',
|
||||
model,
|
||||
...(live.provider ? { provider: live.provider } : {}),
|
||||
useCase: 'live_note_agent',
|
||||
// Use the granular trigger as the analytics sub-use-case so
|
||||
// dashboards can break down agent runs by what woke them up
|
||||
// (manual / cron / window / event). Pass 1 routing emits the
|
||||
// separate `routing` sub-use-case from routing.ts.
|
||||
subUseCase: trigger,
|
||||
});
|
||||
|
||||
log.log(`${filePath} — start trigger=${trigger} runId=${agentRun.id}`);
|
||||
|
||||
// Bump `lastAttemptAt` immediately (before the agent executes) so the
|
||||
// scheduler's next poll suppresses duplicate firings during a slow run
|
||||
// and applies a backoff after a failure. `lastRunAt` is only bumped on
|
||||
// *success* below — that way failures don't lock the cycle anchor for
|
||||
// cron / window triggers.
|
||||
await patchLiveNote(filePath, {
|
||||
lastAttemptAt: new Date().toISOString(),
|
||||
lastRunId: agentRun.id,
|
||||
});
|
||||
|
||||
await liveNoteBus.publish({
|
||||
type: 'live_note_agent_start',
|
||||
filePath,
|
||||
trigger,
|
||||
runId: agentRun.id,
|
||||
});
|
||||
|
||||
try {
|
||||
await createMessage(agentRun.id, buildMessage(filePath, live, trigger, context));
|
||||
// throwOnError: surface any error event in the run's log (LLM API
|
||||
// failures, tool errors, billing/credit issues) as a rejection so
|
||||
// the failure branch records lastRunError. Without this the run
|
||||
// can "complete" with errors silently and we'd hit the success
|
||||
// branch with an empty summary, clobbering any prior lastRunError.
|
||||
await waitForRunCompletion(agentRun.id, { throwOnError: true });
|
||||
const summary = await extractAgentResponse(agentRun.id);
|
||||
|
||||
const bodyAfter = await readNoteBody(filePath);
|
||||
const didUpdate = bodyAfter !== bodyBefore;
|
||||
|
||||
// Success — bump the cycle anchor, refresh the summary, clear any
|
||||
// prior error.
|
||||
await patchLiveNote(filePath, {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastRunSummary: summary ?? undefined,
|
||||
lastRunError: undefined,
|
||||
});
|
||||
|
||||
log.log(`${filePath} — done action=${didUpdate ? 'replace' : 'no_update'} summary="${truncate(summary)}"`);
|
||||
|
||||
await liveNoteBus.publish({
|
||||
type: 'live_note_agent_complete',
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
summary: summary ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
action: didUpdate ? 'replace' : 'no_update',
|
||||
contentBefore: bodyBefore,
|
||||
contentAfter: bodyAfter,
|
||||
summary,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Failure — keep `lastRunAt` and `lastRunSummary` intact so the
|
||||
// user keeps seeing the last good state. Just record the error;
|
||||
// the scheduler's backoff (lastAttemptAt + 5min) prevents storming.
|
||||
try {
|
||||
await patchLiveNote(filePath, { lastRunError: msg });
|
||||
} catch {
|
||||
// Don't mask the original error if the patch itself fails.
|
||||
}
|
||||
|
||||
log.log(`${filePath} — failed: ${truncate(msg)}`);
|
||||
|
||||
await liveNoteBus.publish({
|
||||
type: 'live_note_agent_complete',
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
error: msg,
|
||||
});
|
||||
|
||||
return { filePath, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg };
|
||||
}
|
||||
} finally {
|
||||
runningLiveNotes.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { CronExpressionParser } from 'cron-parser';
|
||||
import type { Triggers } from '@x/shared/dist/live-note.js';
|
||||
|
||||
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
|
||||
export const RETRY_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Decide whether a live note's `triggers` block has any timed sub-trigger
|
||||
* (cron or window) whose cycle is currently ready to fire. Pure cycle check —
|
||||
* does NOT consider backoff.
|
||||
*
|
||||
* - Cycle accounting (cron prev-occurrence, window once-per-day) is anchored
|
||||
* on `lastRunAt` — which is bumped only on *successful* completions. So a
|
||||
* failed run leaves the cycle unfired and this returns the matched trigger
|
||||
* again on the next tick (caller is expected to gate on backoff separately).
|
||||
* - `cronExpr` enforces a 2-minute grace window — if the scheduled time was
|
||||
* more than 2 minutes ago, it's a miss and skipped (avoids replay storms
|
||||
* after the app was offline).
|
||||
* - `windows` are forgiving: each window fires at most once per day per
|
||||
* successful run, anywhere inside its time-of-day band. Cycles anchored at
|
||||
* `startTime`. Adjacent windows sharing an endpoint (e.g. 08–12 and 12–15)
|
||||
* each still fire on the same day.
|
||||
*
|
||||
* Returns the source ('cron' | 'window') or null if no cycle is ready.
|
||||
*/
|
||||
export function dueTimedTrigger(
|
||||
triggers: Triggers | undefined,
|
||||
lastRunAt: string | null,
|
||||
): 'cron' | 'window' | null {
|
||||
if (!triggers) return null;
|
||||
|
||||
if (triggers.cronExpr && isCronDue(triggers.cronExpr, lastRunAt)) return 'cron';
|
||||
|
||||
if (triggers.windows) {
|
||||
for (const w of triggers.windows) {
|
||||
if (isWindowDue(w.startTime, w.endTime, lastRunAt)) return 'window';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff check — has there been an attempt within `RETRY_BACKOFF_MS`?
|
||||
* Returns the milliseconds remaining until the backoff lifts (positive) or 0
|
||||
* if not in backoff. Caller logs the remaining time in human form.
|
||||
*/
|
||||
export function backoffRemainingMs(lastAttemptAt: string | null): number {
|
||||
if (!lastAttemptAt) return 0;
|
||||
const sinceAttempt = Date.now() - new Date(lastAttemptAt).getTime();
|
||||
if (sinceAttempt < 0 || sinceAttempt >= RETRY_BACKOFF_MS) return 0;
|
||||
return RETRY_BACKOFF_MS - sinceAttempt;
|
||||
}
|
||||
|
||||
function isCronDue(expression: string, lastRunAt: string | null): boolean {
|
||||
const now = new Date();
|
||||
if (!lastRunAt) return true; // never ran — immediately due
|
||||
|
||||
try {
|
||||
// Find the most recent occurrence at-or-before `now`, not the
|
||||
// occurrence right after lastRunAt — if lastRunAt is old, that
|
||||
// occurrence would be ancient too and always fall outside the
|
||||
// grace window, blocking every future fire.
|
||||
const interval = CronExpressionParser.parse(expression, { currentDate: now });
|
||||
const prevRun = interval.prev().toDate();
|
||||
|
||||
// Already ran at-or-after this occurrence → skip.
|
||||
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
|
||||
|
||||
// Within grace → fire. Outside grace → missed, skip.
|
||||
return now.getTime() <= prevRun.getTime() + GRACE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowDue(startTime: string, endTime: string, lastRunAt: string | null): boolean {
|
||||
const now = new Date();
|
||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = endTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
|
||||
|
||||
if (!lastRunAt) return true;
|
||||
|
||||
const cycleStart = new Date(now);
|
||||
cycleStart.setHours(startHour, startMin, 0, 0);
|
||||
if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false;
|
||||
return true;
|
||||
}
|
||||
96
apps/x/packages/core/src/knowledge/live-note/scheduler.ts
Normal file
96
apps/x/packages/core/src/knowledge/live-note/scheduler.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { PrefixLogger } from '@x/shared';
|
||||
import * as workspace from '../../workspace/workspace.js';
|
||||
import { fetchLiveNote } from './fileops.js';
|
||||
import { runLiveNoteAgent } from './runner.js';
|
||||
import { backoffRemainingMs, dueTimedTrigger } from './schedule-utils.js';
|
||||
|
||||
const log = new PrefixLogger('LiveNote:Scheduler');
|
||||
const POLL_INTERVAL_MS = 15_000; // 15 seconds
|
||||
|
||||
async function listKnowledgeMarkdownFiles(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await workspace.readdir('knowledge', { recursive: true });
|
||||
return entries
|
||||
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
|
||||
.map(e => e.path.replace(/^knowledge\//, ''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function humanMs(ms: number): string {
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.round(s / 60);
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
async function processScheduledLiveNotes(): Promise<void> {
|
||||
const relativePaths = await listKnowledgeMarkdownFiles();
|
||||
|
||||
let liveCount = 0;
|
||||
let pausedCount = 0;
|
||||
let firedCount = 0;
|
||||
let backoffCount = 0;
|
||||
|
||||
for (const relativePath of relativePaths) {
|
||||
let live;
|
||||
try {
|
||||
live = await fetchLiveNote(relativePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!live) continue;
|
||||
liveCount++;
|
||||
|
||||
if (live.active === false) {
|
||||
pausedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = dueTimedTrigger(live.triggers, live.lastRunAt ?? null);
|
||||
if (!source) continue;
|
||||
|
||||
// Cycle is ready to fire — but check backoff before triggering. This is
|
||||
// the disk-persistent backstop; the runner's in-memory concurrency
|
||||
// guard covers the common in-flight case.
|
||||
const backoffMs = backoffRemainingMs(live.lastAttemptAt ?? null);
|
||||
if (backoffMs > 0) {
|
||||
backoffCount++;
|
||||
log.log(`${relativePath} — skip (matched ${source}, backoff ${humanMs(backoffMs)} remaining)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
firedCount++;
|
||||
log.log(`${relativePath} — firing (matched ${source})`);
|
||||
runLiveNoteAgent(relativePath, source).catch(err => {
|
||||
log.log(`${relativePath} — fire error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// One summary line per tick — keeps logs scannable without spamming a row
|
||||
// per inactive note.
|
||||
if (liveCount > 0 || firedCount > 0 || backoffCount > 0) {
|
||||
log.log(
|
||||
`tick — scanned ${relativePaths.length} md, ${liveCount} live` +
|
||||
(pausedCount > 0 ? `, ${pausedCount} paused` : '') +
|
||||
(firedCount > 0 ? `, fired ${firedCount}` : '') +
|
||||
(backoffCount > 0 ? `, backoff ${backoffCount}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
log.log(`starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
|
||||
|
||||
await processScheduledLiveNotes();
|
||||
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
try {
|
||||
await processScheduledLiveNotes();
|
||||
} catch (error) {
|
||||
log.log(`tick error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { WorkDir } from '../config/config.js';
|
|||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { createEvent } from './track/events.js';
|
||||
import { createEvent } from './live-note/events.js';
|
||||
|
||||
const MAX_EVENTS_IN_DIGEST = 50;
|
||||
const MAX_DESCRIPTION_CHARS = 500;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { WorkDir } from '../config/config.js';
|
|||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { createEvent } from './track/events.js';
|
||||
import { createEvent } from './live-note/events.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
import z from 'zod';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { TrackSchema } from '@x/shared/dist/track.js';
|
||||
import { TrackStateSchema } from './types.js';
|
||||
import { withFileLock } from '../file-lock.js';
|
||||
import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
function absPath(filePath: string): string {
|
||||
return path.join(KNOWLEDGE_DIR, filePath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track-array helpers (read/write the `track:` key in a parsed frontmatter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTrackArray(fm: Record<string, unknown>): unknown[] {
|
||||
const raw = fm.track;
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
|
||||
function setTrackArray(fm: Record<string, unknown>, tracks: unknown[]): Record<string, unknown> {
|
||||
const next = { ...fm };
|
||||
if (tracks.length === 0) {
|
||||
delete next.track;
|
||||
} else {
|
||||
next.track = tracks;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
const tracks: z.infer<typeof TrackStateSchema>[] = [];
|
||||
for (const raw of getTrackArray(frontmatter)) {
|
||||
const result = TrackSchema.safeParse(raw);
|
||||
if (result.success) tracks.push({ track: result.data });
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
export async function fetch(filePath: string, id: string): Promise<z.infer<typeof TrackStateSchema> | null> {
|
||||
const all = await fetchAll(filePath);
|
||||
return all.find(t => t.track.id === id) ?? null;
|
||||
}
|
||||
|
||||
export async function fetchYaml(filePath: string, id: string): Promise<string | null> {
|
||||
const t = await fetch(filePath, id);
|
||||
if (!t) return null;
|
||||
return stringifyYaml(t.track).trimEnd();
|
||||
}
|
||||
|
||||
export async function readNoteBody(filePath: string): Promise<string> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
return splitFrontmatter(content).body;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findRawIndex(rawTracks: unknown[], id: string): number {
|
||||
return rawTracks.findIndex(
|
||||
(raw) => raw && typeof raw === 'object' && (raw as Record<string, unknown>).id === id,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTrack(
|
||||
filePath: string,
|
||||
id: string,
|
||||
updates: Partial<z.infer<typeof TrackSchema>>,
|
||||
): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
const idx = findRawIndex(rawTracks, id);
|
||||
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
|
||||
const next = [...rawTracks];
|
||||
next[idx] = { ...(rawTracks[idx] as Record<string, unknown>), ...updates };
|
||||
const nextFm = setTrackArray(frontmatter, next);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceTrackYaml(
|
||||
filePath: string,
|
||||
id: string,
|
||||
newYaml: string,
|
||||
): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const parsed = TrackSchema.safeParse(parseYaml(newYaml));
|
||||
if (!parsed.success) throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||
if (parsed.data.id !== id) {
|
||||
throw new Error(`id cannot be changed (was "${id}", got "${parsed.data.id}")`);
|
||||
}
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
const idx = findRawIndex(rawTracks, id);
|
||||
if (idx === -1) throw new Error(`Track ${id} not found in ${filePath}`);
|
||||
const next = [...rawTracks];
|
||||
next[idx] = parsed.data;
|
||||
const nextFm = setTrackArray(frontmatter, next);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTrack(filePath: string, id: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
const idx = findRawIndex(rawTracks, id);
|
||||
if (idx === -1) return; // already gone
|
||||
const next = [...rawTracks];
|
||||
next.splice(idx, 1);
|
||||
const nextFm = setTrackArray(frontmatter, next);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the note's body. Frontmatter is preserved (including the `track:`
|
||||
* array). Used by the runner to commit the agent's body edits without granting
|
||||
* the agent write access to its own runtime state.
|
||||
*/
|
||||
export async function writeNoteBody(filePath: string, newBody: string): Promise<void> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(frontmatter, newBody), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note-level summaries (tracks-list view)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TrackNoteSummary = {
|
||||
path: string;
|
||||
trackCount: number;
|
||||
createdAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
async function summarizeTrackNote(
|
||||
filePath: string,
|
||||
tracks: z.infer<typeof TrackStateSchema>[],
|
||||
): Promise<TrackNoteSummary | null> {
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const stats = await fs.stat(absPath(filePath));
|
||||
const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs;
|
||||
|
||||
let latestRunAt: string | null = null;
|
||||
let latestRunMs = -1;
|
||||
for (const { track } of tracks) {
|
||||
if (!track.lastRunAt) continue;
|
||||
const candidateMs = Date.parse(track.lastRunAt);
|
||||
if (Number.isNaN(candidateMs) || candidateMs <= latestRunMs) continue;
|
||||
latestRunMs = candidateMs;
|
||||
latestRunAt = track.lastRunAt;
|
||||
}
|
||||
|
||||
return {
|
||||
path: `knowledge/${filePath}`,
|
||||
trackCount: tracks.length,
|
||||
createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null,
|
||||
lastRunAt: latestRunAt,
|
||||
isActive: tracks.every(({ track }) => track.active !== false),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> {
|
||||
async function walk(relativeDir = ''): Promise<string[]> {
|
||||
const dirPath = absPath(relativeDir);
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const childRelPath = relativeDir
|
||||
? path.posix.join(relativeDir, entry.name)
|
||||
: entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await walk(childRelPath));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||
files.push(childRelPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const markdownFiles = await walk();
|
||||
const notes = await Promise.all(markdownFiles.map(async (relativePath) => {
|
||||
try {
|
||||
const tracks = await fetchAll(relativePath);
|
||||
return await summarizeTrackNote(relativePath, tracks);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
return notes
|
||||
.filter((note): note is TrackNoteSummary => note !== null)
|
||||
.sort((a, b) => {
|
||||
const aName = path.basename(a.path, '.md').toLowerCase();
|
||||
const bName = path.basename(b.path, '.md').toLowerCase();
|
||||
if (aName !== bName) return aName.localeCompare(bName);
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setNoteTracksActive(
|
||||
filePath: string,
|
||||
active: boolean,
|
||||
): Promise<TrackNoteSummary | null> {
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const rawTracks = getTrackArray(frontmatter);
|
||||
if (rawTracks.length === 0) return null;
|
||||
|
||||
const allMatch = rawTracks.every(
|
||||
(raw) => raw && typeof raw === 'object'
|
||||
&& ((raw as Record<string, unknown>).active !== false) === active,
|
||||
);
|
||||
if (!allMatch) {
|
||||
const updated = rawTracks.map((raw) =>
|
||||
raw && typeof raw === 'object'
|
||||
? { ...(raw as Record<string, unknown>), active }
|
||||
: raw,
|
||||
);
|
||||
const nextFm = setTrackArray(frontmatter, updated);
|
||||
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
|
||||
}
|
||||
|
||||
const validated = await fetchAll(filePath);
|
||||
return summarizeTrackNote(filePath, validated);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { generateObject } from 'ai';
|
||||
import { track, PrefixLogger } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { captureLlmUsage } from '../../analytics/usage.js';
|
||||
|
||||
const log = new PrefixLogger('TrackRouting');
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
export interface ParsedTrack {
|
||||
trackId: string;
|
||||
filePath: string;
|
||||
eventMatchCriteria: string;
|
||||
instruction: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
|
||||
|
||||
You will receive an event (something that happened — an email, meeting, message, etc.) and a list of tracks. Each track has:
|
||||
- trackId: an identifier (only unique within its file)
|
||||
- filePath: the note file the track lives in
|
||||
- matchCriteria: a description of what kinds of signals are relevant to this track (collected from the track's event triggers)
|
||||
|
||||
Your job is to identify which tracks MIGHT be relevant to this event.
|
||||
|
||||
Rules:
|
||||
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
|
||||
- Prefer false positives over false negatives. It is much better to include a track that turns out to be irrelevant than to miss one that was relevant.
|
||||
- Only exclude tracks that are CLEARLY and OBVIOUSLY irrelevant to the event.
|
||||
- Do not attempt to judge whether the event contains enough information to update the track. That is handled by a later stage.
|
||||
- Return an empty list only if no tracks are relevant at all.
|
||||
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
|
||||
|
||||
async function resolveModel() {
|
||||
const modelId = await getTrackBlockModel();
|
||||
const { provider } = await getDefaultModelAndProvider();
|
||||
const config = await resolveProviderConfig(provider);
|
||||
return {
|
||||
model: createProvider(config).languageModel(modelId),
|
||||
modelId,
|
||||
providerName: provider,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
|
||||
const trackList = batch
|
||||
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n matchCriteria: ${t.eventMatchCriteria}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `## Event
|
||||
|
||||
Source: ${event.source}
|
||||
Type: ${event.type}
|
||||
Time: ${event.createdAt}
|
||||
|
||||
${event.payload}
|
||||
|
||||
## Tracks
|
||||
|
||||
${trackList}`;
|
||||
}
|
||||
|
||||
function trackKey(trackId: string, filePath: string): string {
|
||||
return `${filePath}::${trackId}`;
|
||||
}
|
||||
|
||||
export async function findCandidates(
|
||||
event: KnowledgeEvent,
|
||||
allTracks: ParsedTrack[],
|
||||
): Promise<ParsedTrack[]> {
|
||||
// Short-circuit for targeted re-runs — skip LLM routing entirely
|
||||
if (event.targetTrackId && event.targetFilePath) {
|
||||
const target = allTracks.find(t =>
|
||||
t.trackId === event.targetTrackId && t.filePath === event.targetFilePath
|
||||
);
|
||||
return target ? [target] : [];
|
||||
}
|
||||
|
||||
const filtered = allTracks.filter(t =>
|
||||
t.active && t.instruction && t.eventMatchCriteria
|
||||
);
|
||||
if (filtered.length === 0) {
|
||||
log.log(`No event-eligible tracks (none with eventMatchCriteria)`);
|
||||
return [];
|
||||
}
|
||||
|
||||
log.log(`Routing event ${event.id} against ${filtered.length} track(s)`);
|
||||
|
||||
const { model, modelId, providerName } = await resolveModel();
|
||||
const candidateKeys = new Set<string>();
|
||||
|
||||
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
|
||||
const batch = filtered.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: ROUTING_SYSTEM_PROMPT,
|
||||
prompt: buildRoutingPrompt(event, batch),
|
||||
schema: track.Pass1OutputSchema,
|
||||
});
|
||||
captureLlmUsage({
|
||||
useCase: 'track_block',
|
||||
subUseCase: 'routing',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
for (const c of result.object.candidates) {
|
||||
candidateKeys.add(trackKey(c.trackId, c.filePath));
|
||||
}
|
||||
} catch (err) {
|
||||
log.log(`Routing batch ${i / BATCH_SIZE} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = filtered.filter(t => candidateKeys.has(trackKey(t.trackId, t.filePath)));
|
||||
log.log(`Event ${event.id}: ${candidates.length} candidate(s) — ${candidates.map(c => `${c.trackId}@${c.filePath}`).join(', ') || '(none)'}`);
|
||||
return candidates;
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
import z from 'zod';
|
||||
import { fetchAll, updateTrack, readNoteBody } from './fileops.js';
|
||||
import { createRun, createMessage } from '../../runs/runs.js';
|
||||
import { getTrackBlockModel } from '../../models/defaults.js';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||
import { trackBus } from './bus.js';
|
||||
import type { TrackStateSchema } from './types.js';
|
||||
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
|
||||
|
||||
export interface TrackUpdateResult {
|
||||
trackId: string;
|
||||
runId: string | null;
|
||||
action: 'replace' | 'no_update';
|
||||
contentBefore: string | null;
|
||||
contentAfter: string | null;
|
||||
summary: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildMessage(
|
||||
filePath: string,
|
||||
track: z.infer<typeof TrackStateSchema>,
|
||||
trigger: 'manual' | 'timed' | 'event',
|
||||
context?: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Workspace-relative path the agent's tools (workspace-readFile,
|
||||
// workspace-edit) expect. Internal fileops storage is knowledge/-relative,
|
||||
// so always prefix here when handing it to the agent.
|
||||
const wsPath = `knowledge/${filePath}`;
|
||||
|
||||
let msg = `Update track **${track.track.id}** in \`${wsPath}\`.
|
||||
|
||||
**Time:** ${localNow} (${tz})
|
||||
|
||||
**Instruction:**
|
||||
${track.track.instruction}
|
||||
|
||||
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
|
||||
|
||||
if (trigger === 'event') {
|
||||
const eventCriteria = (track.track.triggers ?? [])
|
||||
.filter(t => t.type === 'event')
|
||||
.map(t => t.matchCriteria)
|
||||
.filter(Boolean);
|
||||
const criteriaText = eventCriteria.length === 0
|
||||
? '(none — should not happen for event-triggered runs)'
|
||||
: eventCriteria.length === 1
|
||||
? eventCriteria[0]
|
||||
: eventCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
||||
msg += `
|
||||
|
||||
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
|
||||
|
||||
**Event match criteria for this track:**
|
||||
${criteriaText}
|
||||
|
||||
**Event payload:**
|
||||
${context ?? '(no payload)'}
|
||||
|
||||
**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that should be reflected in the note.`;
|
||||
} else if (context) {
|
||||
msg += `\n\n**Context:**\n${context}`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const runningTracks = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trigger an update for a specific track.
|
||||
* Can be called by any trigger system (manual, cron, event matching).
|
||||
*/
|
||||
export async function triggerTrackUpdate(
|
||||
trackId: string,
|
||||
filePath: string,
|
||||
context?: string,
|
||||
trigger: 'manual' | 'timed' | 'event' = 'manual',
|
||||
): Promise<TrackUpdateResult> {
|
||||
const key = `${trackId}:${filePath}`;
|
||||
const logger = new PrefixLogger('track:runner');
|
||||
logger.log('triggering track update', trackId, filePath, trigger, context);
|
||||
if (runningTracks.has(key)) {
|
||||
logger.log('skipping, already running');
|
||||
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Already running' };
|
||||
}
|
||||
runningTracks.add(key);
|
||||
|
||||
try {
|
||||
const tracks = await fetchAll(filePath);
|
||||
logger.log('fetched tracks from file', tracks);
|
||||
const track = tracks.find(t => t.track.id === trackId);
|
||||
if (!track) {
|
||||
logger.log('track not found', trackId, filePath, trigger, context);
|
||||
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
|
||||
}
|
||||
|
||||
const bodyBefore = await readNoteBody(filePath);
|
||||
|
||||
const model = track.track.model ?? await getTrackBlockModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'track-run',
|
||||
model,
|
||||
...(track.track.provider ? { provider: track.track.provider } : {}),
|
||||
useCase: 'track_block',
|
||||
subUseCase: 'run',
|
||||
});
|
||||
|
||||
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
||||
// the scheduler's next poll won't re-trigger this track.
|
||||
await updateTrack(filePath, trackId, {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastRunId: agentRun.id,
|
||||
});
|
||||
|
||||
await trackBus.publish({
|
||||
type: 'track_run_start',
|
||||
trackId,
|
||||
filePath,
|
||||
trigger,
|
||||
runId: agentRun.id,
|
||||
});
|
||||
|
||||
try {
|
||||
await createMessage(agentRun.id, buildMessage(filePath, track, trigger, context));
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
const summary = await extractAgentResponse(agentRun.id);
|
||||
|
||||
const bodyAfter = await readNoteBody(filePath);
|
||||
const didUpdate = bodyAfter !== bodyBefore;
|
||||
|
||||
// Patch summary into frontmatter on completion.
|
||||
await updateTrack(filePath, trackId, {
|
||||
lastRunSummary: summary ?? undefined,
|
||||
});
|
||||
|
||||
await trackBus.publish({
|
||||
type: 'track_run_complete',
|
||||
trackId,
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
summary: summary ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
trackId,
|
||||
runId: agentRun.id,
|
||||
action: didUpdate ? 'replace' : 'no_update',
|
||||
contentBefore: bodyBefore,
|
||||
contentAfter: bodyAfter,
|
||||
summary,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
await trackBus.publish({
|
||||
type: 'track_run_complete',
|
||||
trackId,
|
||||
filePath,
|
||||
runId: agentRun.id,
|
||||
error: msg,
|
||||
});
|
||||
|
||||
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg };
|
||||
}
|
||||
} finally {
|
||||
runningTracks.delete(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { CronExpressionParser } from 'cron-parser';
|
||||
import type { Trigger } from '@x/shared/dist/track.js';
|
||||
|
||||
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/** Subset of Trigger that fires on a clock — the schedulable types. */
|
||||
export type TimedTrigger = Extract<Trigger, { type: 'cron' | 'window' | 'once' }>;
|
||||
|
||||
/**
|
||||
* Determine if a timed trigger is due to fire.
|
||||
*
|
||||
* - `cron` and `once` enforce a 2-minute grace window — if the scheduled time
|
||||
* was more than 2 minutes ago, it's considered a miss and skipped (avoids
|
||||
* replay storms after the app was offline at the trigger time).
|
||||
* - `window` is forgiving: it fires at most once per day, anywhere inside the
|
||||
* configured time-of-day band. The day's cycle is anchored at `startTime` —
|
||||
* once a fire lands at-or-after today's startTime, the trigger is done for
|
||||
* the day. Use this for tracks that should "happen sometime in the morning"
|
||||
* rather than "at exactly 8:00am."
|
||||
*/
|
||||
export function isTriggerDue(schedule: TimedTrigger, lastRunAt: string | null): boolean {
|
||||
const now = new Date();
|
||||
|
||||
switch (schedule.type) {
|
||||
case 'cron': {
|
||||
if (!lastRunAt) return true; // Never ran — immediately due
|
||||
try {
|
||||
// Find the MOST RECENT occurrence at-or-before `now`, not the
|
||||
// occurrence right after lastRunAt. If lastRunAt is old, that
|
||||
// occurrence would be ancient too and always fall outside the
|
||||
// grace window, blocking every future fire.
|
||||
const interval = CronExpressionParser.parse(schedule.expression, {
|
||||
currentDate: now,
|
||||
});
|
||||
const prevRun = interval.prev().toDate();
|
||||
|
||||
// Already ran at-or-after this occurrence → skip.
|
||||
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
|
||||
|
||||
// Within grace → fire. Outside grace → missed, skip.
|
||||
return now.getTime() <= prevRun.getTime() + GRACE_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'window': {
|
||||
// Must be inside the time-of-day band.
|
||||
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
|
||||
|
||||
if (!lastRunAt) return true;
|
||||
|
||||
// Daily cycle anchored at startTime. If we've already fired
|
||||
// strictly after today's startTime, skip until tomorrow. The
|
||||
// strict comparison (>, not >=) means a fire happening exactly
|
||||
// at a window boundary belongs to the earlier window — so two
|
||||
// adjacent windows sharing an endpoint (e.g. 08–12 and 12–15)
|
||||
// each still get their own fire on the same day.
|
||||
const cycleStart = new Date(now);
|
||||
cycleStart.setHours(startHour, startMin, 0, 0);
|
||||
if (new Date(lastRunAt).getTime() > cycleStart.getTime()) return false;
|
||||
return true;
|
||||
}
|
||||
case 'once': {
|
||||
if (lastRunAt) return false; // Already ran
|
||||
const runAt = new Date(schedule.runAt);
|
||||
return now >= runAt && now.getTime() <= runAt.getTime() + GRACE_MS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { PrefixLogger } from '@x/shared';
|
||||
import * as workspace from '../../workspace/workspace.js';
|
||||
import { fetchAll } from './fileops.js';
|
||||
import { triggerTrackUpdate } from './runner.js';
|
||||
import { isTriggerDue, type TimedTrigger } from './schedule-utils.js';
|
||||
|
||||
const log = new PrefixLogger('TrackScheduler');
|
||||
const POLL_INTERVAL_MS = 15_000; // 15 seconds
|
||||
|
||||
async function listKnowledgeMarkdownFiles(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await workspace.readdir('knowledge', { recursive: true });
|
||||
return entries
|
||||
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
|
||||
.map(e => e.path.replace(/^knowledge\//, ''));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function processScheduledTracks(): Promise<void> {
|
||||
const relativePaths = await listKnowledgeMarkdownFiles();
|
||||
log.log(`Scanning ${relativePaths.length} markdown files`);
|
||||
|
||||
for (const relativePath of relativePaths) {
|
||||
let tracks;
|
||||
try {
|
||||
tracks = await fetchAll(relativePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const trackState of tracks) {
|
||||
const { track } = trackState;
|
||||
if (!track.active) continue;
|
||||
if (!track.triggers || track.triggers.length === 0) continue;
|
||||
|
||||
const timed: TimedTrigger[] = track.triggers.filter(
|
||||
(t): t is TimedTrigger => t.type !== 'event',
|
||||
);
|
||||
if (timed.length === 0) continue;
|
||||
|
||||
const dueTrigger = timed.find(t => isTriggerDue(t, track.lastRunAt ?? null));
|
||||
if (!dueTrigger) {
|
||||
log.log(`Track "${track.id}" in ${relativePath}: ${timed.length} timed trigger(s), none due`);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.log(`Triggering "${track.id}" in ${relativePath} (matched ${dueTrigger.type})`);
|
||||
triggerTrackUpdate(track.id, relativePath, undefined, 'timed').catch(err => {
|
||||
log.log(`Error running ${track.id}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
|
||||
|
||||
// Initial run
|
||||
await processScheduledTracks();
|
||||
|
||||
// Periodic polling
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
try {
|
||||
await processScheduledTracks();
|
||||
} catch (error) {
|
||||
log.log('Error in main loop:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import z from "zod";
|
||||
import { TrackSchema } from "@x/shared/dist/track.js";
|
||||
|
||||
export const TrackStateSchema = z.object({
|
||||
track: TrackSchema,
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import container from "../di/container.js";
|
|||
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview";
|
||||
const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5";
|
||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
/**
|
||||
* The single source of truth for "what model+provider should we use when
|
||||
|
|
@ -66,14 +66,14 @@ export async function getKgModel(): Promise<string> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Model used by track-block runner + routing classifier.
|
||||
* Signed-in: curated default. BYOK: user override (`trackBlockModel`) or
|
||||
* Model used by the live-note agent + routing classifier.
|
||||
* Signed-in: curated default. BYOK: user override (`liveNoteAgentModel`) or
|
||||
* assistant model.
|
||||
*/
|
||||
export async function getTrackBlockModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL;
|
||||
export async function getLiveNoteAgentModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_LIVE_NOTE_AGENT_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.trackBlockModel ?? cfg.model;
|
||||
return cfg.liveNoteAgentModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
models: config.models,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||
meetingNotesModel: config.meetingNotesModel,
|
||||
trackBlockModel: config.trackBlockModel,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel,
|
||||
};
|
||||
|
||||
const toWrite = { ...config, providers: existingProviders };
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ import { getDefaultModelAndProvider } from "../models/defaults.js";
|
|||
const LegacyStartEvent = StartEvent.extend({
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
// Pre-rename run files carry `useCase: "track_block"`. Map it to its
|
||||
// canonical successor on read so the strict downstream types never see
|
||||
// the old value. Read-only — writes always use the current enum.
|
||||
useCase: z.preprocess(
|
||||
(v) => (v === 'track_block' ? 'live_note_agent' : v),
|
||||
StartEvent.shape.useCase,
|
||||
),
|
||||
});
|
||||
const ReadRunEvent = RunEvent.or(LegacyStartEvent);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export * as agentScheduleState from './agent-schedule-state.js';
|
|||
export * as serviceEvents from './service-events.js'
|
||||
export * as inlineTask from './inline-task.js';
|
||||
export * as blocks from './blocks.js';
|
||||
export * as track from './track.js';
|
||||
export * as liveNote from './live-note.js';
|
||||
export * as promptBlock from './prompt-block.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js';
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { TrackEvent } from './track.js';
|
||||
import { LiveNoteAgentEvent, LiveNoteSchema } from './live-note.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
|
|
@ -214,8 +214,8 @@ const ipcSchemas = {
|
|||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'tracks:events': {
|
||||
req: TrackEvent,
|
||||
'live-note-agent:events': {
|
||||
req: LiveNoteAgentEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'models:list': {
|
||||
|
|
@ -611,93 +611,83 @@ const ipcSchemas = {
|
|||
response: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
// Track channels
|
||||
'track:run': {
|
||||
// Live-note channels
|
||||
'live-note:run': {
|
||||
req: z.object({
|
||||
filePath: z.string(),
|
||||
context: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
runId: z.string().nullable().optional(),
|
||||
action: z.enum(['replace', 'no_update']).optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
contentAfter: z.string().nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'live-note:get': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
summary: z.string().optional(),
|
||||
// Fresh, authoritative live-note object from frontmatter, or null when
|
||||
// the note is passive. Renderer should use this for display/edit —
|
||||
// never a stale cached copy.
|
||||
live: LiveNoteSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:get': {
|
||||
'live-note:set': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
live: LiveNoteSchema,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
// Fresh, authoritative YAML of the track from frontmatter.
|
||||
// Renderer should use this for display/edit — never a stale cached copy.
|
||||
yaml: z.string().optional(),
|
||||
live: LiveNoteSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:update': {
|
||||
'live-note:setActive': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
// Partial Track updates — merged into the entry on disk.
|
||||
// Backend is the sole writer; avoids races with scheduler/runner writes.
|
||||
updates: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:replaceYaml': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
yaml: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:delete': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:setNoteActive': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
active: z.boolean(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
note: z.object({
|
||||
path: RelPath,
|
||||
trackCount: z.number().int().positive(),
|
||||
createdAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
}).optional(),
|
||||
live: LiveNoteSchema.nullable().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:listNotes': {
|
||||
'live-note:delete': {
|
||||
req: z.object({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'live-note:stop': {
|
||||
req: z.object({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'live-note:listNotes': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
notes: z.array(z.object({
|
||||
path: RelPath,
|
||||
trackCount: z.number().int().positive(),
|
||||
createdAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
objective: z.string(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
133
apps/x/packages/shared/src/live-note.ts
Normal file
133
apps/x/packages/shared/src/live-note.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import z from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live notes
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A live note is a markdown file whose body is kept current by a background
|
||||
// agent. The user expresses intent via the `live:` block in the note's YAML
|
||||
// frontmatter:
|
||||
//
|
||||
// ---
|
||||
// live:
|
||||
// objective: |
|
||||
// Keep this note current with major developments in AI coding agents.
|
||||
// active: true
|
||||
// triggers:
|
||||
// cronExpr: "0 * * * *"
|
||||
// windows:
|
||||
// - { startTime: "09:00", endTime: "12:00" }
|
||||
// eventMatchCriteria: |
|
||||
// News, tweets, or emails about AI coding agents.
|
||||
// model: anthropic/claude-haiku-4.5
|
||||
// provider: anthropic
|
||||
// ---
|
||||
//
|
||||
// A note with no `live:` key is passive. Manual-only is `live:` with no
|
||||
// `triggers` (or all three trigger fields absent).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hand-written types — single source of truth. Zod schemas below validate at
|
||||
// runtime *against* these types via `satisfies`. We don't `z.infer` here
|
||||
// because the resulting types pass through Zod's generic machinery and can
|
||||
// resolve to `any` once the dist .d.ts is consumed downstream (project-
|
||||
// references build, mismatched zod resolution, etc.). Plain types are stable.
|
||||
|
||||
export type TriggerWindow = {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
export type Triggers = {
|
||||
cronExpr?: string;
|
||||
windows?: TriggerWindow[];
|
||||
eventMatchCriteria?: string;
|
||||
};
|
||||
|
||||
export type LiveNote = {
|
||||
objective: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunAt?: string;
|
||||
lastRunId?: string;
|
||||
lastRunSummary?: string;
|
||||
lastRunError?: string;
|
||||
};
|
||||
|
||||
const TriggerWindowSchema = z.object({
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the agent fires after this time, the window is done for the day.'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
|
||||
});
|
||||
|
||||
export const TriggersSchema = z.object({
|
||||
cronExpr: z.string().optional().describe('5-field cron expression (e.g. "0 * * * *"). Always quote when written by hand. Omit to skip cron-driven runs.'),
|
||||
windows: z.array(TriggerWindowSchema).optional().describe('A list of daily time-of-day bands. The agent fires once per day per window, anywhere inside the band — useful for "sometime in the morning" rather than an exact clock time. Omit to skip window-driven runs.'),
|
||||
eventMatchCriteria: z.string().optional().describe('Natural-language description of which incoming events (emails, calendar changes, etc.) should wake this note. Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload. Omit to skip event-driven runs.'),
|
||||
}).describe('When the live-note agent fires. Each field is optional — omit any/all. The whole `triggers` object is also optional; absent (or fully empty) means manual-only.');
|
||||
|
||||
export const LiveNoteSchema = z.object({
|
||||
objective: z.string().min(1).describe('A persistent intent in the user\'s words — what should this note keep being? E.g. "Keep this note updated with important developments in AI coding agents." The agent re-reads the objective on every run and is responsible for maintaining the entire body to satisfy it.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
|
||||
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-note LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS note. The global default already picks a tuned model for live-note runs; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-note provider name override (e.g. "openai", "anthropic"). Almost always omitted; the global default flows through correctly.'),
|
||||
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped only when an agent run *succeeds*; used as the cycle anchor for cron / window triggers and as the freshness timestamp shown in the UI.'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the live-note:stop handler.'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself. Set on success; not overwritten on failure so the user keeps seeing the last good summary.'),
|
||||
lastRunError: z.string().optional().describe('Runtime-managed — never write this yourself. Set on a failed run; cleared on the next successful run.'),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge events (live-note event-driven pipeline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KnowledgeEventSchema = z.object({
|
||||
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||
targetFilePath: z.string().optional().describe('If set, skip routing and target this note directly (used for re-runs)'),
|
||||
// Enriched on move from pending/ to done/
|
||||
processedAt: z.string().optional(),
|
||||
candidateFilePaths: z.array(z.string()).optional(),
|
||||
runIds: z.array(z.string()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
||||
|
||||
export const Pass1OutputSchema = z.object({
|
||||
filePaths: z.array(z.string()).describe('Note file paths whose objective and event-match criteria suggest the event might be relevant. The agent does Pass 2 on the event payload before editing.'),
|
||||
});
|
||||
|
||||
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bus events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LiveNoteTrigger = z.enum(['manual', 'cron', 'window', 'event']);
|
||||
export type LiveNoteTriggerType = z.infer<typeof LiveNoteTrigger>;
|
||||
|
||||
export const LiveNoteAgentStartEvent = z.object({
|
||||
type: z.literal('live_note_agent_start'),
|
||||
filePath: z.string(),
|
||||
trigger: LiveNoteTrigger,
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const LiveNoteAgentCompleteEvent = z.object({
|
||||
type: z.literal('live_note_agent_complete'),
|
||||
filePath: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
export const LiveNoteAgentEvent = z.union([LiveNoteAgentStartEvent, LiveNoteAgentCompleteEvent]);
|
||||
export type LiveNoteAgentEventType = z.infer<typeof LiveNoteAgentEvent>;
|
||||
|
|
@ -22,5 +22,5 @@ export const LlmModelConfig = z.object({
|
|||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
trackBlockModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
// run files written before these fields existed still parse cleanly.
|
||||
useCase: z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"live_note_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]).optional(),
|
||||
|
|
@ -137,7 +137,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
|||
|
||||
export const UseCase = z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"live_note_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import z from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Triggers — when a track fires
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A track can carry zero or more triggers under the `triggers:` key.
|
||||
// Each trigger is one of:
|
||||
// - cron: exact time, recurring
|
||||
// - window: once per day, anywhere inside a time-of-day band
|
||||
// - once: one-shot at a future time
|
||||
// - event: driven by incoming signals (emails, calendar events, etc.)
|
||||
//
|
||||
// A track can have multiple triggers — e.g. a daily cron trigger AND an event
|
||||
// trigger. Omit `triggers` (or pass an empty array) for a manual-only track.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TriggerSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('cron').describe('Fires at exact cron times'),
|
||||
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
|
||||
}).describe('Recurring at exact times'),
|
||||
z.object({
|
||||
type: z.literal('window').describe('Fires once per day, anywhere inside a time-of-day band'),
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the track fires after this time, it won\'t fire again until the next day.'),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
|
||||
}).describe('Recurring within a daily time-of-day window'),
|
||||
z.object({
|
||||
type: z.literal('once').describe('Fires once and never again'),
|
||||
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
|
||||
}).describe('One-shot future run'),
|
||||
z.object({
|
||||
type: z.literal('event').describe('Fires when a matching event arrives'),
|
||||
matchCriteria: z.string().describe('Describe the kinds of events that should consider this track for an update (e.g. "Emails about Q3 planning"). Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload.'),
|
||||
}).describe('Event-driven'),
|
||||
]);
|
||||
|
||||
export type Trigger = z.infer<typeof TriggerSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track entity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackSchema = z.object({
|
||||
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
|
||||
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||
triggers: z.array(TriggerSchema).optional().describe('When this track fires. A track can have multiple triggers — e.g. an hourly cron AND an event trigger. Omit (or use an empty array) for a manual-only track.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
|
||||
icon: z.string().optional().describe('Lucide icon name for status display (e.g. "clock", "calendar-days", "mail", "history", "list-todo"). Omit to use the default icon for this track.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge events (event-driven track triggering pipeline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KnowledgeEventSchema = z.object({
|
||||
id: z.string().describe('Monotonically increasing ID; also the filename in events/pending/'),
|
||||
source: z.string().describe('Producer of the event (e.g. "gmail", "calendar")'),
|
||||
type: z.string().describe('Event type (e.g. "email.synced")'),
|
||||
createdAt: z.string().describe('ISO timestamp when the event was produced'),
|
||||
payload: z.string().describe('Human-readable event body, usually markdown'),
|
||||
targetTrackId: z.string().optional().describe('If set, skip routing and target this track directly (used for re-runs)'),
|
||||
targetFilePath: z.string().optional(),
|
||||
// Enriched on move from pending/ to done/
|
||||
processedAt: z.string().optional(),
|
||||
candidates: z.array(z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
})).optional(),
|
||||
runIds: z.array(z.string()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
|
||||
|
||||
export const Pass1OutputSchema = z.object({
|
||||
candidates: z.array(z.object({
|
||||
trackId: z.string().describe('The track identifier'),
|
||||
filePath: z.string().describe('The note file path the track lives in'),
|
||||
})).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'),
|
||||
});
|
||||
|
||||
export type Pass1Output = z.infer<typeof Pass1OutputSchema>;
|
||||
|
||||
// Track bus events
|
||||
export const TrackRunStartEvent = z.object({
|
||||
type: z.literal('track_run_start'),
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
trigger: z.enum(['timed', 'manual', 'event']),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const TrackRunCompleteEvent = z.object({
|
||||
type: z.literal('track_run_complete'),
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
|
||||
|
||||
export type Track = z.infer<typeof TrackSchema>;
|
||||
export type TrackEventType = z.infer<typeof TrackEvent>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue