Merge pull request #537 from rowboatlabs/dev

Dev
This commit is contained in:
Ramnique Singh 2026-05-07 18:18:23 +05:30 committed by GitHub
commit 62a07618e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 6785 additions and 4379 deletions

View file

@ -108,7 +108,7 @@ Long-form docs for specific features. Read the relevant file before making chang
| Feature | Doc |
|---------|-----|
| Track Blocks — auto-updating note content (scheduled / event-driven / manual), Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
| Tracks — frontmatter directives that keep a note's body auto-updated (cron / window / once / event / multi-trigger), section-placement model, sidebar UI, Copilot skill, prompts catalog | `apps/x/TRACKS.md` |
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
## Common Tasks

View file

@ -1,24 +1,29 @@
# Track Blocks
# Tracks
> Living blocks of content embedded in markdown notes that auto-refresh on a schedule, when relevant events arrive, or on demand.
> Frontmatter directives that keep a markdown note's body auto-updated — on a schedule, when a relevant event arrives, or on demand.
A track block is a small YAML code fence plus a sibling HTML-comment region in a note. The YAML defines what to produce and when; the comment region holds the generated output, rewritten on each run. A single note can hold many independent tracks — one for weather, one for an email digest, one for a running project summary.
A track is a single entry in a note's YAML frontmatter under the `track:` array. Each entry defines an instruction, optional triggers (cron / window / once / event — any mix), and (after the first run) some runtime state. When a trigger fires, a background agent edits the **note body** to satisfy the instruction. A note with no `track:` key is just a static note.
**Example** (a Chicago-time track refreshed hourly):
**Example** (a note that shows the current Chicago time, refreshed hourly):
~~~markdown
```track
trackId: chicago-time
instruction: Show the current time in Chicago, IL in 12-hour format.
---
track:
- id: chicago-time
instruction: |
Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
triggers:
- type: cron
expression: "0 * * * *"
```
lastRunAt: "2026-05-07T15:00:01.234Z"
lastRunId: "..."
lastRunSummary: "Updated — 3:00 PM, Central Time."
---
<!--track-target:chicago-time-->
2:30 PM, Central Time
<!--/track-target:chicago-time-->
# Chicago time
3:00 PM, Central Time
~~~
## Table of Contents
@ -27,70 +32,74 @@ schedule:
2. [Architecture at a Glance](#architecture-at-a-glance)
3. [Technical Flows](#technical-flows)
4. [Schema Reference](#schema-reference)
5. [Prompts Catalog](#prompts-catalog)
6. [File Map](#file-map)
7. [Known Follow-ups](#known-follow-ups)
5. [Section Placement](#section-placement)
6. [Daily-Note Template & Migrations](#daily-note-template--migrations)
7. [Renderer UI](#renderer-ui)
8. [Prompts Catalog](#prompts-catalog)
9. [File Map](#file-map)
---
## Product Overview
### Trigger types
### Triggers
A track block has exactly one of four trigger configurations. Schedule and event triggers can co-exist on a single track.
A track has zero or more triggers under a single `triggers:` array. Each trigger is one of four types and can be mixed freely:
| Trigger | When it fires | How to express it |
| Type | When it fires | Shape |
|---|---|---|
| **Manual** | Only when the user (or Copilot) hits Run | Omit `schedule`, leave `eventMatchCriteria` unset |
| **Scheduled — cron** | At exact cron times | `schedule: { type: cron, expression: "0 * * * *" }` |
| **Scheduled — window** | At most once per cron occurrence, only within a time-of-day window | `schedule: { type: window, cron, startTime: "09:00", endTime: "17:00" }` |
| **Scheduled — once** | Once at a future time, then never | `schedule: { type: once, runAt: "2026-04-14T09:00:00" }` |
| **Event-driven** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` |
| **`cron`** | At exact cron times | `{ type: cron, expression: "0 * * * *" }` |
| **`window`** | Once per day, anywhere inside a time-of-day band | `{ type: window, startTime: "09:00", endTime: "12:00" }` |
| **`once`** | Once at a future time, then never | `{ type: once, runAt: "2026-04-14T09:00:00" }` |
| **`event`** | When a matching event arrives (e.g. new Gmail thread) | `{ type: event, matchCriteria: "Emails about Q3 planning" }` |
Decision shorthand: cron for exact times, window for "sometime in the morning", once for one-shots, event for signal-driven updates. Combine `schedule` + `eventMatchCriteria` for a track that refreshes on a cadence **and** reacts to incoming signals.
A track with no `triggers` (or an empty array) is **manual-only** — fires only when the user clicks Run in the sidebar.
`cron` and `once` enforce a 2-minute grace window — if the scheduled time passed more than 2 minutes ago (e.g. the app was offline), the run is skipped, not replayed. `window` is forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, it fires the moment the app is open. The day's cycle is anchored at `startTime`.
A single track can carry multiple triggers. The flagship example is in Today.md's `priorities` track: three `window` entries (morning / midday / post-lunch) plus two `event` entries (gmail / calendar) — five triggers total, giving a baseline rebuild three times per day plus reactive updates on incoming signals.
### Creating a track
Three paths, all produce identical on-disk YAML:
Two paths, both producing identical on-disk YAML:
1. **Hand-written** — type the YAML fence directly in a note. The renderer picks it up instantly via the Tiptap `track-block` extension.
2. **Cmd+K with cursor context** — press Cmd+K anywhere in a note, say "add a track that shows Chicago weather hourly". Copilot loads the `tracks` skill and splices the block at the cursor using `workspace-edit`.
3. **Sidebar chat** — mention a note (or have it attached) and ask for a track. Copilot appends the block at the end or under the heading you name.
1. **Hand-written** — type the entry directly into a note's frontmatter under `track:`. The scheduler picks it up on its next 15-second tick.
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond the literal word "track" (see "Prompts Catalog → Copilot trigger paragraph" for the signal taxonomy); it loads the `tracks` skill, edits the note's frontmatter via `workspace-edit`, then **runs the track once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
### Viewing and managing a track
There is no inline-block creation flow anymore. The Cmd+K palette is search-only and does not invoke Copilot.
The editor shows track blocks as an inline **chip** — a pill with the track ID, a clipped instruction, a color rail matching the trigger type, and a spinner while running.
### Viewing and managing tracks
Clicking the chip opens the **track modal**, where everything happens:
The editor has a Radio-icon button in the top toolbar (right side) that opens the **Track Sidebar** for the current note. The sidebar:
- **Header** — track ID, schedule summary (e.g. "Hourly"), and a Switch to pause/resume (flips `active`).
- **Tabs***What to track* (renders `instruction` as markdown), *When to run* (schedule details, shown if `schedule` is present), *Event matching* (shown if `eventMatchCriteria` is present), *Details* (ID, file, run metadata).
- **Advanced** — expandable raw-YAML editor for power users.
- **Danger zone** (Details tab) — delete with two-step confirmation; also removes the target region.
- **Footer***Edit with Copilot* (opens sidebar chat with the note attached) and *Run now* (triggers immediately).
- **List view** — one row per track in the note's frontmatter. Title is the track's `id`; subtitle is the trigger summary plus a `Paused ·` prefix when applicable, plus the instruction's first line as a tertiary line. A Play button on the right runs that track.
- **Detail view** (click a row) — back arrow + tabs (*What* / *Schedule* / *Events* / *Details*), an advanced raw-YAML editor, danger-zone delete, and a footer with "Edit with Copilot" + "Run now".
- **Status hook**`useTrackStatus` subscribes to `tracks:events` IPC; rows show a spinner whenever a track is running, regardless of hover state.
Every mutation inside the modal goes through IPC to the backend — the renderer never writes the file itself. This is the fix that prevents the editor's save cycle from clobbering backend-written fields like `lastRunAt`.
Every mutation in the sidebar goes through IPC to the backend — the renderer never writes the file itself. This avoids races with the scheduler/runner writing runtime fields like `lastRunAt`.
### What Copilot can do
### What the runtime agent does
- **Create, edit, and delete** track blocks (via the `tracks` skill + `workspace-edit` / `workspace-readFile`).
- **Run** a track with the `run-track-block` builtin tool. An optional `context` parameter biases this single run — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`. Common use: seed a newly-created event-driven track so its target region isn't empty waiting for the next matching event.
- **Proactively suggest** tracks when the user signals interest ("track", "monitor", "watch", "every morning"), per the trigger paragraph in `instructions.ts`.
- **Re-enter edit mode** via the modal's *Edit with Copilot* button, which seeds a new chat with the note attached and invites Copilot to load the `tracks` skill.
When a trigger fires, a background agent ("track-run") receives a short message:
- The track's `id`, the workspace-relative path to the note, and a localized timestamp.
- The instruction.
- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it").
### After a run
The agent's system prompt tells it to:
1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
2. Find or create the H2 section the instruction names (placement model below).
3. Update only that section's content. Never modify YAML frontmatter — that's owned by the user and the runtime.
4. After writing, re-check its section's position; cut-and-paste only its own block if it's misplaced (handles the cold-start firing-order problem).
5. End with a one-line summary stored as `lastRunSummary`.
- The **target region** (between `<!--track-target:ID-->` markers) is rewritten by the track-run agent using the `update-track-content` tool.
- `lastRunAt`, `lastRunId`, `lastRunSummary` are updated in the YAML.
- The chip pulses while running, then displays the latest `lastRunAt`.
- Bus events (`track_run_start` / `track_run_complete`) are forwarded to the renderer via the `tracks:events` IPC channel, consumed by the `useTrackStatus` hook.
The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP) — there's no special "track-content" tool anymore; tracks just ship general edits.
---
## Architecture at a Glance
```
Editor chip (display-only) ──click──► TrackModal (React)
Editor toolbar Radio button ─click──► TrackSidebar (React)
├──► IPC: track:get / update /
│ replaceYaml / delete / run
@ -98,208 +107,229 @@ Editor chip (display-only) ──click──► TrackModal (React)
Backend (main process)
├─ Scheduler loop (15 s) ──┐
├─ Event processor (5 s) ──┼──► triggerTrackUpdate() ──► track-run agent
└─ Copilot tool run-track-block ──┘
└─ Builtin tool run-track ─┘
update-track-content tool
workspace-readFile / -edit
target region rewritten on disk
body region rewritten on disk
frontmatter lastRun* patched
```
**Single-writer invariant:** the renderer is never a file writer. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrackBlock`, `replaceTrackBlockYaml`, `deleteTrackBlock`). This avoids races with the scheduler/runner writing runtime fields.
**Single-writer invariant** the renderer is never a file writer for the `track:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/track/fileops.ts` (`updateTrack`, `replaceTrackYaml`, `deleteTrack`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `track:` key (and `buildFrontmatter` splices it back from the original raw on save), so the FrontmatterProperties UI can't accidentally mangle it.
**Event contract:** `window.dispatchEvent(CustomEvent('rowboat:open-track-modal', { detail }))` is the sole entry point from editor chip → modal. Similarly, `rowboat:open-copilot-edit-track` opens the sidebar chat with context.
**Event contract** `window.dispatchEvent(CustomEvent('rowboat:open-track-sidebar', { detail: { filePath } }))` is the sole entry point from editor toolbar → sidebar. `rowboat:open-copilot-edit-track` opens the Copilot sidebar with the note attached.
---
## Technical Flows
### 4.1 Scheduling (cron / window / once)
### Scheduling (cron / window / once)
- Module: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all track blocks via `fetchAll(relPath)`.
- Per-track due check: `isTrackScheduleDue(schedule, lastRunAt)` in `schedule-utils.ts`. All three schedule types enforce a **2-minute grace window** — missed schedules (app offline at trigger time) are skipped, not replayed.
- When due, fires `triggerTrackUpdate(trackId, filePath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
- Startup: `initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
- **Module**: `packages/core/src/knowledge/track/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`).
- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, iterate all tracks via `fetchAll(relPath)`.
- For each track with `active === true` and at least one timed trigger (`cron` / `window` / `once`), `find` the first due trigger via `isTriggerDue(t, lastRunAt)` (`schedule-utils.ts`).
- When due, fire `triggerTrackUpdate(track.id, relPath, undefined, 'timed')` (fire-and-forget; the runner's concurrency guard prevents duplicates).
- **Grace window**`cron` and `once` enforce a 2-minute grace; missed schedules are skipped, not replayed. `window` has no grace — anywhere inside the band counts.
- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a fire lands strictly after today's `startTime`, that window is done for the day. The strict comparison handles the boundary case (e.g. an 08:0012:00 + a 12:0015:00 window each get to fire even when the morning fire happens exactly at 12:00:00).
- **Startup**`initTrackScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initTrackEventProcessor()`.
### 4.2 Event pipeline
### Event pipeline
**Producers** — any data source that should feed tracks emits events:
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — three call sites, each after a successful thread sync, call `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync. The payload is a markdown digest of all new/updated/deleted events built by `summarizeCalendarSync()` (line 68). `publishCalendarSyncEvent()` (line ~126) wraps it with `source: 'calendar'`, `type: 'calendar.synced'`.
- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: <thread markdown> })`.
- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`.
**Storage** — `packages/core/src/knowledge/track/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/<id>.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO.
**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event:
1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive).
2. `listAllTracks()` scans every `.md` under `knowledge/` and collects `ParsedTrack[]`.
3. `findCandidates(event, allTracks)` in `routing.ts` runs Pass 1 LLM routing (below).
4. For each candidate, `triggerTrackUpdate(trackId, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then `moveEventToDone()` — write to `events/done/<id>.json`, unlink from `pending/`.
2. `listAllTracks()` scans every `.md` under `knowledge/`. Only tracks with at least one `event`-type trigger appear in the routing list; their `eventMatchCriteria` is the joined `matchCriteria` from all event triggers (`'; '`-separated).
3. `findCandidates(event, allTracks)` runs Pass 1 LLM routing (below).
4. For each candidate, `triggerTrackUpdate(id, filePath, event.payload, 'event')` **sequentially** — preserves total ordering within the event.
5. Enrich the event JSON with `processedAt`, `candidates`, `runIds`, `error?`, then move to `events/done/<id>.json`.
**Pass 1 routing** (`routing.ts:73+ findCandidates`):
- **Short-circuit**: if `event.targetTrackId` + `targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
**Pass 1 routing** (`routing.ts`):
- **Short-circuit** — if `event.targetTrackId` + `event.targetFilePath` are set (manual re-run events), skip the LLM and return that track directly.
- Filter to `active && instruction && eventMatchCriteria` tracks.
- Batches of `BATCH_SIZE = 20`.
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema``candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `trackId` is only unique per file.
- Model resolution: gateway if signed in, local provider otherwise; prefers `knowledgeGraphModel` from config.
- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema``candidates: { trackId, filePath }[]`. The composite key `trackId::filePath` deduplicates across files since `id` is only unique per file.
**Pass 2 decision** happens inside the track-run agent (see Run flow below) — the liberal Pass 1 produces candidates, the agent vetoes false positives before touching the target region.
**Pass 2 decision** happens inside the track-run agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body.
### 4.3 Run flow (`triggerTrackUpdate`)
### Run flow (`triggerTrackUpdate`)
Module: `packages/core/src/knowledge/track/runner.ts`.
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${trackId}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
2. **Fetch block** via `fetchAll(filePath)`, locate by `trackId`.
3. **Create agent run**`createRun({ agentId: 'track-run' })`.
4. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger, and for `once` tracks the "done" flag is already set.
5. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
6. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` — three branches (see Prompts Catalog). For `'event'` trigger, the message includes the event payload and a Pass 2 decision directive.
7. **Wait for completion**`waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
8. **Compare content**: re-read the file, diff `contentBefore` vs `contentAfter`. If changed → `action: 'replace'`; else → `action: 'no_update'`.
9. **Store `lastRunSummary`** via `updateTrackBlock`.
10. **Emit `track_run_complete`** with `summary` or `error`.
11. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
1. **Concurrency guard** — static `runningTracks: Set<string>` keyed by `${id}:${filePath}`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`.
2. **Fetch track** via `fetchAll(filePath)`, locate by `id`.
3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff.
4. **Create agent run**`createRun({ agentId: 'track-run' })`.
5. **Set `lastRunAt` + `lastRunId` immediately** (before the agent executes). Prevents retry storms: if the run fails, the scheduler's next tick won't re-trigger; for `once` tracks the "done" marker is already set.
6. **Emit `track_run_start`** on the `trackBus` with the trigger type (`manual` / `timed` / `event`).
7. **Send agent message** built by `buildMessage(filePath, track, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly.
8. **Wait for completion**`waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary.
9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`.
10. **Patch `lastRunSummary`** via `updateTrack(filePath, id, { lastRunSummary })`.
11. **Emit `track_run_complete`** with `summary` or `error`.
12. **Cleanup**: `runningTracks.delete(key)` in a `finally` block.
Returned to callers: `{ trackId, runId, action, contentBefore, contentAfter, summary, error? }`.
### 4.4 IPC surface
### IPC surface
| Channel | Caller → handler | Purpose |
|---|---|---|
| `track:run` | Renderer (chip/modal Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
| `track:get` | Modal on open | Returns fresh YAML from disk via `fetchYaml` |
| `track:update` | Modal toggle / partial edits | `updateTrackBlock` merges a partial into on-disk YAML |
| `track:replaceYaml` | Advanced raw-YAML save | `replaceTrackBlockYaml` validates + writes full YAML |
| `track:delete` | Danger-zone confirm | `deleteTrackBlock` removes YAML fence + target region |
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to the `useTrackStatus` hook |
| `track:run` | Renderer (sidebar Run button) | Fires `triggerTrackUpdate(..., 'manual')` |
| `track:get` | Sidebar on detail open | Returns fresh per-track YAML from disk via `fetchYaml(filePath, id)` |
| `track:update` | Sidebar toggle / partial edits | `updateTrack` merges a partial into the on-disk entry |
| `track:replaceYaml` | Sidebar advanced raw-YAML save | `replaceTrackYaml` validates + writes the full entry |
| `track:delete` | Sidebar danger-zone confirm | `deleteTrack` removes the entry from the `track:` array |
| `track:setNoteActive` | Background-agents view toggle | Flips `active` on every track in a note |
| `track:listNotes` | Background-agents view load | Lists all notes that contain at least one track, with summary fields |
| `tracks:events` | Server → renderer (`webContents.send`) | Forwards `trackBus` events to `useTrackStatus` |
Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/track/fileops.ts`.
### 4.5 Renderer integration
### Concurrency & FIFO guarantees
- **Chip**`apps/renderer/src/extensions/track-block.tsx`. Tiptap atom node. Render-only: click dispatches `CustomEvent('rowboat:open-track-modal', { detail: { trackId, filePath, initialYaml, onDeleted } })`. The `onDeleted` callback is the Tiptap `deleteNode` — the modal calls it after a successful `track:delete` IPC so the editor also drops the node and doesn't resurrect it on next save.
- **Modal**`apps/renderer/src/components/track-modal.tsx`. Mounted once in `App.tsx`, self-listens for the open event. On open: calls `track:get` to get the authoritative YAML, not the Tiptap cached attr. All mutations go through IPC; `updateAttributes` is never called.
- **Status hook**`apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` and maintains a `Map<"${trackId}:${filePath}", RunState>` so chips and the modal reflect live run state.
- **Edit with Copilot** — the modal's footer button dispatches `rowboat:open-copilot-edit-track`. An `useEffect` listener in `App.tsx` opens the chat sidebar via `submitFromPalette(text, mention)`, where `text` is a short opener that invites Copilot to load the `tracks` skill and `mention` attaches the note file.
### 4.6 Copilot skill integration
- **Skill content**`packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported as a `String.raw` template literal; injected into the Copilot system prompt when the `loadSkill('tracks')` tool is called.
- **Canonical schema** inside the skill is **auto-generated at module load**: `stringifyYaml(z.toJSONSchema(TrackBlockSchema))`. Editing `TrackBlockSchema` in `packages/shared/src/track-block.ts` propagates to the skill without manual sync.
- **Skill registration**`packages/core/src/application/assistant/skills/index.ts` (import + entry in the `definitions` array).
- **Loading trigger**`packages/core/src/application/assistant/instructions.ts:73` — one paragraph tells Copilot to load the skill on track / monitor / watch / "keep an eye on" / Cmd+K auto-refresh requests.
- **Builtin tools**`packages/core/src/application/lib/builtin-tools.ts`:
- `update-track-content` — low-level: rewrite the target region between `<!--track-target:ID-->` markers. Used mainly by the track-run agent.
- `run-track-block` — high-level: trigger a run with an optional `context`. Uses `await import("../../knowledge/track/runner.js")` inside the execute body to break a module-init cycle (`builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools`).
### 4.7 Concurrency & FIFO guarantees
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once; overlapping triggers (manual + scheduled + event) return `error: 'Already running'` cleanly through IPC.
- **Backend is single writer** — renderer uses IPC for every edit; backend helpers in `fileops.ts` are the only code paths that touch the file.
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()` = strict FIFO across events. Candidates within one event are processed sequentially (`await` in loop) so ordering holds within an event too.
- **Per-track serialization** — the `runningTracks` guard in `runner.ts`. A track is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`.
- **Backend is single writer for `track:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `track:` byte-for-byte across saves.
- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file.
- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially.
- **No retry storms**`lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the track marked as ran; the scheduler's next tick computes the next occurrence from that point.
---
## Schema Reference
All canonical schemas live in `packages/shared/src/track-block.ts`:
All canonical schemas live in `packages/shared/src/track.ts`:
- `TrackBlockSchema` — the YAML that goes inside a ` ```track ` fence. User-authored fields: `trackId` (kebab-case, unique within the file), `instruction`, `active`, `schedule?`, `eventMatchCriteria?`. **Runtime-managed fields (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
- `TrackScheduleSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' }`.
- `TrackSchema` — a single entry in the frontmatter `track:` array. Fields: `id` (kebab-case, unique within the note), `instruction`, `active` (default true), `triggers?`, `model?`, `provider?`, `icon?`. **Runtime-managed (never hand-write):** `lastRunAt`, `lastRunId`, `lastRunSummary`.
- `TriggerSchema` — discriminated union over `{ type: 'cron' | 'window' | 'once' | 'event' }`. Window has just `startTime` + `endTime` (no `cron` field — the cycle is anchored at `startTime`).
- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidates`, `runIds`, `error`) are populated when moving to `done/`.
- `Pass1OutputSchema` — the structured response the routing classifier returns: `{ candidates: { trackId, filePath }[] }`.
Since the skill's schema block is generated from `TrackBlockSchema`, schema changes should start here — the skill, the validator in `replaceTrackBlockYaml`, and modal display logic all follow from the Zod source of truth.
The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(TrackSchema))` — so editing `TrackSchema` propagates to the skill on the next build.
---
## Section Placement
Tracks no longer have formal target regions. Each instruction names a section by H2 heading (e.g. *"in a section titled 'Overview' at the top"*) and the agent finds or creates that section.
The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/track/run-agent.ts`):
- Sections are **H2 headings** (`## Section Name`). Match by exact heading text.
- **Existing**: replace its content (everything between that heading and the next H2 — or end of file). Heading itself stays.
- **Missing**: create it. The placement hint determines location:
- "at the top" → just below the H1 title.
- "after X" → immediately after section X.
- no hint → append.
- **Self-heal**: after writing, the agent re-checks its section's position. If misplaced (the cold-start case where empty notes get sections in firing order rather than reading order), the agent moves only its **own** H2 block — never reorders other tracks' sections.
- **Boundaries**: never modify another track's section content; never duplicate; never touch frontmatter; if the user renamed the heading, recreate per the placement hint.
This keeps tracks loosely coupled: each one stakes out a section by name, and the rest of the body is entirely the user's.
---
## Daily-Note Template & Migrations
`Today.md` is the canonical demo of what tracks can do. It ships with six tracks (overview/photo combined into one, calendar, emails, what-you-missed, priorities) showing pure-cron, pure-event, multi-window, and multi-trigger configurations.
**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`:
- File missing → fresh write at canonical version.
- File at-or-above canonical → no-op.
- File below canonical → rename existing to `Today.md.bkp.<ISO-stamp>` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template with the body byte-preserved via `splitFrontmatter` from `application/lib/parse-frontmatter.ts`.
Any change to the canonical TRACKS list, instructions, default body, or trigger config should bump the constant. Existing users will get the new template on next launch with their body sections preserved; their `lastRunAt` and any custom additions to the tracks list are dropped (the .bkp file is the recovery path).
---
## Renderer UI
The chip-in-editor model is gone. Replacements:
- **Toolbar button**`apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon ghost button at the top-right of the editor toolbar. `markdown-editor.tsx` passes `onOpenTracks` (only when a `notePath` is available) which dispatches `rowboat:open-track-sidebar` with `{ filePath }`.
- **Sidebar**`apps/renderer/src/components/track-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-track-sidebar`; on open, calls `workspace:readFile` and parses tracks from the frontmatter on the renderer side (uses the same `TrackSchema` from `@x/shared`). All mutations go through IPC.
- Constant top header: Radio icon, "Tracks" title, note name subtitle, X close. Uses the `bg-sidebar` design tokens to match the app's left sidebar.
- List view: one row per track. Title is `id`; subtitle is the trigger summary (with `Paused ·` prefix); third line is the instruction's first line, truncated. Run button always visible while running, otherwise fades in on hover.
- Detail view: back arrow + track id; status row (trigger summary + Active/Paused toggle); tabs (`What` / `Schedule` / `Events` / `Details`); advanced raw-YAML editor; danger-zone delete; footer (Edit with Copilot + Run now).
- **Status hook**`apps/renderer/src/hooks/use-track-status.ts`. Subscribes to `tracks:events` IPC and maintains a `Map<"${id}:${filePath}", RunState>` keyed by composite key.
- **Edit-with-Copilot flow** — sidebar dispatches `rowboat:open-copilot-edit-track` (App.tsx listener handles it via `submitFromPalette`).
- **FrontmatterProperties safety**`apps/renderer/src/lib/frontmatter.ts` adds `STRUCTURED_KEYS = new Set(['track'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `track:` block back from `preserveRaw` on save. This means the property panel can edit `tags` / `status` / etc. without ever clobbering the tracks frontmatter.
---
## Prompts Catalog
Every LLM-facing prompt in the feature, with file + line pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app (`npm run dev`).
Every LLM-facing prompt in the feature, with file pointers so you can edit in place. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app.
### 1. Routing system prompt (Pass 1 classifier)
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; Pass 2 inside the agent catches them.
- **File**: `packages/core/src/knowledge/track/routing.ts:2237` (`ROUTING_SYSTEM_PROMPT`).
- **Inputs**: none interpolated — constant system prompt.
- **Purpose**: decide which tracks *might* be relevant to an incoming event. Liberal — prefers false positives; the run-agent does Pass 2.
- **File**: `packages/core/src/knowledge/track/routing.ts` (`ROUTING_SYSTEM_PROMPT`).
- **Output**: structured `Pass1OutputSchema``{ candidates: { trackId, filePath }[] }`.
- **Invoked by**: `findCandidates()` at `routing.ts:73+`, per batch of 20 tracks, via `generateObject({ model, system, prompt, schema })`.
- **Invoked by**: `findCandidates()` per batch of 20 tracks via `generateObject({ model, system, prompt, schema })`.
### 2. Routing user prompt template
- **Purpose**: formats the event and the current batch of tracks into the user message fed alongside the system prompt.
- **File**: `packages/core/src/knowledge/track/routing.ts:5166` (`buildRoutingPrompt`).
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `eventMatchCriteria`).
- **Output**: plain text, two sections — `## Event` and `## Track Blocks`.
- **Note**: the event `payload` is what the producer wrote. For Gmail it's a thread markdown dump; for Calendar it's a digest (see #7 below).
- **Purpose**: formats the event and the current batch of tracks into the user message for Pass 1.
- **File**: `packages/core/src/knowledge/track/routing.ts` (`buildRoutingPrompt`).
- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedTrack[]` (each: `trackId`, `filePath`, `matchCriteria` — joined from all event triggers, `'; '`-separated).
- **Output**: plain text, two sections — `## Event` and `## Tracks`.
### 3. Track-run agent instructions
- **Purpose**: system prompt for the background agent that actually rewrites target regions. Sets tone ("no user present — don't ask questions"), points at the knowledge graph, and prescribes the `update-track-content` tool as the write path.
- **File**: `packages/core/src/knowledge/track/run-agent.ts:650` (`TRACK_RUN_INSTRUCTIONS`).
- **Inputs**: `${WorkDir}` template literal (substituted at module load).
- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the section-placement contract (find/create/self-heal), points at the knowledge graph, and prescribes general `workspace-readFile` / `workspace-edit` as the write path.
- **File**: `packages/core/src/knowledge/track/run-agent.ts` (`TRACK_RUN_INSTRUCTIONS`).
- **Inputs**: `${WorkDir}` template literal substituted at module load.
- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`.
- **Invoked by**: `buildTrackRunAgent()` at line 52, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
- **Invoked by**: `buildTrackRunAgent()`, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`.
### 4. Track-run agent message (`buildMessage`)
- **Purpose**: the user message seeded into each track-run. Three shape variants based on `trigger`.
- **File**: `packages/core/src/knowledge/track/runner.ts:2362`.
- **Inputs**: `filePath`, `track.trackId`, `track.instruction`, `track.content`, `track.eventMatchCriteria`, `trigger`, optional `context`, plus `localNow` / `tz`.
- **Output**: free-form — the agent decides whether to call `update-track-content`.
- **Purpose**: the user message seeded into each track-run.
- **File**: `packages/core/src/knowledge/track/runner.ts` (`buildMessage`).
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `track.id`, `track.instruction`, all event triggers' `matchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`.
- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run).
Three branches:
- **`manual`** — base message (instruction + current content + tool hint). If `context` is passed, it's appended as a `**Context:**` section. The `run-track-block` tool uses this path for both plain refreshes and context-biased backfills.
Three branches by `trigger`:
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-track` tool uses this path for both plain refreshes and context-biased backfills.
- **`timed`** — same as `manual`. Called by the scheduler with no `context`.
- **`event`** — adds a **Pass 2 decision block** (lines 4556). Quoted verbatim:
> **Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
>
> **Event match criteria for this track:**
>
> **Event payload:**
>
> **Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update — do NOT call `update-track-content`. Only call the tool if the event provides new or changed information that should be reflected in the track.
- **`event`** — adds a Pass 2 decision block listing all event triggers' `matchCriteria` (numbered if multiple) and the event payload, with the directive to skip the edit if the event isn't truly relevant.
### 5. Tracks skill (Copilot-facing)
- **Purpose**: teaches Copilot everything about track blocks — anatomy, schema, schedules, event matching, insertion workflow, when to proactively suggest, when to run with context.
- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts` (~318 lines). Exported `skill` constant.
- **Inputs**: at module load, `stringifyYaml(z.toJSONSchema(TrackBlockSchema))` is interpolated into the "Canonical Schema" section. Rebuilds propagate schema edits automatically.
- **Purpose**: teaches Copilot the frontmatter `track:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, user-facing language (call them "tracks"; surface the **Track sidebar** by name), the auto-run-once-on-create/edit default, schema, triggers, multi-trigger combos, YAML-safety rules, insertion workflow, and the `run-track` tool with `context` backfills.
- **File**: `packages/core/src/application/assistant/skills/tracks/skill.ts`. Exported `skill` constant.
- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(TrackSchema))` is interpolated into the "Canonical Schema" section. Edits to `TrackSchema` propagate automatically.
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('tracks')` fires.
- **Invoked by**: Copilot's `loadSkill` builtin tool (see `packages/core/src/application/lib/builtin-tools.ts`). Registration in `skills/index.ts`.
- **Edit guidance**: for schema-shaped changes, start at `packages/shared/src/track-block.ts` — the skill picks it up on the next build. For prose/workflow tweaks, edit the `String.raw` template.
- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`.
### 6. Copilot trigger paragraph
- **Purpose**: tells Copilot *when* to load the `tracks` skill.
- **File**: `packages/core/src/application/assistant/instructions.ts:73`.
- **Inputs**: none; static prose.
- **Output**: part of the baseline Copilot system prompt.
- **Trigger words**: *track*, *monitor*, *watch*, *keep an eye on*, "*every morning tell me X*", "*show the current Y in this note*", "*pin live updates of Z here*", plus any Cmd+K request that implies auto-refresh.
- **Purpose**: tells Copilot *when* to load the `tracks` skill, and frames how aggressively to act once loaded.
- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Tracks (Auto-Updating Notes)" paragraph).
- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…").
- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up.
- **Anti-signals (do NOT track)**: definitional questions, one-off lookups, manual document editing.
### 7. `run-track-block` tool — `context` parameter description
### 7. `run-track` tool — `context` parameter description
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. Copilot sees this text as part of the tool's schema.
- **File**: `packages/core/src/application/lib/builtin-tools.ts:1454+` (the `run-track-block` tool definition; the `context` field's `.describe()` block is the mini-prompt).
- **Inputs**: free-form string from Copilot.
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run.
- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-track` tool definition).
- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), `id`, optional `context`.
- **Output**: flows into `triggerTrackUpdate(..., 'manual')``buildMessage` → appended as `**Context:**` in the agent message.
- **Key use case**: backfill a newly-created event-driven track so its target region isn't empty on day 1 — e.g. `"Backfill from gmail_sync/ for the last 90 days about this topic"`.
- **Key use case**: backfill a newly-created event-driven track so its section isn't empty on day 1.
### 8. Calendar sync digest (event payload template)
- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`.
- **File**: `packages/core/src/knowledge/sync_calendar.ts:68+` (`summarizeCalendarSync`). Wrapped by `publishCalendarSyncEvent()` at line ~126.
- **Inputs**: `newEvents`, `updatedEvents`, `deletedEventIds` gathered during a sync.
- **Output**: markdown with counts header, a `## Changed events` section (per-event block: title, ID, time, organizer, location, attendees, truncated description), and a `## Deleted event IDs` section. Capped at ~50 events with a "…and N more" line; descriptions truncated to 500 chars.
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. If the classifier misses obvious matches, this is a place to look.
- **File**: `packages/core/src/knowledge/sync_calendar.ts` (`summarizeCalendarSync`, wrapped by `publishCalendarSyncEvent()`).
- **Output**: markdown with a counts header, `## Changed events` (per-event block: title, ID, time, organizer, location, attendees, truncated description), `## Deleted event IDs`. Capped at ~50 events; descriptions truncated to 500 chars.
- **Why care**: the quality of Pass 1 matching depends on how clear this payload is.
---
@ -307,37 +337,30 @@ Three branches:
| Purpose | File |
|---|---|
| Zod schemas (tracks, schedules, events, Pass1) | `packages/shared/src/track-block.ts` |
| Zod schemas (track, triggers, events, Pass1) | `packages/shared/src/track.ts` |
| IPC channel schemas | `packages/shared/src/ipc.ts` |
| IPC handlers (main process) | `apps/main/src/ipc.ts` |
| File operations (fetch / update / replace / delete) | `packages/core/src/knowledge/track/fileops.ts` |
| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` |
| File operations (fetchAll / fetch / updateTrack / replaceTrackYaml / deleteTrack / readNoteBody / list / setActive) | `packages/core/src/knowledge/track/fileops.ts` |
| Scheduler (cron / window / once) | `packages/core/src/knowledge/track/scheduler.ts` |
| Schedule due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` |
| Trigger due-check helper | `packages/core/src/knowledge/track/schedule-utils.ts` |
| Event producer + consumer loop | `packages/core/src/knowledge/track/events.ts` |
| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/track/routing.ts` |
| Run orchestrator (`triggerTrackUpdate`, `buildMessage`) | `packages/core/src/knowledge/track/runner.ts` |
| Track-run agent definition | `packages/core/src/knowledge/track/run-agent.ts` |
| Track bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/track/bus.ts` |
| Track state type | `packages/core/src/knowledge/track/types.ts` |
| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` |
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
| Copilot skill | `packages/core/src/application/assistant/skills/tracks/skill.ts` |
| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
| Builtin tools (`update-track-content`, `run-track-block`) | `packages/core/src/application/lib/builtin-tools.ts` |
| Tiptap chip (editor node view) | `apps/renderer/src/extensions/track-block.tsx` |
| React modal (all UI mutations) | `apps/renderer/src/components/track-modal.tsx` |
| `run-track` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` |
| Editor toolbar (Radio button → sidebar) | `apps/renderer/src/components/editor-toolbar.tsx` |
| Track sidebar (list + detail view) | `apps/renderer/src/components/track-sidebar.tsx` |
| Status hook (`useTrackStatus`) | `apps/renderer/src/hooks/use-track-status.ts` |
| App-level listeners (modal + Copilot edit) | `apps/renderer/src/App.tsx` |
| CSS | `apps/renderer/src/styles/editor.css`, `apps/renderer/src/styles/track-modal.css` |
| Renderer frontmatter helper (preserves `track:`) | `apps/renderer/src/lib/frontmatter.ts` |
| App-level listeners (sidebar open + Copilot edit) | `apps/renderer/src/App.tsx` |
| CSS (sidebar styles, legacy filename) | `apps/renderer/src/styles/track-modal.css`, `apps/renderer/src/styles/editor.css` |
| Main process startup (schedulers & processors) | `apps/main/src/main.ts` |
---
## Known Follow-ups
- **Tiptap save can still re-serialize a stale node attr.** The chip+modal refactor makes every *intentional* mutation go through IPC, so toggles, raw-YAML edits, and deletes are all safe. But if the backend writes runtime fields (`lastRunAt`, `lastRunSummary`) after the note was loaded, and the user then types anywhere in the note body, Tiptap's markdown serializer writes out the cached (stale) YAML for each track block, clobbering those fields.
- **Cleanest fix**: subscribe to `trackBus` events in the renderer and refresh the corresponding node attr via `updateAttributes` before Tiptap can save.
- **Alternative**: make the markdown serializer pull fresh YAML from disk per track block at write time (async-friendly refactor).
- **Only Gmail + Calendar produce events today.** Granola, Fireflies, and other sync services are plumbed for the pattern but don't emit yet. Adding a producer is a one-liner `createEvent({ source, type, createdAt, payload })` at the right spot in the sync flow.

View file

@ -11,6 +11,9 @@ module.exports = {
icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity',
protocols: [
{ name: 'Rowboat', schemes: ['rowboat'] },
],
extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
},

View file

@ -1,8 +1,24 @@
import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js';
import type { BrowserControlAction, BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js';
import type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js';
import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js';
import { browserViewManager } from './view.js';
import { normalizeNavigationTarget } from './navigation.js';
async function getSuggestedSkills(url: string | undefined): Promise<SuggestedBrowserSkill[] | undefined> {
if (!url) return undefined;
try {
const status = await ensureLoaded();
if (status.status === 'ready' || status.status === 'stale') {
const matched = matchSkillsForUrl(status.index, url);
if (matched.length === 0) return undefined;
return matched.map((e) => ({ id: e.id, title: e.title, path: e.path }));
}
} catch (err) {
console.warn('[browser-control] suggestedSkills lookup failed:', err);
}
return undefined;
}
function buildSuccessResult(
action: BrowserControlAction,
message: string,
@ -52,11 +68,13 @@ export class ElectronBrowserControlService implements IBrowserControlService {
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult(
const suggestedSkills = await getSuggestedSkills(page?.url);
const success = buildSuccessResult(
'new-tab',
target ? `Opened a new tab for ${target}.` : 'Opened a new tab.',
page,
);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'switch-tab': {
@ -99,7 +117,9 @@ export class ElectronBrowserControlService implements IBrowserControlService {
}
await browserViewManager.ensureActiveTabReady(signal);
const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined;
return buildSuccessResult('navigate', `Navigated to ${target}.`, page);
const suggestedSkills = await getSuggestedSkills(page?.url);
const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'back': {
@ -140,7 +160,9 @@ export class ElectronBrowserControlService implements IBrowserControlService {
if (!result.ok || !result.page) {
return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.');
}
return buildSuccessResult('read-page', 'Read the current page.', result.page);
const suggestedSkills = await getSuggestedSkills(result.page.url);
const success = buildSuccessResult('read-page', 'Read the current page.', result.page);
return suggestedSkills ? { ...success, suggestedSkills } : success;
}
case 'click': {

View file

@ -109,19 +109,62 @@ export class BrowserViewManager extends EventEmitter {
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
private snapshotCache = new Map<string, CachedSnapshot>();
private cleanupWindowListeners: (() => void) | null = null;
attach(window: BrowserWindow): void {
this.cleanupWindowListeners?.();
this.cleanupWindowListeners = null;
this.window = window;
window.on('closed', () => {
const hostWebContents = window.webContents;
const resetForHostWindowNavigation = () => {
// Renderer refreshes do not run React unmount cleanup reliably, so the
// native browser view must be detached from the main process side.
this.visible = false;
this.bounds = { x: 0, y: 0, width: 0, height: 0 };
this.syncAttachedView();
};
const handleDidStartLoading = () => {
resetForHostWindowNavigation();
};
const handleRenderProcessGone = () => {
resetForHostWindowNavigation();
};
const handleClosed = () => {
if (this.window !== window) return;
const tabs = [...this.tabs.values()];
this.cleanupWindowListeners = null;
this.window = null;
this.browserSession = null;
this.bounds = { x: 0, y: 0, width: 0, height: 0 };
for (const tab of tabs) {
this.destroyTab(tab);
}
this.tabs.clear();
this.tabOrder = [];
this.activeTabId = null;
this.attachedTabId = null;
this.visible = false;
this.snapshotCache.clear();
});
};
hostWebContents.on('did-start-loading', handleDidStartLoading);
hostWebContents.on('render-process-gone', handleRenderProcessGone);
window.on('closed', handleClosed);
this.cleanupWindowListeners = () => {
if (!hostWebContents.isDestroyed()) {
hostWebContents.removeListener('did-start-loading', handleDidStartLoading);
hostWebContents.removeListener('render-process-gone', handleRenderProcessGone);
}
if (!window.isDestroyed()) {
window.removeListener('closed', handleClosed);
}
};
}
private getSession(): Session {

View file

@ -293,20 +293,6 @@ export function listConnected(): { toolkits: string[] } {
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
}
/**
* Check if Composio should be used for Google services (Gmail, etc.)
*/
export async function useComposioForGoogle(): Promise<{ enabled: boolean }> {
return { enabled: await composioClient.useComposioForGoogle() };
}
/**
* Check if Composio should be used for Google Calendar
*/
export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> {
return { enabled: await composioClient.useComposioForGoogleCalendar() };
}
/**
* List available Composio toolkits filtered to curated list only.
* Return type matches the ZToolkit schema from core/composio/types.ts.

View file

@ -0,0 +1,165 @@
import { BrowserWindow } from "electron";
import path from "node:path";
import fs from "node:fs/promises";
import { WorkDir } from "@x/core/dist/config/config.js";
export const DEEP_LINK_SCHEME = "rowboat";
const URL_PREFIX = `${DEEP_LINK_SCHEME}://`;
const ACTION_HOST = "action";
let pendingUrl: string | null = null;
let mainWindowRef: BrowserWindow | null = null;
export function setMainWindowForDeepLinks(win: BrowserWindow | null): void {
mainWindowRef = win;
}
export function consumePendingDeepLink(): string | null {
const url = pendingUrl;
pendingUrl = null;
return url;
}
export function extractDeepLinkFromArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg;
}
return null;
}
/**
* Dispatch any rowboat:// URL — chooses among action / oauth-completion /
* navigation automatically. Use this from notification click handlers and
* other URL entry points.
*
* OAuth completion (rowboat://oauth/google/done?session=<state>) is handled
* in main, not the renderer, because claiming tokens writes oauth.json and
* triggers sync both main-process concerns.
*/
export function dispatchUrl(url: string): void {
if (parseAction(url)) {
void dispatchAction(url);
} else if (parseOAuthCompletion(url)) {
void dispatchOAuthCompletion(url);
} else {
dispatchDeepLink(url);
}
}
export function dispatchDeepLink(url: string): void {
if (!url.startsWith(URL_PREFIX)) return;
pendingUrl = url;
const win = mainWindowRef;
if (!win || win.isDestroyed()) return;
focusWindow(win);
if (win.webContents.isLoading()) return;
win.webContents.send("app:openUrl", { url });
pendingUrl = null;
}
interface MeetingNotesAction {
type: "take-meeting-notes" | "join-and-take-meeting-notes";
eventId: string;
}
type ParsedAction = MeetingNotesAction;
function parseAction(url: string): ParsedAction | null {
if (!url.startsWith(URL_PREFIX)) return null;
const rest = url.slice(URL_PREFIX.length);
const queryIdx = rest.indexOf("?");
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, "");
if (host !== ACTION_HOST) return null;
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
const type = params.get("type");
if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") {
const eventId = params.get("eventId");
return eventId ? { type, eventId } : null;
}
return null;
}
async function dispatchAction(url: string): Promise<void> {
const parsed = parseAction(url);
if (!parsed) return;
const openMeeting = parsed.type === "join-and-take-meeting-notes";
await handleTakeMeetingNotes(parsed.eventId, openMeeting);
}
async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> {
const win = mainWindowRef;
if (!win || win.isDestroyed()) return;
focusWindow(win);
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
let event: unknown;
try {
const raw = await fs.readFile(filePath, "utf-8");
event = JSON.parse(raw);
} catch (err) {
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
return;
}
const payload = { event, openMeeting };
if (win.webContents.isLoading()) {
win.webContents.once("did-finish-load", () => {
win.webContents.send("app:takeMeetingNotes", payload);
});
return;
}
win.webContents.send("app:takeMeetingNotes", payload);
}
// --- OAuth completion (rowboat-mode Google connect) ---
interface OAuthCompletion {
provider: "google";
state: string;
}
/**
* Match rowboat://oauth/google/done?session=<state>. Returns null for
* anything else including paths with the right shape but wrong provider
* or a missing `session` query param.
*/
function parseOAuthCompletion(url: string): OAuthCompletion | null {
if (!url.startsWith(URL_PREFIX)) return null;
const rest = url.slice(URL_PREFIX.length);
const queryIdx = rest.indexOf("?");
const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
const parts = path.split("/").filter(Boolean);
if (parts.length !== 3 || parts[0] !== "oauth" || parts[2] !== "done") return null;
if (parts[1] !== "google") return null;
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
const state = params.get("session");
return state ? { provider: "google", state } : null;
}
async function dispatchOAuthCompletion(url: string): Promise<void> {
const parsed = parseOAuthCompletion(url);
if (!parsed) return;
// Bring the app to the front so the renderer can react to the
// oauthEvent IPC that completeRowboatGoogleConnect emits.
const win = mainWindowRef;
if (win && !win.isDestroyed()) focusWindow(win);
// Lazy-import to keep deeplink.ts free of OAuth deps and avoid a
// potential circular dep with oauth-handler.ts.
const { completeRowboatGoogleConnect } = await import("./oauth-handler.js");
await completeRowboatGoogleConnect(parsed.state);
}
function focusWindow(win: BrowserWindow): void {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}

View file

@ -34,6 +34,8 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
import { consumePendingDeepLink } from './deeplink.js';
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
@ -50,9 +52,11 @@ import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
import {
fetchYaml,
updateTrackBlock,
replaceTrackBlockYaml,
deleteTrackBlock,
listNotesWithTracks,
setNoteTracksActive,
updateTrack,
replaceTrackYaml,
deleteTrack,
} from '@x/core/dist/knowledge/track/fileops.js';
import { browserIpcHandlers } from './browser/ipc.js';
@ -133,6 +137,14 @@ function resolveShellPath(filePath: string): string {
return workspace.resolveWorkspacePath(filePath);
}
function toKnowledgeTrackPath(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized.startsWith('knowledge/')) {
throw new Error('Track note path must be within knowledge/')
}
return normalized.slice('knowledge/'.length)
}
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -417,6 +429,9 @@ export function setupIpcHandlers() {
// args is null for this channel (no request payload)
return getVersions();
},
'app:consumePendingDeepLink': async () => {
return { url: consumePendingDeepLink() };
},
'analytics:bootstrap': async () => {
return {
installationId: getInstallationId(),
@ -608,11 +623,8 @@ export function setupIpcHandlers() {
'composio:list-toolkits': async () => {
return composioHandler.listToolkits();
},
'composio:use-composio-for-google': async () => {
return composioHandler.useComposioForGoogle();
},
'composio:use-composio-for-google-calendar': async () => {
return composioHandler.useComposioForGoogleCalendar();
'migration:check-composio-google': async () => {
return qualifyAndDisconnectComposioGoogle();
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
@ -673,6 +685,19 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size };
},
'dialog:openDirectory': async (event, args) => {
const win = BrowserWindow.fromWebContents(event.sender);
const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir();
const result = await dialog.showOpenDialog(win!, {
title: args.title ?? 'Choose work directory',
defaultPath,
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return { path: null };
}
return { path: result.filePaths[0] ?? null };
},
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);
@ -790,12 +815,12 @@ export function setupIpcHandlers() {
},
// Track handlers
'track:run': async (_event, args) => {
const result = await triggerTrackUpdate(args.trackId, args.filePath);
const result = await triggerTrackUpdate(args.id, args.filePath);
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
},
'track:get': async (_event, args) => {
try {
const yaml = await fetchYaml(args.filePath, args.trackId);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track not found' };
return { success: true, yaml };
} catch (err) {
@ -804,8 +829,8 @@ export function setupIpcHandlers() {
},
'track:update': async (_event, args) => {
try {
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.trackId);
await updateTrack(args.filePath, args.id, args.updates as Record<string, unknown>);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track vanished after update' };
return { success: true, yaml };
} catch (err) {
@ -814,8 +839,8 @@ export function setupIpcHandlers() {
},
'track:replaceYaml': async (_event, args) => {
try {
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
const yaml = await fetchYaml(args.filePath, args.trackId);
await replaceTrackYaml(args.filePath, args.id, args.yaml);
const yaml = await fetchYaml(args.filePath, args.id);
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
return { success: true, yaml };
} catch (err) {
@ -824,12 +849,25 @@ export function setupIpcHandlers() {
},
'track:delete': async (_event, args) => {
try {
await deleteTrackBlock(args.filePath, args.trackId);
await deleteTrack(args.filePath, args.id);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:setNoteActive': async (_event, args) => {
try {
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
if (!note) return { success: false, error: 'No tracks found in note' };
return { success: true, note };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:listNotes': async () => {
const notes = await listNotesWithTracks();
return { notes };
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();

View file

@ -23,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
@ -34,10 +35,17 @@ import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import { registerBrowserControlService } from "@x/core/dist/di/container.js";
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
import {
DEEP_LINK_SCHEME,
dispatchUrl,
extractDeepLinkFromArgv,
setMainWindowForDeepLinks,
} from "./deeplink.js";
const execAsync = promisify(exec);
@ -47,6 +55,44 @@ const __dirname = dirname(__filename);
// run this as early in the main process as possible
if (started) app.quit();
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
// back into the existing process via the 'second-instance' event.
if (!app.requestSingleInstanceLock()) {
console.error('[Main] Another Rowboat instance is already running; exiting this process.');
app.quit();
process.exit(0);
}
// Register as the OS handler for rowboat:// URLs.
// In dev, point at the right argv so the OS can re-invoke us correctly.
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME);
}
// First-launch URL on Windows/Linux comes through argv.
{
const initialUrl = extractDeepLinkFromArgv(process.argv);
if (initialUrl) dispatchUrl(initialUrl);
}
// macOS sends URLs via 'open-url' (both first launch and while running).
app.on("open-url", (event, url) => {
event.preventDefault();
dispatchUrl(url);
});
// Subsequent launches on Windows/Linux land here via the single-instance lock.
app.on("second-instance", (_event, argv) => {
const url = extractDeepLinkFromArgv(argv);
if (url) dispatchUrl(url);
});
// Fix PATH for packaged Electron apps on macOS/Linux.
// Packaged apps inherit a minimal environment that doesn't include paths from
// the user's shell profile (such as those provided by nvm, Homebrew, etc.).
@ -67,7 +113,9 @@ function initializeExecutionEnvironment(): void {
).trim();
const env = JSON.parse(stdout) as Record<string, string>;
process.env = { ...env, ...process.env };
// Let the user's shell environment win for overlapping keys like PATH.
// Finder/launched GUI apps on macOS often start with a stripped PATH.
process.env = { ...process.env, ...env };
} catch (error) {
console.error('Failed to load shell environment', error);
}
@ -165,6 +213,9 @@ function createWindow() {
configureSessionPermissions(session.defaultSession);
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
setMainWindowForDeepLinks(win);
win.on("closed", () => setMainWindowForDeepLinks(null));
// Show window when content is ready to prevent blank screen
win.once("ready-to-show", () => {
win.maximize();
@ -240,6 +291,7 @@ app.whenReady().then(async () => {
});
registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService());
setupIpcHandlers();
setupBrowserEventForwarding();
@ -298,6 +350,9 @@ app.whenReady().then(async () => {
// start agent notes learning service
initAgentNotes();
// start calendar meeting notification service (fires 1-minute warnings)
initCalendarNotifications();
// start chrome extension sync server
initChromeSync();

View file

@ -0,0 +1,84 @@
import { BrowserWindow, Notification, shell } from "electron";
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
import { dispatchUrl } from "../deeplink.js";
const HTTP_URL = /^https?:\/\//i;
const ROWBOAT_URL = /^rowboat:\/\//i;
export class ElectronNotificationService implements INotificationService {
// Holds strong references to active Notification instances so the GC can't
// collect them while they're still visible — without this, the click handler
// gets dropped and macOS clicks just focus the app silently.
private active = new Set<Notification>();
isSupported(): boolean {
return Notification.isSupported();
}
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
// Build the actions array AND a parallel index → link map.
// macOS shows actions[0] inline (Banner) or all of them (Alert);
// additional ones live behind the chevron menu.
const actionDefs: Electron.NotificationConstructorOptions["actions"] = [];
const actionLinks: string[] = [];
const primaryLabel = actionLabel?.trim();
if (link && primaryLabel) {
actionDefs!.push({ type: "button", text: primaryLabel });
actionLinks.push(link);
}
if (secondaryActions) {
for (const sa of secondaryActions) {
actionDefs!.push({ type: "button", text: sa.label });
actionLinks.push(sa.link);
}
}
const notification = new Notification({
title,
body: message,
actions: actionDefs,
});
this.active.add(notification);
const release = () => { this.active.delete(notification); };
const openLink = (target: string | undefined) => {
if (target && ROWBOAT_URL.test(target)) {
dispatchUrl(target);
} else if (target && HTTP_URL.test(target)) {
shell.openExternal(target).catch((err) => {
console.error("[notification] failed to open link:", err);
});
} else {
this.focusMainWindow();
}
release();
};
// Body click: always opens the primary `link` (or focuses the app if none).
notification.on("click", () => openLink(link));
// Action button click: dispatch by index into the actions array.
notification.on("action", (_event, index) => {
if (index >= 0 && index < actionLinks.length) {
openLink(actionLinks[index]);
} else {
openLink(undefined);
}
});
notification.on("close", release);
notification.on("failed", release);
notification.show();
}
private focusMainWindow(): void {
const [win] = BrowserWindow.getAllWindows();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
}

View file

@ -13,6 +13,9 @@ import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync
import { emitOAuthEvent } from './ipc.js';
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
@ -201,6 +204,23 @@ export async function connectProvider(provider: string, credentials?: { clientId
if (provider === 'google') {
if (!credentials?.clientId || !credentials?.clientSecret) {
// No credentials → rowboat mode if the user is signed in to Rowboat
// (we use the company-owned Google client via the api + webapp).
// Otherwise it's BYOK with missing creds → error.
if (await isSignedIn()) {
try {
const webappUrl = await getWebappUrl();
await shell.openExternal(`${webappUrl}/oauth/google/start`);
console.log('[OAuth] Started rowboat-mode Google connect (browser opened to webapp)');
return { success: true };
} catch (error) {
console.error('[OAuth] Failed to start rowboat-mode Google connect:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to open browser',
};
}
}
return { success: false, error: 'Google client ID and client secret are required to connect.' };
}
}
@ -257,11 +277,15 @@ export async function connectProvider(provider: string, credentials?: { clientId
state
);
// Save tokens and credentials
// Save tokens and credentials. For Google, BYOK is the only path
// that reaches this token exchange (rowboat path returns above
// before any local server runs); stamp mode: 'byok' so a future
// refresh / reconnect can't get confused with a rowboat entry.
console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.upsert(provider, {
tokens,
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
...(provider === 'google' ? { mode: 'byok' as const } : {}),
error: null,
});
@ -358,12 +382,65 @@ export async function connectProvider(provider: string, credentials?: { clientId
}
}
/**
* Complete a rowboat-mode Google connect: claim the tokens parked under
* `state` by the webapp callback, persist them locally, and trigger sync.
*
* Called by the deep-link dispatcher (deeplink.ts) when the OS hands us a
* rowboat://oauth/google/done?session=<state> URL.
*/
export async function completeRowboatGoogleConnect(state: string): Promise<void> {
try {
console.log('[OAuth] Claiming rowboat-mode Google tokens...');
const tokens = await claimTokensViaBackend(state);
const oauthRepo = getOAuthRepo();
await oauthRepo.upsert('google', {
tokens,
mode: 'rowboat',
// Explicitly null these — no client_id/secret on the desktop in this mode.
clientId: null,
clientSecret: null,
error: null,
});
triggerGmailSync();
triggerCalendarSync();
emitOAuthEvent({ provider: 'google', success: true });
console.log('[OAuth] Rowboat-mode Google connect complete');
} catch (error) {
console.error('[OAuth] Failed to complete rowboat-mode Google connect:', error);
emitOAuthEvent({
provider: 'google',
success: false,
error: error instanceof Error ? error.message : 'Failed to claim Google tokens',
});
}
}
/**
* Disconnect a provider (clear tokens)
*/
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
try {
const oauthRepo = getOAuthRepo();
// For rowboat-mode Google, best-effort revoke at Google before clearing
// local state. Google's revoke endpoint accepts an unauthenticated POST
// with the access_token; failure is logged but doesn't block disconnect.
if (provider === 'google') {
const connection = await oauthRepo.read(provider);
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
try {
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
const res = await fetch(revokeUrl, { method: 'POST' });
if (!res.ok) {
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
}
} catch (error) {
console.warn('[OAuth] Google revoke failed; continuing with local disconnect:', error);
}
}
}
await oauthRepo.delete(provider);
if (provider === 'rowboat') {
analyticsCapture('user_signed_out');

View file

@ -25,15 +25,16 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-image": "^3.16.0",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-task-item": "^3.15.3",
"@tiptap/extension-task-list": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"@tiptap/core": "3.22.4",
"@tiptap/extension-image": "3.22.4",
"@tiptap/extension-link": "3.22.4",
"@tiptap/extension-placeholder": "3.22.4",
"@tiptap/extension-table": "3.22.4",
"@tiptap/extension-task-item": "3.22.4",
"@tiptap/extension-task-list": "3.22.4",
"@tiptap/pm": "3.22.4",
"@tiptap/react": "3.22.4",
"@tiptap/starter-kit": "3.22.4",
"@x/preload": "workspace:*",
"@x/shared": "workspace:*",
"ai": "^5.0.117",

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react'
import { workspace } from '@x/shared';
import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { BackgroundAgentsView } from '@/components/background-agents-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@ -35,11 +36,12 @@ import {
import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText';
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { TerminalOutput } from '@/components/terminal-output';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
@ -54,9 +56,11 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackModal } from '@/components/track-modal'
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackSidebar } from '@/components/track-sidebar'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
import { VersionHistoryPanel } from '@/components/version-history-panel'
@ -76,10 +80,12 @@ import {
getAppActionCardData,
getComposioConnectCardData,
getToolDisplayName,
groupConversationItems,
inferRunTitleFromMessage,
isChatMessage,
isErrorMessage,
isToolCall,
isToolGroup,
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
@ -116,6 +122,31 @@ function SmoothStreamingMessage({ text, components }: { text: string; components
return <MessageResponse components={components}>{smoothText}</MessageResponse>
}
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLPreElement>(null)
const stickToBottom = useRef(true)
useLayoutEffect(() => {
const el = ref.current
if (el && stickToBottom.current) {
el.scrollTop = el.scrollHeight
}
}, [children])
const handleScroll = useCallback(() => {
const el = ref.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
stickToBottom.current = atBottom
}, [])
return (
<pre ref={ref} onScroll={handleScroll} className={className}>
{children}
</pre>
)
}
const DEFAULT_SIDEBAR_WIDTH = 256
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
const graphPalette = [
@ -138,6 +169,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) =>
@ -267,6 +299,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
@ -318,11 +351,29 @@ const buildSuggestedTopicExplorePrompt = ({
'Treat a clear confirmation from me as explicit approval to proceed.',
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'Add a track to the note (a `track:` entry in its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
].join('\n')
}
const buildBackgroundAgentSetupPrompt = () => [
'Help me set up a background agent.',
'In this flow, a background agent is the same thing as a track on a note (a `track:` entry in the note frontmatter). Do not tell me they are separate concepts.',
'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.',
'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.',
'Start with a short, plain-English explanation of what a background agent is.',
'Do not make the explanation too terse.',
'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.',
'Do not mention triggers, event-based vs schedule-based behavior, tracks, skills, note paths, or other internal implementation details unless I ask.',
'In the first reply, tell me that you will create this in my Tasks folder by default.',
'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.',
'Then ask only what I want it to monitor or update and how often I want it to run.',
'Keep it concise and friendly, but not abrupt.',
'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.',
'Do not create or modify anything yet.',
'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.',
].join('\n')
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
@ -504,6 +555,7 @@ type ViewState =
| { type: 'graph' }
| { type: 'task'; name: string }
| { type: 'suggested-topics' }
| { type: 'background-agents' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@ -513,6 +565,48 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
return true // both graph
}
/**
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|background-agents>&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics
* background-agents: ?type=background-agents
*/
function parseDeepLink(input: string): ViewState | null {
const SCHEME = 'rowboat://'
if (!input.startsWith(SCHEME)) return null
const rest = input.slice(SCHEME.length)
const queryIdx = rest.indexOf('?')
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '')
if (host !== 'open') return null
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '')
switch (params.get('type')) {
case 'file': {
const path = params.get('path')
return path ? { type: 'file', path } : null
}
case 'chat':
return { type: 'chat', runId: params.get('runId') || null }
case 'graph':
return { type: 'graph' }
case 'task': {
const name = params.get('name')
return name ? { type: 'task', name } : null
}
case 'suggested-topics':
return { type: 'suggested-topics' }
case 'background-agents':
return { type: 'background-agents' }
default:
return null
}
}
/** Sidebar toggle (fixed position, top-left) */
function FixedSidebarToggle({
leftInsetPx,
@ -613,7 +707,13 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null)
const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{
path: string | null
graph: boolean
suggestedTopics: boolean
backgroundAgents: boolean
} | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
nodes: [],
@ -738,6 +838,30 @@ function App() {
return cleanup
}, [refreshVoiceAvailability])
// One-time Composio→native Google migration check. Runs on mount and again
// after the user signs in to Rowboat (so we catch users who weren't signed
// in at startup). The IPC is idempotent — once `dismissed_at` is set on the
// main side, every subsequent call returns `{shouldShow: false}`.
useEffect(() => {
const run = async () => {
try {
const result = await window.ipc.invoke('migration:check-composio-google', null)
if (result.shouldShow) {
setShowComposioGoogleMigration(true)
}
} catch (error) {
console.error('[migration] check-composio-google failed:', error)
}
}
void run()
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider === 'rowboat' && event.success) {
void run()
}
})
return cleanup
}, [])
const handleStartRecording = useCallback(() => {
setIsRecording(true)
isRecordingRef.current = true
@ -750,7 +874,6 @@ function App() {
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
// queued across the new-chat-tab state flush before submit fires.
const editorRefsByTabId = useRef<Map<string, MarkdownEditorHandle>>(new Map())
const [paletteContext, setPaletteContext] = useState<CommandPaletteContext | null>(null)
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
const handleSubmitRecording = useCallback(() => {
@ -910,6 +1033,7 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View'
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -991,6 +1115,9 @@ function App() {
// Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false)
// One-time Composio→native Google migration modal
const [showComposioGoogleMigration, setShowComposioGoogleMigration] = useState(false)
// Search state
const [isSearchOpen, setIsSearchOpen] = useState(false)
@ -1983,6 +2110,10 @@ function App() {
return next
})
if (event.toolCallId && event.toolName !== 'executeCommand') {
setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false)
}
// Handle app-navigation tool results — trigger UI side effects
if (event.toolName === 'app-navigation') {
const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined
@ -1994,6 +2125,23 @@ function App() {
break
}
case 'tool-output-stream': {
if (!isActiveRun) return
setConversation(prev => prev.map(item => {
if (
isToolCall(item)
&& item.id === event.toolCallId
) {
if (!item.streamingOutput) {
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
}
return { ...item, streamingOutput: (item.streamingOutput ?? '') + event.output }
}
return item
}))
break
}
case 'tool-permission-request': {
if (!isActiveRun) return
const key = event.toolCall.toolCallId
@ -2358,6 +2506,10 @@ function App() {
}
}, [runId])
const dismissBrowserOverlay = useCallback(() => {
setIsBrowserOpen(false)
}, [])
const handleNewChat = useCallback(() => {
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
loadRunRequestIdRef.current += 1
@ -2581,10 +2733,13 @@ function App() {
// File tab operations
const openFileInNewTab = useCallback((path: string) => {
dismissBrowserOverlay()
const existingTab = fileTabs.find(t => t.path === path)
if (existingTab) {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(path)
return
}
@ -2592,12 +2747,15 @@ function App() {
setFileTabs(prev => [...prev, { id, path }])
setActiveFileTabId(id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(path)
}, [fileTabs])
}, [fileTabs, dismissBrowserOverlay])
const switchFileTab = useCallback((tabId: string) => {
const tab = fileTabs.find(t => t.id === tabId)
if (!tab) return
dismissBrowserOverlay()
setActiveFileTabId(tabId)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
@ -2609,18 +2767,28 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
return
}
if (isBackgroundAgentsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
return
}
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized])
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId)
@ -2647,6 +2815,7 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@ -2660,13 +2829,21 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
} else if (isBackgroundAgentsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
} else {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(newActiveTab.path)
}
}
@ -2694,10 +2871,16 @@ function App() {
setChatTabs(prev => [...prev, { id, runId: null }])
setActiveChatTabId(id)
}
dismissBrowserOverlay()
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
backgroundAgents: isBackgroundAgentsOpen,
})
} else {
setExpandedFrom(null)
}
@ -2705,7 +2888,8 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
setIsBackgroundAgentsOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
@ -2748,8 +2932,7 @@ function App() {
setPendingPaletteSubmit(null)
}, [pendingPaletteSubmit])
// Listener for track-block "Edit with Copilot" events
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
// Listener for "Edit with Copilot" events from the track sidebar.
useEffect(() => {
const handler = (e: Event) => {
const ev = e as CustomEvent<{
@ -2820,26 +3003,40 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
backgroundAgents: isBackgroundAgentsOpen,
})
}
dismissBrowserOverlay()
setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
setIsBackgroundAgentsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
if (expandedFrom.graph) {
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
} else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
} else if (expandedFrom.backgroundAgents) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
} else if (expandedFrom.path) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
@ -2849,11 +3046,12 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isBackgroundAgentsOpen) return { type: 'background-agents' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
}, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@ -2910,6 +3108,17 @@ function App() {
setActiveFileTabId(id)
}, [fileTabs])
const ensureBackgroundAgentsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
@ -2919,6 +3128,7 @@ function App() {
// visible in the middle pane.
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
@ -2933,6 +3143,7 @@ function App() {
setSelectedPath(null)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -2945,6 +3156,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -2957,17 +3169,29 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
ensureSuggestedTopicsFileTab()
return
case 'chat':
case 'background-agents':
setSelectedPath(null)
setIsGraphOpen(false)
// Don't touch isBrowserOpen here — chat navigation should land in
// the right sidebar when the browser overlay is active.
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
ensureBackgroundAgentsFileTab()
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
@ -2975,11 +3199,16 @@ function App() {
}
return
}
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
}, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
if (viewStatesEqual(current, nextView)) return
if (viewStatesEqual(current, nextView)) {
if (isBrowserOpen) {
dismissBrowserOverlay()
}
return
}
cancelRecordingIfActive()
const nextHistory = {
@ -2988,7 +3217,7 @@ function App() {
}
setHistory(nextHistory)
await applyViewState(nextView)
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory])
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay])
const navigateBack = useCallback(async () => {
const { back, forward } = historyRef.current
@ -3048,6 +3277,58 @@ function App() {
void navigateToView({ type: 'file', path })
}, [navigateToView])
// Deep-link handler kept in a ref so the useEffect below can register the
// IPC listener (and run the one-time pending-link drain) just once on mount,
// rather than re-running on every navigation when navigateToView's identity
// changes.
const navigateToViewRef = useRef(navigateToView)
useEffect(() => { navigateToViewRef.current = navigateToView }, [navigateToView])
useEffect(() => {
const handle = (url: string) => {
const view = parseDeepLink(url)
if (view) void navigateToViewRef.current(view)
}
void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => {
if (url) handle(url)
})
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
}, [])
// Triggered by main when the user clicks a calendar-meeting notification.
// Reuses the same flow as the in-app "Join meeting & take notes" button.
// When `openMeeting` is true, also opens the meeting URL in the system browser.
useEffect(() => {
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => {
const e = event as {
summary?: string
start?: { dateTime?: string; date?: string; timeZone?: string }
end?: { dateTime?: string; date?: string; timeZone?: string }
location?: string
htmlLink?: string
hangoutLink?: string
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
}
if (!e || typeof e !== 'object') return
const conferenceLink = extractConferenceLink(e as Record<string, unknown>)
if (openMeeting && conferenceLink) {
window.open(conferenceLink, '_blank')
} else if (openMeeting) {
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e)
}
window.__pendingCalendarEvent = {
summary: e.summary,
start: e.start,
end: e.end,
location: e.location,
htmlLink: e.htmlLink,
conferenceLink,
source: 'calendar-sync',
}
window.dispatchEvent(new Event('calendar-block:join-meeting'))
})
}, [])
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
}, [])
@ -3240,7 +3521,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3256,16 +3537,11 @@ function App() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
// Keyboard shortcut: Cmd+K / Ctrl+K opens the unified palette (defaults to Chat mode).
// If an editor tab is currently active, capture cursor context so Chat mode shows the
// note + line as a removable chip.
// Keyboard shortcut: Cmd+K / Ctrl+K opens the search palette (search-only).
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
const activeId = activeFileTabIdRef.current
const handle = activeId ? editorRefsByTabId.current.get(activeId) : null
setPaletteContext(handle?.getCursorContext() ?? null)
setIsSearchOpen(true)
}
}
@ -3318,15 +3594,17 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen)
const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH
: isSuggestedTopicsOpen
? SUGGESTED_TOPICS_TAB_PATH
: isBackgroundAgentsOpen
? BACKGROUND_AGENTS_TAB_PATH
: selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
@ -3381,7 +3659,7 @@ function App() {
}
document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
@ -3406,7 +3684,7 @@ function App() {
}),
},
}))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -3528,14 +3806,14 @@ function App() {
},
openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' })
},
openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -4076,7 +4354,13 @@ function App() {
state={toToolState(item.status)}
/>
<ToolContent>
{item.streamingOutput ? (
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
<TerminalOutput raw={item.streamingOutput} />
</AutoScrollPre>
) : (
<ToolTabbedContent input={input} output={output} errorText={errorText} />
)}
</ToolContent>
</Tool>
)
@ -4119,7 +4403,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
@ -4136,7 +4420,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen) {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
}}>
@ -4169,7 +4453,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4180,7 +4464,7 @@ function App() {
return
}
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad)
return
@ -4204,14 +4488,14 @@ function App() {
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat()
} else {
@ -4235,10 +4519,14 @@ function App() {
meetingSummarizing={meetingSummarizing}
meetingAvailable={voiceAvailable}
onToggleMeeting={() => { void handleToggleMeeting() }}
isSearchOpen={isSearchOpen}
isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'}
isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
isBackgroundAgentsOpen={isBackgroundAgentsOpen}
onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })}
/>
<SidebarInset
className={cn(
@ -4258,7 +4546,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -4266,7 +4554,7 @@ function App() {
getTabId={(t) => t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : (
<TabBar
@ -4319,7 +4607,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4334,7 +4622,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4349,7 +4637,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4369,7 +4657,10 @@ function App() {
</ContentHeader>
{isBrowserOpen ? (
<BrowserPane onClose={handleCloseBrowser} />
<BrowserPane
onClose={handleCloseBrowser}
forceHidden={isSearchOpen || showMeetingPermissions}
/>
) : isSuggestedTopicsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<SuggestedTopicsView
@ -4379,6 +4670,15 @@ function App() {
}}
/>
</div>
) : isBackgroundAgentsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BackgroundAgentsView
onOpenNote={(path) => navigateToFile(path)}
onAddNewBackgroundAgent={() => {
submitFromPalette(buildBackgroundAgentSetupPrompt(), null)
}}
/>
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView
@ -4578,7 +4878,20 @@ function App() {
</ConversationEmptyState>
) : (
<>
{tabState.conversation.map(item => {
{groupConversationItems(
tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id)
).map(item => {
if (isToolGroup(item)) {
return (
<ToolGroupComponent
key={item.groupId}
group={item}
isToolOpen={(toolId) => isToolOpenForTab(tab.id, toolId)}
onToolOpenChange={(toolId, open) => setToolOpenForTab(tab.id, toolId, open)}
/>
)
}
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item)) {
const permRequest = tabState.allPermissionRequests.get(item.id)
@ -4743,6 +5056,7 @@ function App() {
onToolOpenChangeForTab={setToolOpenForTab}
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
onActivate={() => setActiveShortcutPane('right')}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
isRecording={isRecording}
recordingText={voice.interimText}
recordingState={voice.state === 'connecting' ? 'connecting' : 'listening'}
@ -4769,16 +5083,25 @@ function App() {
onOpenChange={setIsSearchOpen}
onSelectFile={navigateToFile}
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
initialContext={paletteContext}
onChatSubmit={submitFromPalette}
/>
</SidebarSectionProvider>
<Toaster />
<TrackModal />
<TrackSidebar />
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}
/>
<ComposioGoogleMigrationModal
open={showComposioGoogleMigration}
onOpenChange={setShowComposioGoogleMigration}
onReconnect={() => {
// Trigger the rowboat-mode Google connect flow. With no credentials
// and the user signed in to Rowboat, the main process opens the
// webapp `/oauth/google/start` URL. The deep link returns and
// completeRowboatGoogleConnect persists the tokens.
void window.ipc.invoke('oauth:connect', { provider: 'google' })
}}
/>
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
<DialogContent showCloseButton={false}>
<DialogHeader>

View file

@ -17,6 +17,9 @@ import {
XCircleIcon,
} from "lucide-react";
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
@ -224,3 +227,89 @@ export const ToolTabbedContent = ({
</div>
);
};
export type ToolGroupProps = {
group: ToolGroupType
isToolOpen: (toolId: string) => boolean
onToolOpenChange: (toolId: string, open: boolean) => void
}
const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => {
if (tools.some(t => t.status === 'error')) return 'output-error'
if (tools.some(t => t.status === 'running')) return 'input-available'
if (tools.some(t => t.status === 'pending')) return 'input-streaming'
return 'output-available'
}
export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => {
const [open, setOpen] = useState(false)
const state = getGroupState(group.items)
const isCompleted = state === 'output-available' || state === 'output-error'
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
const currentTool = runningTool ?? group.items[group.items.length - 1]
const summary = isCompleted
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-md border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={summary}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
title={summary}
>
{summary}
</motion.span>
</AnimatePresence>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{getStatusBadge(state)}
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<div className="flex flex-col gap-2 p-2">
{group.items.map((tool) => {
const toolState = toToolState(tool.status)
const isOpen = isToolOpen(tool.id)
return (
<Tool
key={tool.id}
open={isOpen}
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
className="mb-0 border-border/60"
>
<ToolHeader
title={getToolDisplayName(tool)}
type={`tool-${tool.name}`}
state={toolState}
/>
<ToolContent>
<ToolTabbedContent
input={tool.input as ToolUIPart["input"]}
output={tool.result as ToolUIPart["output"]}
errorText={tool.status === 'error' ? 'Tool error' : undefined}
/>
</ToolContent>
</Tool>
)
})}
</div>
</CollapsibleContent>
</Collapsible>
)
}

View file

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

View file

@ -49,6 +49,7 @@ const BLOCKING_OVERLAY_SLOTS = new Set([
interface BrowserPaneProps {
onClose: () => void
forceHidden?: boolean
}
const getActiveTab = (state: BrowserState) =>
@ -85,7 +86,7 @@ const getBrowserTabTitle = (tab: BrowserTabState) => {
}
}
export function BrowserPane({ onClose }: BrowserPaneProps) {
export function BrowserPane({ onClose, forceHidden = false }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
@ -175,6 +176,12 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
}, [])
const syncView = useCallback(() => {
if (forceHidden) {
lastBoundsRef.current = null
setViewVisible(false)
return null
}
const doc = viewportRef.current?.ownerDocument
if (doc && hasBlockingOverlay(doc)) {
lastBoundsRef.current = null
@ -191,7 +198,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
pushBounds(bounds)
setViewVisible(true)
return bounds
}, [measureBounds, pushBounds, setViewVisible])
}, [forceHidden, measureBounds, pushBounds, setViewVisible])
useEffect(() => {
syncView()

View file

@ -10,8 +10,10 @@ import {
FileSpreadsheet,
FileText,
FileVideo,
FolderCog,
Globe,
Headphones,
ImagePlus,
LoaderIcon,
Mic,
Plus,
@ -23,8 +25,10 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@ -169,6 +173,7 @@ function ChatInputInner({
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [workDir, setWorkDir] = useState<string | null>(null)
// When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => {
@ -251,6 +256,55 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
// Load currently configured work directory
const loadWorkDir = useCallback(async () => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' })
const parsed = JSON.parse(result.data)
const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
setWorkDir(value || null)
} catch {
setWorkDir(null)
}
}, [])
useEffect(() => {
loadWorkDir()
}, [isActive, loadWorkDir])
const handleSetWorkDir = useCallback(async () => {
try {
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
title: 'Choose work directory',
defaultPath: workDir ?? undefined,
})
if (!chosen) return
await window.ipc.invoke('workspace:writeFile', {
path: 'config/workdir.json',
data: JSON.stringify({ path: chosen }, null, 2),
})
setWorkDir(chosen)
toast.success(`Work directory set: ${chosen}`)
} catch (err) {
console.error('Failed to set work directory', err)
toast.error('Failed to set work directory')
}
}, [workDir])
const handleClearWorkDir = useCallback(async () => {
try {
await window.ipc.invoke('workspace:writeFile', {
path: 'config/workdir.json',
data: JSON.stringify({}, null, 2),
})
setWorkDir(null)
toast.success('Work directory cleared')
} catch (err) {
console.error('Failed to clear work directory', err)
toast.error('Failed to clear work directory')
}
}, [])
// Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
@ -484,14 +538,53 @@ function ChatInputInner({
/>
</div>
<div className="flex items-center gap-2 px-4 pb-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Attach files"
aria-label="Add"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
<ImagePlus className="size-4" />
<span>Add files or photos</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
<FolderCog className="size-4" />
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
</DropdownMenuItem>
{workDir && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}>
<X className="size-4" />
<span>Clear work directory</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{workDir && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleSetWorkDir}
className="flex h-7 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<FolderCog className="h-3.5 w-3.5" />
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
Work directory: {workDir}
</TooltipContent>
</Tooltip>
)}
{searchAvailable && (
searchEnabled ? (
<button

View file

@ -16,10 +16,11 @@ import {
MessageResponse,
} from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { TerminalOutput } from '@/components/terminal-output'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
@ -30,6 +31,7 @@ import remarkBreaks from 'remark-breaks'
import { TabBar, type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { useSidebar } from '@/components/ui/sidebar'
import { wikiLabel } from '@/lib/wiki-links'
import {
type ChatViewportAnchorState,
@ -40,9 +42,11 @@ import {
getWebSearchCardData,
getComposioConnectCardData,
getToolDisplayName,
groupConversationItems,
isChatMessage,
isErrorMessage,
isToolCall,
isToolGroup,
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
@ -56,6 +60,31 @@ const streamdownComponents = { pre: MarkdownPreOverride }
// into <br> so typed line breaks are preserved without requiring blank lines.
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLPreElement>(null)
const stickToBottom = useRef(true)
useEffect(() => {
const el = ref.current
if (el && stickToBottom.current) {
el.scrollTop = el.scrollHeight
}
}, [children])
const handleScroll = useCallback(() => {
const el = ref.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
stickToBottom.current = atBottom
}, [])
return (
<pre ref={ref} onScroll={handleScroll} className={className}>
{children}
</pre>
)
}
/* ─── Billing error helpers ─── */
const BILLING_ERROR_PATTERNS = [
@ -175,6 +204,7 @@ interface ChatSidebarProps {
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
onOpenKnowledgeFile?: (path: string) => void
onActivate?: () => void
collapsedLeftPaddingPx?: number
// Voice / TTS props
isRecording?: boolean
recordingText?: string
@ -229,6 +259,7 @@ export function ChatSidebar({
onToolOpenChangeForTab,
onOpenKnowledgeFile,
onActivate,
collapsedLeftPaddingPx = 196,
isRecording,
recordingText,
recordingState,
@ -243,6 +274,7 @@ export function ChatSidebar({
onTtsModeChange,
onComposioConnected,
}: ChatSidebarProps) {
const { state: sidebarState } = useSidebar()
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
const [showContent, setShowContent] = useState(isOpen)
@ -446,7 +478,13 @@ export function ChatSidebar({
>
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
{item.streamingOutput ? (
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
<TerminalOutput raw={item.streamingOutput} />
</AutoScrollPre>
) : (
<ToolTabbedContent input={input} output={output} errorText={errorText} />
)}
</ToolContent>
</Tool>
)
@ -517,7 +555,14 @@ export function ChatSidebar({
{showContent && (
<>
<header className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar">
<header
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar"
style={{
paddingLeft: isMaximized && sidebarState === 'collapsed' ? collapsedLeftPaddingPx : undefined,
paddingRight: isMaximized ? 12 : undefined,
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
}}
>
<TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
@ -591,7 +636,20 @@ export function ChatSidebar({
</ConversationEmptyState>
) : (
<>
{tabState.conversation.map((item) => {
{groupConversationItems(
tabState.conversation,
(id) => !!tabState.allPermissionRequests.get(id)
).map((item) => {
if (isToolGroup(item)) {
return (
<ToolGroupComponent
key={item.groupId}
group={item}
isToolOpen={(toolId) => isToolOpenForTab?.(tab.id, toolId) ?? false}
onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)}
/>
)
}
const rendered = renderConversationItem(item, tab.id)
if (isToolCall(item) && onPermissionResponse) {
const permRequest = tabState.allPermissionRequests.get(item.id)

View file

@ -0,0 +1,74 @@
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
interface ComposioGoogleMigrationModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onReconnect: () => void
}
/**
* One-time modal shown to signed-in users who had Gmail/Calendar connected
* via Composio before the native rowboat-mode OAuth flow shipped. By the
* time this opens, the Composio Google accounts have already been
* disconnected (fire-and-forget, on the qualification IPC) the modal
* just explains what happened and offers a one-click reconnect.
*
* Both buttons close the modal. The qualification IPC marks the migration
* as dismissed before showing this, so neither button needs a follow-up
* IPC of its own.
*/
export function ComposioGoogleMigrationModal({
open,
onOpenChange,
onReconnect,
}: ComposioGoogleMigrationModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
<div className="p-6 pb-0">
<DialogHeader className="space-y-1.5">
<DialogTitle className="text-lg font-semibold">
Reconnect Google to resume syncing
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 text-sm leading-relaxed">
<p>
Knowledge graph syncing for Gmail and Calendar now uses a
direct Google connection. Reconnect to resume. Your existing
emails and events stay where they are.
</p>
</div>
</DialogDescription>
</DialogHeader>
</div>
<div className="flex justify-end gap-2 px-6 py-4 mt-6 border-t bg-muted/30">
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
>
I&apos;ll do this later
</Button>
<Button
size="sm"
onClick={() => {
onReconnect()
onOpenChange(false)
}}
>
Reconnect Google
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -29,6 +29,7 @@ import {
FileTextIcon,
FileIcon,
FileTypeIcon,
Radio,
} from 'lucide-react'
import {
DropdownMenu,
@ -42,6 +43,7 @@ interface EditorToolbarProps {
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void
onExport?: (format: 'md' | 'pdf' | 'docx') => void
onOpenTracks?: () => void
}
export function EditorToolbar({
@ -49,6 +51,7 @@ export function EditorToolbar({
onSelectionHighlight,
onImageUpload,
onExport,
onOpenTracks,
}: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
@ -385,6 +388,19 @@ export function EditorToolbar({
</DropdownMenu>
</>
)}
{/* Tracks — pushed to far right */}
{onOpenTracks && (
<Button
variant="ghost"
size="icon-sm"
onClick={onOpenTracks}
title="Tracks"
className="ml-auto"
>
<Radio className="size-4" />
</Button>
)}
</div>
)
}

View file

@ -15,12 +15,12 @@ function fieldsFromRaw(raw: string | null): FieldEntry[] {
return Object.entries(record).map(([key, value]) => ({ key, value }))
}
function fieldsToRaw(fields: FieldEntry[]): string | null {
function fieldsToRaw(fields: FieldEntry[], preserveRaw: string | null): string | null {
const record: Record<string, string | string[]> = {}
for (const { key, value } of fields) {
if (key.trim()) record[key.trim()] = value
}
return buildFrontmatter(record)
return buildFrontmatter(record, preserveRaw)
}
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
@ -45,10 +45,12 @@ export function FrontmatterProperties({ raw, onRawChange, editable = true }: Fro
}, [editingNewKey])
const commit = useCallback((updated: FieldEntry[]) => {
const newRaw = fieldsToRaw(updated)
// Use the latest raw seen as the preserve-source so structured keys
// (like `track:`) survive a round-trip through this UI.
const newRaw = fieldsToRaw(updated, raw ?? lastCommittedRaw.current)
lastCommittedRaw.current = newRaw
onRawChange(newRaw)
}, [onRawChange])
}, [onRawChange, raw])
// For scalar fields: update local state immediately, commit on blur
const updateLocalValue = useCallback((index: number, newValue: string) => {

View file

@ -11,16 +11,14 @@ import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { TrackBlockExtension } from '@/extensions/track-block'
import { PromptBlockExtension } from '@/extensions/prompt-block'
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { IframeBlockExtension } from '@/extensions/iframe-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block'
import { EmailBlockExtension } from '@/extensions/email-block'
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { Markdown } from 'tiptap-markdown'
@ -48,36 +46,6 @@ function preprocessMarkdown(markdown: string): string {
})
}
// Convert track-target open/close HTML comment markers into placeholder divs
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
// nodes. Content *between* the markers is left untouched — tiptap-markdown
// parses it naturally as whatever it is (paragraphs, lists, custom-block
// fences, etc.), all rendered live by the existing extension set.
//
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
// line until a blank line terminates it, and markdown inline rules (bold,
// italics, links) don't apply inside the block. Without surrounding blank
// lines, the line right after our placeholder div gets absorbed as HTML and
// its markdown is not parsed.
//
// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n`
// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks
// on save; a `\n?` regex on reload would only consume one of those two
// newlines, so every cycle would add a net newline on each side of every
// marker — causing tracks running on an open note to steadily inflate the
// file with blank lines around target regions.
function preprocessTrackTargets(md: string): string {
return md
.replace(
/\n*<!--track-target:([^\s>]+)-->\n*/g,
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
)
.replace(
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
)
}
// Post-process to clean up any zero-width spaces in the output
function postprocessMarkdown(markdown: string): string {
// Remove lines that contain only the zero-width space marker
@ -189,12 +157,6 @@ function blockToMarkdown(node: JsonNode): string {
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'promptBlock':
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackBlock':
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
case 'trackTargetOpen':
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'trackTargetClose':
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
case 'imageBlock':
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
@ -697,22 +659,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
}),
ImageUploadPlaceholderExtension,
TaskBlockExtension,
TrackBlockExtension.configure({ notePath }),
PromptBlockExtension.configure({ notePath }),
TrackTargetOpenExtension,
TrackTargetCloseExtension,
ImageBlockExtension,
EmbedBlockExtension,
IframeBlockExtension,
ChartBlockExtension,
TableBlockExtension,
CalendarBlockExtension,
EmailsBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
MermaidBlockExtension,
WikiLink.configure({
onCreate: wikiLinks?.onCreate
? (path) => {
? (path: string) => {
void wikiLinks.onCreate(path)
}
: undefined,
@ -1099,9 +1059,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
isInternalUpdate.current = true
// Pre-process to preserve blank lines, then wrap track-target comment
// regions into placeholder divs so TrackTargetExtension can pick them up.
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
const preprocessed = preprocessMarkdown(content)
// Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false
@ -1471,6 +1429,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onExport={onExport}
onOpenTracks={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-track-sidebar', {
detail: { filePath: notePath },
}))
} : undefined}
/>
{(frontmatter !== undefined) && onFrontmatterChange && (
<FrontmatterProperties

View file

@ -96,14 +96,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Composio/Gmail state
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
// Composio Gmail/Calendar sync was removed — flags are seeded false and
// never flipped. Kept here so legacy gating expressions still type-check.
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false)
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [useComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
@ -151,25 +151,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
setProvidersLoading(false)
}
}
async function loadComposioForGoogleFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
setUseComposioForGoogle(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google flag:', error)
}
}
async function loadComposioForGoogleCalendarFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
setUseComposioForGoogleCalendar(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google-calendar flag:', error)
}
}
// (Composio Gmail/Calendar flag fetches removed — sync was deleted.)
loadProviders()
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [open])
// Load LLM models catalog on open
@ -622,12 +605,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
// Signed-in users use the rowboat (managed-credentials) flow: opens
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
if (isSignedIntoRowboat) {
await startConnect('google')
return
}
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
}, [startConnect, providerStates])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)

View file

@ -66,16 +66,16 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Inline upsell callout dismissed
const [upsellDismissed, setUpsellDismissed] = useState(false)
// Composio/Gmail state (used when signed in with Rowboat account)
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
// Composio Gmail/Calendar sync was removed — flags are seeded false and
// never flipped. Kept here so legacy gating expressions still type-check.
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailConnecting, setGmailConnecting] = useState(false)
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail')
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [useComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
@ -123,25 +123,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
setProvidersLoading(false)
}
}
async function loadComposioForGoogleFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
setUseComposioForGoogle(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google flag:', error)
}
}
async function loadComposioForGoogleCalendarFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
setUseComposioForGoogleCalendar(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google-calendar flag:', error)
}
}
// (Composio Gmail/Calendar flag fetches removed — sync was deleted; flags stay false.)
loadProviders()
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [open])
// Load LLM models catalog on open
@ -539,17 +522,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const cleanup = window.ipc.on('oauth:didConnect', async (event) => {
if (event.provider === 'rowboat' && event.success) {
// Re-check composio flags now that the account is connected
try {
const [googleResult, calendarResult] = await Promise.all([
window.ipc.invoke('composio:use-composio-for-google', null),
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
])
setUseComposioForGoogle(googleResult.enabled)
setUseComposioForGoogleCalendar(calendarResult.enabled)
} catch (error) {
console.error('Failed to re-check composio flags:', error)
}
// (Composio Gmail/Calendar flag re-check removed — sync was deleted.)
setCurrentStep(2) // Go to Connect Accounts
}
})
@ -609,12 +582,20 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
// Signed-in users use the rowboat (managed-credentials) flow: opens
// the webapp in the browser, no BYOK modal. Falls back to BYOK modal
// for not-signed-in users. (Mirrors useConnectors.handleConnect.)
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
if (isSignedIntoRowboat) {
await startConnect('google')
return
}
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
}, [startConnect, providerStates])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import posthog from 'posthog-js'
import * as analytics from '@/lib/analytics'
import { FileTextIcon, MessageSquareIcon, XIcon } from 'lucide-react'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
import {
CommandDialog,
CommandInput,
@ -22,13 +22,14 @@ interface SearchResult {
}
type SearchType = 'knowledge' | 'chat'
type Mode = 'chat' | 'search'
function activeTabToTypes(section: ActiveSection): SearchType[] {
if (section === 'knowledge') return ['knowledge']
return ['chat'] // "tasks" tab maps to chat
return ['chat']
}
// Retained for any remaining programmatic Copilot entry points (background-agent
// setup button, prompt-block run, etc.) — Cmd+K no longer invokes Copilot.
export type CommandPaletteContext = {
path: string
lineNumber: number
@ -43,12 +44,8 @@ export type CommandPaletteMention = {
interface CommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
// Search mode
onSelectFile: (path: string) => void
onSelectRun: (runId: string) => void
// Chat mode
initialContext?: CommandPaletteContext | null
onChatSubmit: (text: string, mention: CommandPaletteMention | null) => void
}
export function CommandPalette({
@ -56,14 +53,8 @@ export function CommandPalette({
onOpenChange,
onSelectFile,
onSelectRun,
initialContext,
onChatSubmit,
}: CommandPaletteProps) {
const { activeSection } = useSidebarSection()
const [mode, setMode] = useState<Mode>('chat')
const [chatInput, setChatInput] = useState('')
const [contextChip, setContextChip] = useState<CommandPaletteContext | null>(null)
const chatInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
@ -74,45 +65,23 @@ export function CommandPalette({
)
const debouncedQuery = useDebounce(query, 250)
// On open: always reset to Chat mode (per spec — no mode persistence), sync context chip
// and reset search filters.
// Sync filters and clear query when the dialog opens.
useEffect(() => {
if (open) {
setMode('chat')
setChatInput('')
setContextChip(initialContext ?? null)
setQuery('')
setActiveTypes(new Set(activeTabToTypes(activeSection)))
}
}, [open, activeSection, initialContext])
}, [open, activeSection])
// Tab cycles modes. Captured at document level so cmdk's internal Tab handling doesn't
// swallow it. Only fires while the dialog is open.
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
e.preventDefault()
e.stopPropagation()
setMode(prev => (prev === 'chat' ? 'search' : 'chat'))
}
document.addEventListener('keydown', handler, true)
return () => document.removeEventListener('keydown', handler, true)
searchInputRef.current?.focus()
}, [open])
// Refocus the appropriate input on mode change so the user can start typing immediately.
useEffect(() => {
if (!open) return
const target = mode === 'chat' ? chatInputRef : searchInputRef
target.current?.focus()
}, [open, mode])
const toggleType = useCallback((type: SearchType) => {
setActiveTypes(new Set([type]))
}, [])
// Search query effect (only meaningful while in search mode, but the debounce keeps running
// harmlessly otherwise — empty query skips the IPC call below).
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([])
@ -133,25 +102,19 @@ export function CommandPalette({
})
.catch((err) => {
console.error('Search failed:', err)
if (!cancelled) {
setResults([])
}
if (!cancelled) setResults([])
})
.finally(() => {
if (!cancelled) {
setIsSearching(false)
}
if (!cancelled) setIsSearching(false)
})
return () => { cancelled = true }
}, [debouncedQuery, activeTypes])
// Reset transient state on close.
useEffect(() => {
if (!open) {
setQuery('')
setResults([])
setChatInput('')
}
}, [open])
@ -164,20 +127,6 @@ export function CommandPalette({
}
}, [onOpenChange, onSelectFile, onSelectRun])
const submitChat = useCallback(() => {
const text = chatInput.trim()
if (!text && !contextChip) return
const mention: CommandPaletteMention | null = contextChip
? {
path: contextChip.path,
displayName: deriveDisplayName(contextChip.path),
lineNumber: contextChip.lineNumber,
}
: null
onChatSubmit(text, mention)
onOpenChange(false)
}, [chatInput, contextChip, onChatSubmit, onOpenChange])
const knowledgeResults = results.filter(r => r.type === 'knowledge')
const chatResults = results.filter(r => r.type === 'chat')
@ -185,74 +134,11 @@ export function CommandPalette({
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title={mode === 'chat' ? 'Chat with copilot' : 'Search'}
description={mode === 'chat' ? 'Start a chat — Tab to switch to search' : 'Search across knowledge and chats — Tab to switch to chat'}
title="Search"
description="Search across knowledge and chats"
showCloseButton={false}
className="top-[20%] translate-y-0"
>
{/* Mode strip */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<ModeButton
active={mode === 'chat'}
onClick={() => setMode('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chat"
/>
<ModeButton
active={mode === 'search'}
onClick={() => setMode('search')}
icon={<FileTextIcon className="size-3" />}
label="Search"
/>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Tab to switch</span>
</div>
{mode === 'chat' ? (
<div className="flex flex-col">
<input
ref={chatInputRef}
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => {
// cmdk's Command component intercepts Enter for item selection — stop it
// before bubbling so we control the chat submit ourselves.
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault()
e.stopPropagation()
submitChat()
}
}}
placeholder="Ask copilot anything…"
autoFocus
className="w-full bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
{contextChip && (
<div className="flex items-center gap-2 px-3 pb-3">
<span className="inline-flex items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 text-xs">
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium">{deriveDisplayName(contextChip.path)}</span>
<span className="text-muted-foreground">· Line {contextChip.lineNumber}</span>
<button
type="button"
onClick={() => setContextChip(null)}
aria-label="Remove context"
className="ml-0.5 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<XIcon className="size-3" />
</button>
</span>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
</div>
)}
{!contextChip && (
<div className="flex items-center px-3 pb-3">
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">Enter to send</span>
</div>
)}
</div>
) : (
<>
<CommandInput
ref={searchInputRef}
placeholder="Search..."
@ -315,48 +201,10 @@ export function CommandPalette({
</CommandGroup>
)}
</CommandList>
</>
)}
</CommandDialog>
)
}
// Back-compat export so existing import sites don't break in one go; thin alias to CommandPalette.
export const SearchDialog = CommandPalette
function deriveDisplayName(path: string): string {
const base = path.split('/').pop() ?? path
return base.replace(/\.md$/, '')
}
function ModeButton({
active,
onClick,
icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
{icon}
{label}
</button>
)
}
function FilterToggle({
active,
onClick,
@ -370,17 +218,19 @@ function FilterToggle({
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
)}
>
{icon}
{label}
<span>{label}</span>
</button>
)
}
// Back-compat export: thin alias to CommandPalette.
export const SearchDialog = CommandPalette

View file

@ -156,6 +156,28 @@ const SERVICE_LABELS: Record<string, string> = {
granola: "Syncing Granola",
graph: "Updating knowledge",
voice_memo: "Processing voice memo",
email_labeling: "Labeling emails",
note_tagging: "Tagging notes",
agent_notes: "Updating agent notes",
}
function summarizeServiceError(error: string): string {
const firstLine = error.split("\n").find((line) => line.trim().length > 0)
return firstLine?.trim() || error.trim()
}
function collectServiceErrors(events: ServiceEventType[]): Map<string, string> {
const errors = new Map<string, string>()
for (const event of events) {
if (event.type === "error") {
errors.set(event.service, summarizeServiceError(event.error))
continue
}
if (event.type === "run_complete" && event.outcome !== "error") {
errors.delete(event.service)
}
}
return errors
}
type TasksActions = {
@ -186,10 +208,14 @@ type SidebarContentPanelProps = {
meetingSummarizing?: boolean
meetingAvailable?: boolean
onToggleMeeting?: () => void
isSearchOpen?: boolean
isMeetingActionActive?: boolean
isBrowserOpen?: boolean
onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void
isBackgroundAgentsOpen?: boolean
onOpenBackgroundAgents?: () => void
} & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -225,6 +251,7 @@ function formatRunTime(ts: string): string {
function SyncStatusBar() {
const { state } = useSidebar()
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
const [serviceErrors, setServiceErrors] = useState<Map<string, string>>(new Map())
const [popoverOpen, setPopoverOpen] = useState(false)
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
const [logLoading, setLogLoading] = useState(false)
@ -258,11 +285,25 @@ function SyncStatusBar() {
next.delete(nextEvent.runId)
return next
})
if (nextEvent.outcome !== 'error') {
setServiceErrors((prev) => {
if (!prev.has(nextEvent.service)) return prev
const next = new Map(prev)
next.delete(nextEvent.service)
return next
})
}
const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId)
if (existingTimeout) {
clearTimeout(existingTimeout)
runTimeoutsRef.current.delete(nextEvent.runId)
}
} else if (nextEvent.type === 'error') {
setServiceErrors((prev) => {
const next = new Map(prev)
next.set(nextEvent.service, summarizeServiceError(nextEvent.error))
return next
})
}
})
return cleanup
@ -296,10 +337,14 @@ function SyncStatusBar() {
// skip malformed lines
}
}
setServiceErrors(collectServiceErrors(parsed))
// Newest first, limit to 1000
setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS))
} catch {
if (!cancelled) setLogEvents([])
if (!cancelled) {
setLogEvents([])
setServiceErrors(new Map())
}
} finally {
if (!cancelled) setLogLoading(false)
}
@ -310,11 +355,18 @@ function SyncStatusBar() {
const isSyncing = activeServices.size > 0
const isCollapsed = state === "collapsed"
const errorEntries = Array.from(serviceErrors.entries())
const primaryErrorService = errorEntries[0]?.[0] ?? null
const hasServiceErrors = errorEntries.length > 0
// Build status label from active services
const activeServiceNames = [...new Set(activeServices.values())]
const statusLabel = isSyncing
? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ")
: hasServiceErrors
? errorEntries.length === 1
? `${SERVICE_LABELS[primaryErrorService ?? ""] || primaryErrorService} failed`
: "Recent sync issues"
: "All caught up"
return (
@ -333,11 +385,16 @@ function SyncStatusBar() {
<PopoverTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent"
className={cn(
"flex w-full items-center justify-between rounded-md px-2 py-1 text-xs hover:bg-sidebar-accent",
hasServiceErrors && !isSyncing ? "text-red-600 dark:text-red-400" : "text-muted-foreground",
)}
>
<span className="flex items-center gap-2 min-w-0">
{isSyncing ? (
<LoaderIcon className="h-3 w-3 shrink-0 animate-spin" />
) : hasServiceErrors ? (
<AlertTriangle className="h-3 w-3 shrink-0" />
) : (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60" />
)}
@ -355,7 +412,7 @@ function SyncStatusBar() {
<div className="p-3 border-b">
<h4 className="font-semibold text-sm">Sync Activity</h4>
<p className="text-xs text-muted-foreground mt-0.5">
{isSyncing ? statusLabel : "All services up to date"}
{isSyncing || hasServiceErrors ? statusLabel : "All services up to date"}
</p>
</div>
<div className="max-h-80 overflow-y-auto p-2">
@ -387,7 +444,17 @@ function SyncStatusBar() {
{SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service}
</span>
</span>
<span className="leading-4 text-foreground/80">{event.message}</span>
<div className="min-w-0 flex-1">
<p className="leading-4 text-foreground/80">{event.message}</p>
{event.type === 'error' && (
<p
className="truncate text-[11px] leading-4 text-red-600/90 dark:text-red-400/90"
title={event.error}
>
{summarizeServiceError(event.error)}
</p>
)}
</div>
</div>
))}
</div>
@ -420,10 +487,14 @@ export function SidebarContentPanel({
meetingSummarizing = false,
meetingAvailable = false,
onToggleMeeting,
isSearchOpen = false,
isMeetingActionActive = false,
isBrowserOpen = false,
onToggleBrowser,
isSuggestedTopicsOpen = false,
onOpenSuggestedTopics,
isBackgroundAgentsOpen = false,
onOpenBackgroundAgents,
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
@ -436,6 +507,10 @@ export function SidebarContentPanel({
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected)
const isMeetingQuickActionSelected = isMeetingActionActive
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => {
try {
@ -533,7 +608,12 @@ export function SidebarContentPanel({
<button
type="button"
onClick={onOpenSearch}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isSearchOpen
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<SearchIcon className="size-4" />
<span>Search</span>
@ -546,9 +626,14 @@ export function SidebarContentPanel({
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
isMeetingQuickActionSelected
? "bg-sidebar-accent"
: "hover:bg-sidebar-accent",
meetingState === 'recording'
? "text-red-500 hover:bg-sidebar-accent"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
? "text-red-500"
: isMeetingQuickActionSelected
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:text-sidebar-accent-foreground"
)}
>
{meetingSummarizing || meetingState === 'connecting' ? (
@ -575,7 +660,7 @@ export function SidebarContentPanel({
onClick={onToggleBrowser}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isBrowserOpen
isBrowserQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
@ -590,7 +675,7 @@ export function SidebarContentPanel({
onClick={onOpenSuggestedTopics}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isSuggestedTopicsOpen
isSuggestedTopicsQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
@ -599,6 +684,21 @@ export function SidebarContentPanel({
<span>Suggested Topics</span>
</button>
)}
{onOpenBackgroundAgents && (
<button
type="button"
onClick={onOpenBackgroundAgents}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isBackgroundAgentsQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Bot className="size-4" />
<span>Background agents</span>
</button>
)}
</div>
</SidebarHeader>
<SidebarContent>

View file

@ -0,0 +1,24 @@
import React, { useMemo } from 'react'
import { processTerminalOutput, spanStyleToCSS } from '../lib/terminal-output'
export function TerminalOutput({ raw }: { raw: string }) {
const lines = useMemo(() => processTerminalOutput(raw), [raw])
return (
<>
{lines.map((line, lineIdx) => (
<React.Fragment key={lineIdx}>
{lineIdx > 0 && '\n'}
{line.spans.map((span, spanIdx) => {
const css = spanStyleToCSS(span.style)
return css ? (
<span key={spanIdx} style={css}>{span.text}</span>
) : (
<React.Fragment key={spanIdx}>{span.text}</React.Fragment>
)
})}
</React.Fragment>
))}
</>
)
}

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Calendar, Video, ChevronDown, Mic } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef } from 'react'
import { extractConferenceLink } from '../lib/calendar-event'
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
@ -40,25 +41,6 @@ function getTimeRange(event: blocks.CalendarEvent): string {
return `${startTime} \u2013 ${endTime}`
}
/**
* Extract a video conference link from raw Google Calendar event JSON.
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
* to conferenceLink if already set.
*/
function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
// Check conferenceData.entryPoints for video entry
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
if (confData?.entryPoints) {
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
if (video?.uri) return video.uri
}
// Check hangoutLink (Google Meet shortcut)
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
// Fall back to conferenceLink if present
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
return undefined
}
interface ResolvedEvent {
event: blocks.CalendarEvent
loaded: blocks.CalendarEvent | null

View file

@ -1,6 +1,6 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context'
@ -11,17 +11,47 @@ function formatEmailDate(dateStr: string): string {
try {
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
const now = new Date()
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
} catch {
return dateStr
}
}
/** Extract just the name part from "Name <email>" format */
function senderFirstName(from: string): string {
const name = from.replace(/<.*>/, '').trim()
return name.split(/\s+/)[0] || name
function formatFullDate(dateStr: string): string {
try {
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +
', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} catch {
return dateStr
}
}
function extractName(from: string): string {
const match = from.match(/^([^<]+)</)
if (match) return match[1].trim()
const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
return username.replace(/\b\w/g, c => c.toUpperCase())
}
function getInitial(from: string): string {
const name = extractName(from)
return (name[0] || '?').toUpperCase()
}
const GMAIL_AVATAR_COLORS = [
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
]
function avatarColor(from: string): string {
let hash = 0
for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
}
declare global {
@ -30,7 +60,307 @@ declare global {
}
}
// --- Email Block ---
// --- Shared: expanded email body used by both block types ---
function EmailExpandedBody({
config,
resolvedTheme,
}: {
config: blocks.EmailBlock
resolvedTheme: string
}) {
const [draftBody, setDraftBody] = useState(config.draft_response || '')
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setDraftBody(config.draft_response || '')
}, [config.draft_response])
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const draftWithAssistant = useCallback(() => {
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config, draftBody])
const copyDraft = useCallback(() => {
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
const el = document.createElement('textarea')
el.value = draftBody
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}, [draftBody])
const gmailUrl = config.threadId
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
const initial = config.from ? getInitial(config.from) : '?'
const color = config.from ? avatarColor(config.from) : '#5f6368'
const hasDraft = !!config.draft_response
return (
<div className="email-gmail-expanded">
{config.subject && (
<div className="email-gmail-exp-subject">{config.subject}</div>
)}
<div className="email-gmail-exp-meta">
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-gmail-exp-meta-right">
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
<div className="email-gmail-exp-to-date">
{config.to && <span>to {config.to}</span>}
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
</div>
</div>
</div>
<div className="email-gmail-exp-body">{config.latest_email}</div>
{config.past_summary && (
<div className="email-gmail-exp-history">
<div className="email-gmail-exp-history-label">Earlier conversation</div>
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
</div>
)}
{!hasDraft && (
<div className="email-gmail-reply-row">
{gmailUrl && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
<button
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
Draft with Rowboat
</button>
</div>
)}
{hasDraft && (
<div className="email-gmail-compose">
<div className="email-gmail-compose-to">
<span className="email-gmail-compose-to-label">Reply</span>
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-gmail-compose-body"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Write your reply..."
rows={3}
/>
<div className="email-gmail-compose-footer">
<button
className="email-gmail-btn email-gmail-btn-primary"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); copyDraft() }}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
{gmailUrl && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
</div>
</div>
)}
</div>
)
}
// --- Multi-email inbox block (language-emails) ---
function EmailsBlockView({ node, deleteNode }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
}) {
const raw = node.attrs.data as string
let config: blocks.EmailsBlock | null = null
try {
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
if (!config || config.emails.length === 0) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
{config.title && (
<div className="email-inbox-title">{config.title}</div>
)}
<div className="email-inbox-list">
{config.emails.map((email, i) => {
const isExpanded = expandedIndex === i
const senderName = email.from ? extractName(email.from) : 'Unknown'
const initial = email.from ? getInitial(email.from) : '?'
const color = email.from ? avatarColor(email.from) : '#5f6368'
const snippet = email.summary
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
return (
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
{/* Collapsed row */}
<div
className="email-inbox-row-header"
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-inbox-content">
<div className="email-inbox-top-row">
<span className="email-inbox-sender">{senderName}</span>
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
</div>
<div className="email-inbox-bottom-row">
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
{snippet && (
<span className="email-inbox-snippet">
{email.subject ? `${snippet}` : snippet}
</span>
)}
</div>
</div>
<ChevronDown
size={14}
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
/>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="email-inbox-expanded-wrap">
<EmailExpandedBody
config={email}
resolvedTheme={resolvedTheme}
/>
</div>
)}
</div>
)
})}
</div>
</div>
</NodeViewWrapper>
)
}
export const EmailsBlockExtension = Node.create({
name: 'emailsBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return { data: { default: '{}' } }
},
parseHTML() {
return [{
tag: 'pre',
priority: 61,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
if ((code.className || '').includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmailsBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```emails\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})
// --- Single email block (language-email, backward compat) ---
function EmailBlockView({ node, deleteNode, updateAttributes }: {
node: { attrs: Record<string, unknown> }
@ -42,194 +372,57 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
try {
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [expanded, setExpanded] = useState(false)
// Local draft state for editing
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
const [emailExpanded, setEmailExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
// Sync draft from external changes
useEffect(() => {
try {
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
setDraftBody(parsed.draft_response || '')
} catch { /* ignore */ }
}, [raw])
// Auto-resize textarea
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const commitDraft = useCallback((newBody: string) => {
try {
const current = JSON.parse(raw) as Record<string, unknown>
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
} catch { /* ignore */ }
}, [raw, updateAttributes])
const draftWithAssistant = useCallback(() => {
if (!config) return
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n`
prompt += `**From:** ${config.from || 'Unknown'}\n`
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) {
prompt += `\n**Current draft:**\n${draftBody}\n`
}
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config, draftBody])
void updateAttributes // available for future per-email draft persistence
if (!config) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-error">
<Mail size={16} />
<span>Invalid email block</span>
</div>
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
</NodeViewWrapper>
)
}
const gmailUrl = config.threadId
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
// Build summary: use explicit summary, or auto-generate from sender + subject
const summary = config.summary
|| (config.from && config.subject
? `${senderFirstName(config.from)} reached out about ${config.subject}`
: config.subject || 'New email')
const senderName = config.from ? extractName(config.from) : 'Unknown'
const initial = config.from ? getInitial(config.from) : '?'
const color = config.from ? avatarColor(config.from) : '#5f6368'
const snippet = config.summary
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button>
{/* Header: Email badge */}
<div className="email-block-badge">
<Mail size={13} />
Email
</div>
{/* Summary */}
<div className="email-block-summary">{summary}</div>
{/* Expandable email details */}
<button
className="email-block-expand-btn"
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
<div
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{emailExpanded ? 'Hide email' : 'Show email'}
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
</button>
<div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div>
<div className="email-gmail-content">
<div className="email-gmail-top-row">
<span className="email-gmail-sender">{senderName}</span>
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
</div>
<div className="email-gmail-bottom-row">
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
{snippet && <span className="email-gmail-snippet">{config.subject ? `${snippet}` : snippet}</span>}
</div>
</div>
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
</div>
{emailExpanded && (
<div className="email-block-email-details">
<div className="email-block-message">
<div className="email-block-message-header">
<div className="email-block-sender-info">
<div className="email-block-sender-row">
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
</div>
</div>
<div className="email-block-message-body">{config.latest_email}</div>
</div>
{hasPastSummary && (
<div className="email-block-context-section">
<div className="email-block-context-label">Earlier conversation</div>
<div className="email-block-context-summary">{config.past_summary}</div>
</div>
)}
</div>
)}
{/* Draft section */}
{hasDraft && (
<div className="email-block-draft-section">
<div className="email-block-draft-label">Draft reply</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-draft-block-body-input"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onBlur={() => commitDraft(draftBody)}
placeholder="Write your reply..."
rows={3}
{expanded && (
<EmailExpandedBody
config={config}
resolvedTheme={resolvedTheme}
/>
</div>
)}
{/* Action buttons */}
<div className="email-block-actions">
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={draftWithAssistant}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
{hasDraft && (
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={() => {
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
// Fallback for Electron contexts where clipboard API may fail
const textarea = document.createElement('textarea')
textarea.value = draftBody
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
)}
{gmailUrl && (
<button
className="email-block-gmail-btn"
onClick={() => window.open(gmailUrl, '_blank')}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
</div>
</div>
</NodeViewWrapper>
)
@ -243,9 +436,7 @@ export const EmailBlockExtension = Node.create({
draggable: false,
addAttributes() {
return {
data: { default: '{}' },
}
return { data: { default: '{}' } }
},
parseHTML() {
@ -256,7 +447,7 @@ export const EmailBlockExtension = Node.create({
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/react'
import { InputRule, inputRules } from '@tiptap/pm/inputrules'
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
@ -88,13 +87,13 @@ export const WikiLink = Node.create<WikiLinkOptions>({
return [
{
tag: 'wiki-link[data-path]',
getAttrs: (element) => ({
getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
}),
},
{
tag: 'a[data-type="wiki-link"]',
getAttrs: (element) => ({
getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
}),
},
@ -132,10 +131,12 @@ export const WikiLink = Node.create<WikiLinkOptions>({
}
},
addProseMirrorPlugins() {
addInputRules() {
const onCreate = this.options.onCreate
const rules = [
new InputRule(wikiLinkInputRegex, (state, match, start, end) => {
return [
new InputRule({
find: wikiLinkInputRegex,
handler: ({ state, range, match }) => {
const rawPath = match[1]?.trim()
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
@ -143,12 +144,10 @@ export const WikiLink = Node.create<WikiLinkOptions>({
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
const finalPath = ensureMarkdownExtension(normalizedPath)
const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath }))
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath }))
onCreate?.(finalPath)
return tr
},
}),
]
return [inputRules({ rules })]
},
})

View file

@ -1,6 +1,6 @@
import z from 'zod';
import { useSyncExternalStore } from 'react';
import { TrackEvent } from '@x/shared/dist/track-block.js';
import { TrackEvent } from '@x/shared/dist/track.js';
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
@ -59,7 +59,7 @@ function getSnapshot(): Map<string, TrackState> {
/**
* Returns a Map of all track run states, keyed by "trackId:filePath".
*
* Usage in a track block component:
* Usage in a track-aware component:
* const trackStatus = useTrackStatus();
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
*

View file

@ -38,16 +38,21 @@ export function useConnectors(active: boolean) {
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Composio/Gmail state
const [useComposioForGoogle, setUseComposioForGoogle] = useState(false)
// Composio Gmail/Calendar sync was removed. These flags are seeded false
// and never flipped — the IPC that used to set them is gone. The setters
// remain so the legacy Composio-Gmail handlers below still type-check,
// but those handlers are no longer reachable in the UI (the gating
// condition `useComposioForGoogle` stays false).
// TODO follow-up: drop these flags entirely and prune the dead UI branches
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
const [useComposioForGoogle] = useState(false)
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailLoading, setGmailLoading] = useState(false)
const [gmailConnecting, setGmailConnecting] = useState(false)
// Composio/Google Calendar state
const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false)
const [useComposioForGoogleCalendar] = useState(false)
const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(false)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
// Load available providers on mount
@ -67,28 +72,7 @@ export function useConnectors(active: boolean) {
loadProviders()
}, [])
// Re-check composio-for-google flags when active
useEffect(() => {
if (!active) return
async function loadComposioForGoogleFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google', null)
setUseComposioForGoogle(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google flag:', error)
}
}
async function loadComposioForGoogleCalendarFlag() {
try {
const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null)
setUseComposioForGoogleCalendar(result.enabled)
} catch (error) {
console.error('Failed to check composio-for-google-calendar flag:', error)
}
}
loadComposioForGoogleFlag()
loadComposioForGoogleCalendarFlag()
}, [active])
// (Composio Gmail/Calendar flag-check effect removed — flags are constant false now.)
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
@ -346,13 +330,22 @@ export function useConnectors(active: boolean) {
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
// Signed-in users use the rowboat (managed-credentials) flow: opens
// the webapp in the browser, no BYOK modal. Main process detects
// signed-in via isSignedIn() when oauth:connect arrives without creds.
// Falls back to the BYOK modal for not-signed-in users.
const isSignedIntoRowboat = providerStates.rowboat?.isConnected ?? false
if (isSignedIntoRowboat) {
await startConnect('google')
return
}
setGoogleClientIdDescription(undefined)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
}, [startConnect, providerStates])
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
@ -485,19 +478,6 @@ export function useConnectors(active: boolean) {
toast.success(`Connected to ${displayName}`)
}
if (provider === 'rowboat') {
try {
const [googleResult, calendarResult] = await Promise.all([
window.ipc.invoke('composio:use-composio-for-google', null),
window.ipc.invoke('composio:use-composio-for-google-calendar', null),
])
setUseComposioForGoogle(googleResult.enabled)
setUseComposioForGoogleCalendar(calendarResult.enabled)
} catch (err) {
console.error('Failed to re-check composio flags:', err)
}
}
refreshAllStatuses()
}
})

View file

@ -0,0 +1,15 @@
/**
* Extract a video conference link from raw Google Calendar event JSON.
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
* to a top-level conferenceLink if present.
*/
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
if (confData?.entryPoints) {
const video = confData.entryPoints.find(ep => ep.entryPointType === 'video')
if (video?.uri) return video.uri
}
if (typeof raw.hangoutLink === 'string') return raw.hangoutLink
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
return undefined
}

View file

@ -24,6 +24,7 @@ export interface ToolCall {
name: string
input: ToolUIPart['input']
result?: ToolUIPart['output']
streamingOutput?: string
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
}
@ -586,6 +587,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat
return null
}
export type ToolGroup = {
type: 'tool-group'
items: ToolCall[]
groupId: string
}
export type GroupedConversationItem = ConversationItem | ToolGroup
export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
'type' in item && (item as ToolGroup).type === 'tool-group'
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
if (!isToolCall(item)) return false
if (getWebSearchCardData(item)) return false
if (getComposioConnectCardData(item)) return false
if (getAppActionCardData(item)) return false
return true
}
export const groupConversationItems = (
items: ConversationItem[],
hasPermissionRequest: (id: string) => boolean
): GroupedConversationItem[] => {
const result: GroupedConversationItem[] = []
let i = 0
while (i < items.length) {
const item = items[i]
if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) {
const group: ToolCall[] = [item]
i++
while (
i < items.length &&
isPlainToolCall(items[i] as ConversationItem) &&
!hasPermissionRequest((items[i] as ToolCall).id)
) {
group.push(items[i] as ToolCall)
i++
}
if (group.length === 1) {
result.push(group[0])
} else {
result.push({ type: 'tool-group', items: group, groupId: group[0].id })
}
} else {
result.push(item)
i++
}
}
return result
}
export const getToolGroupSummary = (tools: ToolCall[]): string => {
const seen = new Set<string>()
const names: string[] = []
for (const tool of tools) {
const name = getToolDisplayName(tool)
if (!seen.has(name)) {
seen.add(name)
names.push(name)
}
}
return names.join(' · ')
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()

View file

@ -133,9 +133,19 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
}
/**
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list values are string[].
* Skips `---` delimiters and blank lines.
* Keys that hold structured (nested object/array-of-object) data and must NOT
* be mangled by the flat-string FrontmatterProperties UI. These pass through
* unchanged on a round-trip never exposed as editable fields, never
* re-emitted by buildFrontmatter (callers must splice them back from the
* original raw if they want to preserve them on save see the helpers below).
*/
const STRUCTURED_KEYS = new Set(['track'])
/**
* Extract editable top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list-of-string
* values are string[]. Structured keys (e.g. `track:`) and any nested-object
* shapes are filtered out they are not editable via this surface.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {}
@ -143,10 +153,12 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
const lines = raw.split('\n')
let currentKey: string | null = null
let pendingNested = false
for (const line of lines) {
if (line === '---' || line.trim() === '') {
currentKey = null
pendingNested = false
continue
}
@ -155,39 +167,61 @@ export function extractAllFrontmatterValues(raw: string | null): Record<string,
if (topMatch) {
const key = topMatch[1]
const value = topMatch[2].trim()
pendingNested = false
if (STRUCTURED_KEYS.has(key)) {
currentKey = null
continue
}
if (value) {
result[key] = value
currentKey = null
} else {
// List will follow
currentKey = key
result[key] = []
}
continue
}
// List item under current key
if (currentKey) {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (!currentKey) continue
// List item under current key.
const itemMatch = line.match(/^\s+-\s+(.*)$/)
if (itemMatch) {
const item = itemMatch[1].trim()
// If the list-item line itself contains a `key: value` pair, this is a
// nested-object shape (e.g. `- id: chicago-time` under `track:`). We
// can't represent that as a flat string array — drop the whole key.
if (/^\w[\w\s]*\w?:\s*\S/.test(item)) {
delete result[currentKey]
currentKey = null
pendingNested = true
continue
}
const arr = result[currentKey]
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim())
}
}
if (Array.isArray(arr)) arr.push(item)
continue
}
// Indented continuation of a nested object — keep dropping its parent.
if (pendingNested && /^\s/.test(line)) continue
}
return result
}
/**
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
* Returns null if no non-empty fields remain.
* Convert a Record of editable frontmatter fields back to a raw YAML
* frontmatter string. If `preserveRaw` is provided, structured keys (e.g.
* `track:`) are spliced back from the original raw byte-for-byte, so
* round-trips through the FrontmatterProperties UI never lose them.
*/
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
export function buildFrontmatter(
fields: Record<string, string | string[]>,
preserveRaw: string | null = null,
): string | null {
const lines: string[] = []
for (const [key, value] of Object.entries(fields)) {
if (STRUCTURED_KEYS.has(key)) continue
if (Array.isArray(value)) {
if (value.length === 0) continue
lines.push(`${key}:`)
@ -200,8 +234,55 @@ export function buildFrontmatter(fields: Record<string, string | string[]>): str
lines.push(`${key}: ${trimmed}`)
}
}
if (lines.length === 0) return null
return `---\n${lines.join('\n')}\n---`
// Splice preserved structured-key blocks (e.g. track:) back from preserveRaw.
const preservedBlocks: string[] = []
if (preserveRaw) {
for (const key of STRUCTURED_KEYS) {
const block = extractTopLevelBlock(preserveRaw, key)
if (block) preservedBlocks.push(block)
}
}
if (lines.length === 0 && preservedBlocks.length === 0) return null
const allLines = [...lines, ...preservedBlocks.flatMap(b => b.split('\n'))]
return `---\n${allLines.join('\n')}\n---`
}
/**
* Return the byte-for-byte line block for a top-level key in raw frontmatter,
* including its nested children (any indented lines that follow), or null if
* the key is absent. Used to round-trip structured keys safely.
*/
function extractTopLevelBlock(raw: string, key: string): string | null {
const lines = raw.split('\n')
let start = -1
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line === '---') continue
const m = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
if (m && m[1] === key) {
start = i
break
}
}
if (start === -1) return null
let end = start
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i]
if (line === '---') break
if (/^\s/.test(line)) {
end = i
continue
}
if (line.trim() === '') {
// blank line — end of this top-level block
break
}
// another top-level key — stop
break
}
return lines.slice(start, end + 1).join('\n')
}
/** Map known tag values → category for legacy flat-list frontmatter. */

View file

@ -0,0 +1,319 @@
/**
* Terminal output processor that handles ANSI escape sequences, carriage returns,
* and other terminal control characters to produce styled, terminal-like output.
*/
export interface StyledSpan {
text: string
style: SpanStyle
}
export interface SpanStyle {
bold?: boolean
dim?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
fg?: string
bg?: string
}
export interface TerminalLine {
spans: StyledSpan[]
}
const ANSI_COLORS_16: Record<number, string> = {
30: '#4e4e4e', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b',
34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#dcdfe4',
90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#e5c07b',
94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff',
}
const ANSI_BG_COLORS_16: Record<number, string> = {
40: '#4e4e4e', 41: '#e06c75', 42: '#98c379', 43: '#e5c07b',
44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#dcdfe4',
100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#e5c07b',
104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff',
}
function color256(n: number): string {
if (n < 8) return ANSI_COLORS_16[30 + n] ?? '#dcdfe4'
if (n < 16) return ANSI_COLORS_16[90 + (n - 8)] ?? '#dcdfe4'
if (n < 232) {
const idx = n - 16
const r = Math.floor(idx / 36)
const g = Math.floor((idx % 36) / 6)
const b = idx % 6
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
const level = 8 + (n - 232) * 10
const hex = level.toString(16).padStart(2, '0')
return `#${hex}${hex}${hex}`
}
function parseSGR(params: number[], style: SpanStyle): SpanStyle {
const s = { ...style }
let i = 0
while (i < params.length) {
const p = params[i]
if (p === 0) {
delete s.bold
delete s.dim
delete s.italic
delete s.underline
delete s.strikethrough
delete s.fg
delete s.bg
} else if (p === 1) s.bold = true
else if (p === 2) s.dim = true
else if (p === 3) s.italic = true
else if (p === 4) s.underline = true
else if (p === 9) s.strikethrough = true
else if (p === 22) {
delete s.bold
delete s.dim
} else if (p === 23) delete s.italic
else if (p === 24) delete s.underline
else if (p === 29) delete s.strikethrough
else if (p >= 30 && p <= 37) s.fg = ANSI_COLORS_16[p]
else if (p === 38) {
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
s.fg = color256(params[i + 2])
i += 2
} else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
const r = params[i + 2]
const g = params[i + 3]
const b = params[i + 4]
s.fg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
i += 4
}
} else if (p === 39) delete s.fg
else if (p >= 40 && p <= 47) s.bg = ANSI_BG_COLORS_16[p]
else if (p === 48) {
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
s.bg = color256(params[i + 2])
i += 2
} else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
const r = params[i + 2]
const g = params[i + 3]
const b = params[i + 4]
s.bg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
i += 4
}
} else if (p === 49) delete s.bg
else if (p >= 90 && p <= 97) s.fg = ANSI_COLORS_16[p]
else if (p >= 100 && p <= 107) s.bg = ANSI_BG_COLORS_16[p]
i++
}
return s
}
export function processTerminalOutput(raw: string): TerminalLine[] {
type Cell = { char: string; style: SpanStyle }
const lines: Cell[][] = [[]]
let cursorRow = 0
let cursorCol = 0
let currentStyle: SpanStyle = {}
function ensureRow(row: number) {
while (lines.length <= row) lines.push([])
}
function ensureCol(row: number, col: number) {
ensureRow(row)
const line = lines[row]
while (line.length <= col) line.push({ char: ' ', style: {} })
}
let i = 0
while (i < raw.length) {
const ch = raw[i]
if (ch === '\x1b' && i + 1 < raw.length) {
const next = raw[i + 1]
if (next === '[') {
i += 2
let paramStr = ''
while (i < raw.length && raw[i] >= '\x20' && raw[i] <= '\x3f') {
paramStr += raw[i]
i++
}
const finalByte = i < raw.length ? raw[i] : ''
i++
const params = paramStr.length > 0
? paramStr.split(';').map(s => parseInt(s, 10) || 0)
: [0]
switch (finalByte) {
case 'm':
currentStyle = parseSGR(params, currentStyle)
break
case 'A':
cursorRow = Math.max(0, cursorRow - (params[0] || 1))
break
case 'B':
cursorRow += (params[0] || 1)
ensureRow(cursorRow)
break
case 'C':
cursorCol += (params[0] || 1)
break
case 'D':
cursorCol = Math.max(0, cursorCol - (params[0] || 1))
break
case 'G':
cursorCol = Math.max(0, (params[0] || 1) - 1)
break
case 'H':
case 'f':
cursorRow = Math.max(0, (params[0] || 1) - 1)
cursorCol = Math.max(0, (params[1] || 1) - 1)
ensureRow(cursorRow)
break
case 'J': {
const mode = params[0] || 0
if (mode === 2 || mode === 3) {
lines.length = 0
lines.push([])
cursorRow = 0
cursorCol = 0
} else if (mode === 0) {
ensureRow(cursorRow)
lines[cursorRow].length = cursorCol
for (let r = cursorRow + 1; r < lines.length; r++) lines[r] = []
} else if (mode === 1) {
for (let r = 0; r < cursorRow; r++) lines[r] = []
ensureCol(cursorRow, cursorCol)
for (let c = 0; c <= cursorCol; c++) lines[cursorRow][c] = { char: ' ', style: {} }
}
break
}
case 'K': {
const mode = params[0] || 0
ensureRow(cursorRow)
const line = lines[cursorRow]
if (mode === 0) {
line.length = cursorCol
} else if (mode === 1) {
ensureCol(cursorRow, cursorCol)
for (let c = 0; c <= cursorCol; c++) line[c] = { char: ' ', style: {} }
} else if (mode === 2) {
lines[cursorRow] = []
}
break
}
default:
break
}
continue
}
if (next === ']') {
i += 2
while (i < raw.length && raw[i] !== '\x07' && !(raw[i] === '\x1b' && raw[i + 1] === '\\')) {
i++
}
if (i < raw.length && raw[i] === '\x07') i++
else if (i < raw.length) i += 2
continue
}
i += 2
continue
}
if (ch === '\r') {
cursorCol = 0
i++
continue
}
if (ch === '\n') {
cursorRow++
cursorCol = 0
ensureRow(cursorRow)
i++
continue
}
if (ch === '\b') {
cursorCol = Math.max(0, cursorCol - 1)
i++
continue
}
if (ch === '\t') {
const nextTabStop = (Math.floor(cursorCol / 8) + 1) * 8
while (cursorCol < nextTabStop) {
ensureCol(cursorRow, cursorCol)
lines[cursorRow][cursorCol] = { char: ' ', style: { ...currentStyle } }
cursorCol++
}
i++
continue
}
if (ch.charCodeAt(0) < 32) {
i++
continue
}
ensureCol(cursorRow, cursorCol)
lines[cursorRow][cursorCol] = { char: ch, style: { ...currentStyle } }
cursorCol++
i++
}
return lines.map(cells => {
const spans: StyledSpan[] = []
if (cells.length === 0) return { spans: [{ text: '', style: {} }] }
let end = cells.length
while (end > 0 && cells[end - 1].char === ' ' && Object.keys(cells[end - 1].style).length === 0) {
end--
}
let currentSpan: StyledSpan | null = null
for (let c = 0; c < end; c++) {
const cell = cells[c]
const sameStyle = currentSpan && styleEquals(currentSpan.style, cell.style)
if (sameStyle && currentSpan) {
currentSpan.text += cell.char
} else {
if (currentSpan) spans.push(currentSpan)
currentSpan = { text: cell.char, style: { ...cell.style } }
}
}
if (currentSpan) spans.push(currentSpan)
if (spans.length === 0) spans.push({ text: '', style: {} })
return { spans }
})
}
function styleEquals(a: SpanStyle, b: SpanStyle): boolean {
return a.bold === b.bold
&& a.dim === b.dim
&& a.italic === b.italic
&& a.underline === b.underline
&& a.strikethrough === b.strikethrough
&& a.fg === b.fg
&& a.bg === b.bg
}
export function spanStyleToCSS(style: SpanStyle): React.CSSProperties | undefined {
if (Object.keys(style).length === 0) return undefined
const css: React.CSSProperties = {}
if (style.fg) css.color = style.fg
if (style.bg) css.backgroundColor = style.bg
if (style.bold) css.fontWeight = 'bold'
if (style.dim) css.opacity = 0.6
if (style.italic) css.fontStyle = 'italic'
if (style.underline) css.textDecoration = 'underline'
if (style.strikethrough) {
css.textDecoration = css.textDecoration ? `${css.textDecoration} line-through` : 'line-through'
}
return Object.keys(css).length > 0 ? css : undefined
}

View file

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
/* Tiptap Editor Styles */
.tiptap-editor {
@ -654,155 +656,11 @@
color: color-mix(in srgb, var(--foreground) 38%, transparent);
}
/* =============================================================
Track Block inline chip (display-only)
The chip just opens a modal (TrackModal). All mutations live in the
modal and go through IPC, so the editor never writes track state.
(Track inline chip and target-marker styles removed tracks now
live entirely in the note's frontmatter and are managed via the
right-side track sidebar.)
============================================================= */
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
--track-accent: #64748b; /* default: manual/slate */
margin: 4px 0;
display: inline-block;
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
.tiptap-editor .ProseMirror .track-block-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 12px;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
color: var(--foreground);
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
border-left: 3px solid var(--track-accent);
border-radius: 999px;
cursor: pointer;
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
user-select: none;
}
.tiptap-editor .ProseMirror .track-block-chip:hover {
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent);
}
.tiptap-editor .ProseMirror .track-block-chip:active {
transform: translateY(0.5px);
}
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
opacity: 0.65;
}
.tiptap-editor .ProseMirror .track-block-chip-running {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
animation: track-chip-pulse 2s ease-in-out infinite;
}
@keyframes track-chip-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
}
.tiptap-editor .ProseMirror .track-block-chip-icon {
flex-shrink: 0;
color: var(--track-accent);
}
.tiptap-editor .ProseMirror .track-block-chip-id {
font-weight: 600;
color: var(--track-accent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-sep {
color: color-mix(in srgb, var(--foreground) 25%, transparent);
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-instruction {
color: color-mix(in srgb, var(--foreground) 80%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
background: color-mix(in srgb, var(--foreground) 10%, transparent);
padding: 1px 6px;
border-radius: 999px;
}
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
outline: 2px solid var(--track-accent);
outline-offset: 2px;
}
/* =============================================================
Track target markers thin visual bookends around a track's
output region. The content BETWEEN these markers is normal,
editable document content (rendered by the existing extensions).
============================================================= */
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
position: relative;
height: 1px;
margin: 14px 0 6px 0;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
content: 'track: ' attr(data-track-id);
position: absolute;
top: -8px;
left: 8px;
padding: 0 6px;
background: var(--background, #fff);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
text-transform: none;
white-space: nowrap;
pointer-events: auto;
}
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
height: 1px;
margin: 6px 0 14px 0;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
pointer-events: none;
}
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
outline-offset: 1px;
pointer-events: auto;
}
/* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper,
.tiptap-editor .ProseMirror .embed-block-wrapper,
@ -816,6 +674,49 @@
margin: 8px 0;
}
/* Consecutive email blocks — zero gap, shared outer border */
/* Kill margins between adjacent email wrappers */
.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) {
margin-bottom: 0;
}
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper {
margin-top: 0;
}
/* Strip card border/radius from every card inside a sequence */
.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card,
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper .email-block-card {
border-radius: 0;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
/* First in group: restore top border + top radius */
.tiptap-editor .ProseMirror .email-block-wrapper:not(.email-block-wrapper + .email-block-wrapper):has(+ .email-block-wrapper) .email-block-card {
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
/* Last in group: restore bottom border + bottom radius, remove hairline */
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:not(:has(+ .email-block-wrapper)) .email-block-card {
border-bottom: 1px solid var(--border);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Middle cards: just left + right borders */
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card {
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.tiptap-editor .ProseMirror .image-block-card,
.tiptap-editor .ProseMirror .embed-block-card,
.tiptap-editor .ProseMirror .iframe-block-card,
@ -1418,141 +1319,209 @@
/* Email block Gmail style */
.tiptap-editor .ProseMirror .email-block-card-gmail {
background-color: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-block-card-gmail:hover {
background-color: var(--background);
}
/* Email badge */
.tiptap-editor .ProseMirror .email-block-badge {
display: inline-flex;
/* Gmail-style two-column row */
.tiptap-editor .ProseMirror .email-gmail-row {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
margin-bottom: 8px;
gap: 12px;
cursor: pointer;
padding: 2px 0;
border-radius: 4px;
transition: background 0.1s ease;
user-select: none;
}
/* Summary */
.tiptap-editor .ProseMirror .email-block-summary {
.tiptap-editor .ProseMirror .email-gmail-row:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-row.email-gmail-row-expanded {
padding-bottom: 12px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
margin-bottom: 2px;
}
/* Sender avatar circle */
.tiptap-editor .ProseMirror .email-gmail-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 500;
color: var(--foreground);
line-height: 1.4;
margin-bottom: 10px;
color: #fff;
flex-shrink: 0;
letter-spacing: 0;
}
/* Expand button */
.tiptap-editor .ProseMirror .email-block-expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
font-size: 13px;
font-weight: 400;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
background: none;
border: none;
cursor: pointer;
transition: color 0.12s ease;
margin-bottom: 4px;
}
.tiptap-editor .ProseMirror .email-block-expand-btn:hover {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-block-expand-meta {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
}
.tiptap-editor .ProseMirror .email-block-toggle-chevron {
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-block-toggle-chevron-open {
transform: rotate(180deg);
}
/* Email details (expanded) */
.tiptap-editor .ProseMirror .email-block-email-details {
margin-top: 10px;
padding: 12px;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tiptap-editor .ProseMirror .email-block-message {
padding: 0;
}
.tiptap-editor .ProseMirror .email-block-message-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.tiptap-editor .ProseMirror .email-block-sender-info {
display: flex;
flex-direction: column;
min-width: 0;
/* Content column */
.tiptap-editor .ProseMirror .email-gmail-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-block-sender-row {
.tiptap-editor .ProseMirror .email-gmail-top-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.tiptap-editor .ProseMirror .email-block-sender-name {
.tiptap-editor .ProseMirror .email-gmail-sender {
font-size: 14px;
font-weight: 500;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-block-sender-date {
.tiptap-editor .ProseMirror .email-gmail-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-block-subject-line {
font-size: 12px;
.tiptap-editor .ProseMirror .email-gmail-bottom-row {
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.tiptap-editor .ProseMirror .email-gmail-subject {
color: color-mix(in srgb, var(--foreground) 80%, transparent);
font-weight: 500;
}
.tiptap-editor .ProseMirror .email-gmail-snippet {
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .email-block-message-body {
/* Chevron */
.tiptap-editor .ProseMirror .email-gmail-chevron {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-gmail-chevron.email-gmail-chevron-open {
transform: rotate(180deg);
}
/* Expanded area */
.tiptap-editor .ProseMirror .email-gmail-expanded {
padding-top: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tiptap-editor .ProseMirror .email-gmail-exp-subject {
font-size: 18px;
font-weight: 400;
color: var(--foreground);
line-height: 1.35;
letter-spacing: -0.01em;
}
/* Metadata strip (avatar + from/to/date + open button) */
.tiptap-editor .ProseMirror .email-gmail-exp-meta {
display: flex;
align-items: flex-start;
gap: 10px;
}
.tiptap-editor .ProseMirror .email-gmail-exp-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #fff;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-gmail-exp-meta-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-gmail-exp-sender {
font-size: 14px;
font-weight: 500;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-gmail-exp-to-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.tiptap-editor .ProseMirror .email-gmail-exp-fulldate {
color: color-mix(in srgb, var(--foreground) 40%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-open-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: none;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-gmail-open-btn:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
/* Email body */
.tiptap-editor .ProseMirror .email-gmail-exp-body {
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 80%, transparent);
white-space: pre-wrap;
line-height: 1.58;
line-height: 1.6;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-block-context-section {
/* Earlier conversation */
.tiptap-editor .ProseMirror .email-gmail-exp-history {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-block-context-label {
.tiptap-editor .ProseMirror .email-gmail-exp-history-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
@ -1560,68 +1529,88 @@
color: color-mix(in srgb, var(--foreground) 40%, transparent);
}
.tiptap-editor .ProseMirror .email-block-context-summary {
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
line-height: 1.58;
.tiptap-editor .ProseMirror .email-gmail-exp-history-body {
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
line-height: 1.55;
white-space: pre-wrap;
padding-left: 12px;
border-left: 3px solid color-mix(in srgb, var(--foreground) 12%, transparent);
}
/* Draft section */
.tiptap-editor .ProseMirror .email-block-draft-section {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-radius: 6px;
/* Compose / draft box */
.tiptap-editor .ProseMirror .email-gmail-compose {
margin-top: 4px;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.tiptap-editor .ProseMirror .email-block-draft-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
margin-bottom: 4px;
.tiptap-editor .ProseMirror .email-gmail-compose-to {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.tiptap-editor .ProseMirror .email-draft-block-body-input {
.tiptap-editor .ProseMirror .email-gmail-compose-to-label {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-compose-to-addr {
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-compose-body {
width: 100%;
font-size: 14px;
color: var(--foreground);
background: none;
border: none;
outline: none;
padding: 4px 0;
padding: 10px 12px;
font-family: inherit;
line-height: 1.58;
resize: none;
overflow: hidden;
box-sizing: border-box;
}
.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder {
.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
}
/* Action buttons */
.tiptap-editor .ProseMirror .email-block-actions {
.tiptap-editor .ProseMirror .email-gmail-compose-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.tiptap-editor .ProseMirror .email-block-gmail-btn {
/* Action buttons */
.tiptap-editor .ProseMirror .email-gmail-actions {
display: flex;
align-items: center;
gap: 8px;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-gmail-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: transparent;
border: 1px solid var(--border);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
border-radius: 18px;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
@ -1629,24 +1618,19 @@
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
.tiptap-editor .ProseMirror .email-gmail-btn:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-block-gmail-btn:disabled {
opacity: 0.6;
cursor: default;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary {
.tiptap-editor .ProseMirror .email-gmail-btn-primary {
color: #fff;
background: #1a73e8;
border-color: #1a73e8;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary:hover:not(:disabled) {
.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover {
background: #1765cc;
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
color: #fff;
@ -1661,6 +1645,167 @@
font-size: 14px;
}
/* Reply / Forward pill buttons (in expanded view) */
.tiptap-editor .ProseMirror .email-gmail-reply-row {
display: flex;
gap: 8px;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-gmail-reply-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 20px;
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
background: transparent;
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
border-radius: 18px;
cursor: pointer;
transition: background 0.12s ease, box-shadow 0.12s ease;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-gmail-reply-btn:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-gmail-reply-row-end {
margin-left: auto;
}
/* ---- Emails inbox block (language-emails) ---- */
.tiptap-editor .ProseMirror .email-inbox-card {
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-inbox-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .email-inbox-list {
display: flex;
flex-direction: column;
}
/* Each email row — hairline separator only, no card */
.tiptap-editor .ProseMirror .email-inbox-row {
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-row:last-child {
border-bottom: none;
}
.tiptap-editor .ProseMirror .email-inbox-row-header {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 4px 7px 0;
cursor: pointer;
transition: background 0.1s ease;
user-select: none;
border-radius: 4px;
}
.tiptap-editor .ProseMirror .email-inbox-row-header:hover {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-row.email-inbox-row-expanded .email-inbox-row-header {
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
/* Avatar */
.tiptap-editor .ProseMirror .email-inbox-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
color: #fff;
flex-shrink: 0;
}
/* Content column — two-line layout */
.tiptap-editor .ProseMirror .email-inbox-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-inbox-top-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.tiptap-editor .ProseMirror .email-inbox-sender {
font-size: 14px;
font-weight: 500;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-inbox-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-inbox-bottom-row {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-inbox-subject {
font-weight: 500;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-inbox-snippet {
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-weight: 400;
}
/* Expand chevron */
.tiptap-editor .ProseMirror .email-inbox-chevron {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 35%, transparent);
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-inbox-chevron.email-inbox-chevron-open {
transform: rotate(180deg);
}
/* Expanded content padding */
.tiptap-editor .ProseMirror .email-inbox-expanded-wrap {
padding: 8px 0 12px 0;
border-top: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
}
/* Transcript block */
.tiptap-editor .ProseMirror .transcript-block-toggle {
display: flex;

View file

@ -1,5 +1,7 @@
/* =============================================================
Track Modal dialog overlay for track block details / edits
Track sidebar styles. Filename is legacy (predates the modal
sidebar refactor); the .track-modal-* class names are reused by
the sidebar's detail-view layout.
============================================================= */
.track-modal-content {
@ -309,3 +311,167 @@
.track-modal-run-btn:hover {
background: color-mix(in srgb, var(--track-accent) 85%, black);
}
/* =============================================================
Track sidebar right panel that lists/edits tracks for the
currently-open note. Reuses the .track-modal-* inner styles.
============================================================= */
.track-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(420px, calc(100vw - 2rem));
z-index: 60;
display: flex;
flex-direction: column;
background: var(--background, #fff);
border-left: 1px solid var(--border);
box-shadow: -8px 0 24px -12px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.track-sidebar-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
min-height: 48px;
}
.track-sidebar-back,
.track-sidebar-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
}
.track-sidebar-back:hover,
.track-sidebar-close:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
.track-sidebar-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 14px;
font-weight: 600;
min-width: 0;
}
.track-sidebar-subtitle {
font-size: 11px;
font-weight: 400;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-sidebar-list {
flex: 1;
overflow: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.track-sidebar-empty {
padding: 24px 16px;
text-align: center;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
font-size: 13px;
display: flex;
flex-direction: column;
gap: 4px;
}
.track-sidebar-empty-hint {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.track-sidebar-row {
--track-accent: #64748b;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
background: var(--background);
border: 1px solid var(--border);
border-left: 3px solid var(--track-accent);
border-radius: 8px;
text-align: left;
cursor: pointer;
transition: background-color 0.12s ease;
}
.track-sidebar-row[data-trigger="scheduled"] { --track-accent: #6366f1; }
.track-sidebar-row[data-trigger="event"] { --track-accent: #a855f7; }
.track-sidebar-row[data-trigger="manual"] { --track-accent: #64748b; }
.track-sidebar-row[data-active="false"] { opacity: 0.65; }
.track-sidebar-row:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
.track-sidebar-row-icon {
color: var(--track-accent);
margin-top: 2px;
}
.track-sidebar-row-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.track-sidebar-row-title {
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.track-sidebar-row-sub {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}
.track-sidebar-row-instruction {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-sidebar-detail {
--track-accent: #64748b;
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.track-sidebar-detail[data-trigger="scheduled"] { --track-accent: #6366f1; }
.track-sidebar-detail[data-trigger="event"] { --track-accent: #a855f7; }
.track-sidebar-detail[data-trigger="manual"] { --track-accent: #64748b; }
.track-sidebar-detail[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }

View file

@ -35,6 +35,19 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
function loadUserWorkDir(): string | null {
try {
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
const parsed = JSON.parse(raw) as { path?: unknown };
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
return value || null;
} catch {
return null;
}
}
function loadAgentNotesContext(): string | null {
const sections: string[] = [];
@ -163,6 +176,7 @@ export class AgentRuntime implements IAgentRuntime {
modelConfigRepo: this.modelConfigRepo,
signal,
abortRegistry: this.abortRegistry,
bus: this.bus,
})) {
eventCount++;
if (event.type !== "llm-stream-event") {
@ -855,6 +869,7 @@ export async function* streamAgent({
modelConfigRepo,
signal,
abortRegistry,
bus,
}: {
state: AgentState,
idGenerator: IMonotonicallyIncreasingIdGenerator;
@ -863,6 +878,7 @@ export async function* streamAgent({
modelConfigRepo: IModelConfigRepo;
signal: AbortSignal;
abortRegistry: IAbortRegistry;
bus: IBus;
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
@ -976,6 +992,7 @@ export async function* streamAgent({
modelConfigRepo,
signal,
abortRegistry,
bus,
})) {
yield* processEvent({
...event,
@ -986,7 +1003,13 @@ export async function* streamAgent({
result = subflowState.finalResponse();
}
} else {
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, {
runId,
toolCallId,
signal,
abortRegistry,
publish: (event) => bus.publish(event),
});
}
} catch (error) {
if ((error instanceof Error && error.name === "AbortError") || signal.aborted) {
@ -1094,6 +1117,28 @@ export async function* streamAgent({
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
const userWorkDir = loadUserWorkDir();
if (userWorkDir) {
loopLogger.log('injecting user work directory', userWorkDir);
instructionsWithDateTime += `\n\n# User Work Directory
The user has chosen the following directory as their current **work directory**:
\`${userWorkDir}\`
Treat this as the **default location** for file operations whenever the user refers to files generically:
- "list the files", "show me what's in here", "what's the latest report" list or look in the work directory.
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
Do not announce the work directory unless it's relevant. Just use it.`;
}
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
// that supersedes any earlier middle-pane mention in the conversation history.
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;

View file

@ -1,6 +1,35 @@
import { bus } from "../runs/bus.js";
import { fetchRun } from "../runs/runs.js";
type RunRecord = Awaited<ReturnType<typeof fetchRun>>;
function extractRunErrors(run: RunRecord): string[] {
return run.log.flatMap((event) => event.type === "error" ? [event.error] : []);
}
export class RunFailedError extends Error {
readonly runId: string;
readonly errors: string[];
constructor(runId: string, errors: string[]) {
const firstError = errors.find(Boolean) ?? null;
super(firstError ? `Run ${runId} failed: ${firstError}` : `Run ${runId} failed`);
this.name = "RunFailedError";
this.runId = runId;
this.errors = errors;
}
}
export function getErrorDetails(error: unknown): string {
if (error instanceof RunFailedError) {
return error.errors.join("\n\n");
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/**
* Extract the assistant's final text response from a run's log.
* @param runId
@ -28,13 +57,28 @@ export async function extractAgentResponse(runId: string): Promise<string | null
/**
* Wait for a run to complete by listening for run-processing-end event
*/
export async function waitForRunCompletion(runId: string): Promise<void> {
return new Promise(async (resolve) => {
export async function waitForRunCompletion(
runId: string,
opts: { throwOnError?: boolean } = {},
): Promise<RunRecord> {
return new Promise((resolve, reject) => {
void (async () => {
const unsubscribe = await bus.subscribe('*', async (event) => {
if (event.type === 'run-processing-end' && event.runId === runId) {
unsubscribe();
resolve();
try {
const run = await fetchRun(runId);
const errors = extractRunErrors(run);
if (opts.throwOnError && errors.length > 0) {
reject(new RunFailedError(runId, errors));
return;
}
resolve(run);
} catch (error) {
reject(error);
}
}
});
})().catch(reject);
});
}

View file

@ -80,11 +80,21 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
**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.
**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 Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
**Tracks (Auto-Updating Notes):** A note's body can be partially or fully agent-maintained — *living* notes that refresh on a schedule or react to incoming emails / calendar events. This is a flagship feature. **Listen for any signal that the user wants something to keep itself updated**, even when they don't use the word "track" load the \`tracks\` skill the moment you spot one.
*Strong signals (load the skill, act without asking):* "every morning / daily / hourly…", "keep a running summary of…", "maintain a digest of…", "watch / monitor / keep an eye on…", "pin live updates of…", "track / follow X", "whenever a relevant email comes in…".
*Medium signals (load the skill, answer the one-off question, then offer to keep it updated):* one-off questions about decaying info ("what's the weather?", "top HN stories?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here", "put my open tasks here"), or recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard").
A track is a directive in a note's frontmatter (\`track:\` array entry) with one or more triggers (cron / window / once / event). Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor). When you set one up, tell them where to find it.
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
**Notifications:** When you need to send a desktop notification completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
## Learning About the User (save-to-memory)

View file

@ -9,7 +9,15 @@ export interface RuntimeContext {
}
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
if (platform === 'win32') {
return process.env.ComSpec || 'cmd.exe';
}
if (process.env.SHELL) {
return process.env.SHELL;
}
return platform === 'darwin' ? '/bin/zsh' : '/bin/sh';
}
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {

View file

@ -1,555 +0,0 @@
export const skill = String.raw`
# Background Agents
Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace.
## Core Concepts
**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent.
- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
- Agents configure a model, tools (in frontmatter), and instructions (in the body)
- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents**
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root
## How multi-agent workflows work
1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + `
2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below)
3. The orchestrator calls other agents as tools when needed
4. Data flows through tool call parameters and responses
## Scheduling Background Agents
Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root.
### Schedule Configuration File
` + "```json" + `
{
"agents": {
"agent_name": {
"schedule": { ... },
"enabled": true
}
}
}
` + "```" + `
### Schedule Types
**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat).
**1. Cron Schedule** - Runs at exact times defined by cron expression
` + "```json" + `
{
"schedule": {
"type": "cron",
"expression": "0 8 * * *"
},
"enabled": true
}
` + "```" + `
Common cron expressions:
- ` + "`*/5 * * * *`" + ` - Every 5 minutes
- ` + "`0 8 * * *`" + ` - Every day at 8am
- ` + "`0 9 * * 1`" + ` - Every Monday at 9am
- ` + "`0 0 1 * *`" + ` - First day of every month at midnight
**2. Window Schedule** - Runs once during a time window
` + "```json" + `
{
"schedule": {
"type": "window",
"cron": "0 0 * * *",
"startTime": "08:00",
"endTime": "10:00"
},
"enabled": true
}
` + "```" + `
The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am").
**3. Once Schedule** - Runs exactly once at a specific time
` + "```json" + `
{
"schedule": {
"type": "once",
"runAt": "2024-02-05T10:30:00"
},
"enabled": true
}
` + "```" + `
Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix).
### Starting Message
You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `.
` + "```json" + `
{
"schedule": { "type": "cron", "expression": "0 8 * * *" },
"enabled": true,
"startingMessage": "Please summarize my emails from the last 24 hours"
}
` + "```" + `
### Description
You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI.
` + "```json" + `
{
"schedule": { "type": "cron", "expression": "0 8 * * *" },
"enabled": true,
"description": "Summarizes emails and calendar events every morning"
}
` + "```" + `
### Complete Schedule Example
` + "```json" + `
{
"agents": {
"daily_digest": {
"schedule": {
"type": "cron",
"expression": "0 8 * * *"
},
"enabled": true,
"description": "Daily email and calendar summary",
"startingMessage": "Summarize my emails and calendar for today"
},
"morning_briefing": {
"schedule": {
"type": "window",
"cron": "0 0 * * *",
"startTime": "07:00",
"endTime": "09:00"
},
"enabled": true,
"description": "Morning news and updates briefing"
},
"one_time_setup": {
"schedule": {
"type": "once",
"runAt": "2024-12-01T12:00:00"
},
"enabled": true,
"description": "One-time data migration task"
}
}
}
` + "```" + `
### Schedule State (Read-Only)
**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root:
- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules)
- ` + "`lastRunAt`" + `: When the agent last ran
- ` + "`nextRunAt`" + `: When the agent will run next
- ` + "`lastError`" + `: Error message if the last run failed
- ` + "`runCount`" + `: Total number of runs
When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `.
## Agent File Format
Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions.
### Basic Structure
` + "```markdown" + `
---
model: gpt-5.1
tools:
tool_key:
type: builtin
name: tool_name
---
# Instructions
Your detailed instructions go here in Markdown format.
` + "```" + `
### Frontmatter Fields
- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5')
- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json
- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions
### Instructions (Body)
The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting.
### Naming Rules
- Agent filename determines the agent name (without .md extension)
- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent"
- Use lowercase with underscores for multi-word names
- No spaces or special characters in names
- **The agent name in agent-schedule.json must match the filename** (without .md)
### Agent Format Example
` + "```markdown" + `
---
model: gpt-5.1
tools:
search:
type: mcp
name: firecrawl_search
description: Search the web
mcpServerName: firecrawl
inputSchema:
type: object
properties:
query:
type: string
description: Search query
required:
- query
---
# Web Search Agent
You are a web search agent. When asked a question:
1. Use the search tool to find relevant information
2. Summarize the results clearly
3. Cite your sources
Be concise and accurate.
` + "```" + `
## Tool Types & Schemas
Tools in agents must follow one of three types. Each has specific required fields.
### 1. Builtin Tools
Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.)
**YAML Schema:**
` + "```yaml" + `
tool_key:
type: builtin
name: tool_name
` + "```" + `
**Required fields:**
- ` + "`type`" + `: Must be "builtin"
- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile")
**Example:**
` + "```yaml" + `
bash:
type: builtin
name: executeCommand
` + "```" + `
**Available builtin tools:**
- ` + "`executeCommand`" + ` - Execute shell commands
- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations
- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations
- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management
- ` + "`analyzeAgent`" + ` - Analyze agent structure
- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management
- ` + "`loadSkill`" + ` - Load skill guidance
### 2. MCP Tools
Tools from external MCP servers (APIs, databases, web scraping, etc.)
**YAML Schema:**
` + "```yaml" + `
tool_key:
type: mcp
name: tool_name_from_server
description: What the tool does
mcpServerName: server_name_from_config
inputSchema:
type: object
properties:
param:
type: string
description: Parameter description
required:
- param
` + "```" + `
**Required fields:**
- ` + "`type`" + `: Must be "mcp"
- ` + "`name`" + `: Exact tool name from MCP server
- ` + "`description`" + `: What the tool does (helps agent understand when to use it)
- ` + "`mcpServerName`" + `: Server name from config/mcp.json
- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters
**Example:**
` + "```yaml" + `
search:
type: mcp
name: firecrawl_search
description: Search the web
mcpServerName: firecrawl
inputSchema:
type: object
properties:
query:
type: string
description: Search query
required:
- query
` + "```" + `
**Important:**
- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server
- Copy the schema exactlydon't modify property types or structure
- Only include ` + "`required`" + ` array if parameters are mandatory
### 3. Agent Tools (for chaining agents)
Reference other agents as tools to build multi-agent workflows
**YAML Schema:**
` + "```yaml" + `
tool_key:
type: agent
name: target_agent_name
` + "```" + `
**Required fields:**
- ` + "`type`" + `: Must be "agent"
- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory)
**Example:**
` + "```yaml" + `
summariser:
type: agent
name: summariser_agent
` + "```" + `
**How it works:**
- Use ` + "`type: agent`" + ` to call other agents as tools
- The target agent will be invoked with the parameters you pass
- Results are returned as tool output
- This is how you build multi-agent workflows
- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `)
## Complete Multi-Agent Workflow Example
**Email digest workflow** - This is all done through agents calling other agents:
**1. Task-specific agent** (` + "`agents/email_reader.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
read_file:
type: builtin
name: workspace-readFile
list_dir:
type: builtin
name: workspace-readdir
---
# Email Reader Agent
Read emails from the gmail_sync folder and extract key information.
Look for unread or recent emails and summarize the sender, subject, and key points.
Don't ask for human input.
` + "```" + `
**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
email_reader:
type: agent
name: email_reader
write_file:
type: builtin
name: workspace-writeFile
---
# Daily Summary Agent
1. Use the email_reader tool to get email summaries
2. Create a consolidated daily digest
3. Save the digest to ~/Desktop/daily_digest.md
Don't ask for human input.
` + "```" + `
Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions.
**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
daily_summary:
type: agent
name: daily_summary
search:
type: mcp
name: search
mcpServerName: exa
description: Search the web for news
inputSchema:
type: object
properties:
query:
type: string
description: Search query
---
# Morning Briefing Workflow
Create a morning briefing:
1. Get email digest using daily_summary
2. Search for relevant news using the search tool
3. Compile a comprehensive morning briefing
Execute these steps in sequence. Don't ask for human input.
` + "```" + `
**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `:
` + "```json" + `
{
"agents": {
"morning_briefing": {
"schedule": {
"type": "cron",
"expression": "0 7 * * *"
},
"enabled": true,
"startingMessage": "Create my morning briefing for today"
}
}
}
` + "```" + `
This schedules the morning briefing workflow to run every day at 7am local time.
## Naming and organization rules
- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
- Agent filename (without .md) becomes the agent name
- When referencing an agent as a tool, use its filename without extension
- When scheduling an agent, use its filename without extension in agent-schedule.json
- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users
## Best practices for background agents
1. **Single responsibility**: Each agent should do one specific thing well
2. **Clear delegation**: Agent instructions should explicitly say when to call other agents
3. **Autonomous operation**: Add "Don't ask for human input" for background agents
4. **Data passing**: Make it clear what data to extract and pass between agents
5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze")
6. **Orchestration**: Create a top-level agent that coordinates the workflow
7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks
8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene
9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations
10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md"
## Validation & Best Practices
### CRITICAL: Schema Compliance
- Agent files MUST be valid Markdown with YAML frontmatter
- Agent filename (without .md) becomes the agent name
- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent")
- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema
- Agent tools MUST reference existing agent files
- Invalid agents will fail to load and prevent workflow execution
### File Creation/Update Process
1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter
2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + `
3. Validate YAML syntax in frontmatter before writingmalformed YAML breaks the agent
4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `)
5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + `
### Common Validation Errors to Avoid
**WRONG - Missing frontmatter delimiters:**
` + "```markdown" + `
model: gpt-5.1
# My Agent
Instructions here
` + "```" + `
**WRONG - Invalid YAML indentation:**
` + "```markdown" + `
---
tools:
bash:
type: builtin
---
` + "```" + `
(bash should be indented under tools)
**WRONG - Invalid tool type:**
` + "```yaml" + `
tools:
tool1:
type: custom
name: something
` + "```" + `
(type must be builtin, mcp, or agent)
**WRONG - Unquoted strings containing colons:**
` + "```yaml" + `
tools:
search:
description: Number of results (default: 8)
` + "```" + `
(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `)
**WRONG - MCP tool missing required fields:**
` + "```yaml" + `
tools:
search:
type: mcp
name: firecrawl_search
` + "```" + `
(Missing: description, mcpServerName, inputSchema)
**CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
---
# Simple Agent
Do simple tasks as instructed.
` + "```" + `
**CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
search:
type: mcp
name: firecrawl_search
description: Search the web
mcpServerName: firecrawl
inputSchema:
type: object
properties:
query:
type: string
---
# Search Agent
Use the search tool to find information on the web.
` + "```" + `
## Capabilities checklist
1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing
2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes
3. Validate YAML frontmatter syntax before creating/updating agents
4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update
5. When creating multi-agent workflows, create an orchestrator agent
6. Add other agents as tools with ` + "`type: agent`" + ` for chaining
7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations
8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
9. Confirm work done and outline next steps once changes are complete
`;
export default skill;

View file

@ -14,8 +14,10 @@ Use this skill when the user asks you to open a website, browse in-app, search t
- page ` + "`url`" + ` and ` + "`title`" + `
- visible page text
- interactable elements with numbered ` + "`index`" + ` values
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
5. After each action, read the returned page snapshot before deciding the next step.
- ` + "`suggestedSkills`" + ` site-specific and interaction-specific skill hints for the current page
4. **Always inspect ` + "`suggestedSkills`" + ` before acting.** If any skill in the list matches what the user asked for (site or task), call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` *first*, read it in full, then plan your actions. These skills encode selectors, timing, and gotchas that would otherwise cost you several failed attempts to rediscover. If no skill matches, proceed — but do not skip this check.
5. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
6. After each action, read the returned page snapshot before deciding the next step including re-checking ` + "`suggestedSkills`" + ` if the navigation landed you on a new domain.
## Actions
@ -92,12 +94,23 @@ Wait for the page to settle, useful after async UI changes.
Parameters:
- ` + "`ms`" + `: milliseconds to wait (optional)
## Companion Tools
### load-browser-skill
Rowboat caches a library of browser skills (from ` + "`browser-use/browser-harness`" + `) indexed by both **domain** (github, linkedin, amazon, booking, ) and **interaction type** within a domain (e.g. ` + "`github/repo-actions`" + `, ` + "`github/scraping`" + `, ` + "`arxiv-bulk/*`" + `). Whenever ` + "`browser-control`" + ` returns a ` + "`suggestedSkills`" + ` array which it does on ` + "`navigate`" + `, ` + "`new-tab`" + `, and ` + "`read-page`" + ` treat it as a required reading step, not optional. Pick the entry that matches the current task (domain match first, then the interaction-specific variant if one exists) and call ` + "`load-browser-skill({ id: \"<id>\" })`" + ` before attempting the action.
You can also proactively call ` + "`load-browser-skill({ action: \"list\", site: \"<site>\" })`" + ` when you know you're about to work on a site, to see what skills exist even if ` + "`suggestedSkills`" + ` is empty (e.g. before navigating).
These skills are written against a Python harness, so treat them as **reference knowledge**. Reuse the selectors, timing, and sequencing, but adapt them to Rowboat's structured browser actions. **Do not look for or call ` + "`http-fetch`" + `.** If a browser-harness recipe suggests ` + "`js(...)`" + ` or ` + "`http_get(...)`" + ` style shortcuts, treat those as non-portable and fall back to reading and interacting with the page itself.
## Important Rules
- Prefer ` + "`read-page`" + ` before interacting.
- Prefer element ` + "`index`" + ` over CSS selectors.
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
- After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state.
- **Always check ` + "`suggestedSkills`" + ` after ` + "`navigate`" + `, ` + "`new-tab`" + `, or ` + "`read-page`" + `, and load the matching domain or interaction skill before acting.** Skipping this step is the single most common way to waste a dozen failed clicks on a site whose quirks are already documented. If the array is empty, proceed normally but don't skip the check.
- Do not try to use ` + "`http-fetch`" + `. If a browser-harness recipe mentions ` + "`http_get(...)`" + ` or a public API shortcut, adapt it to DOM-based browsing instead.
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.

View file

@ -0,0 +1,90 @@
export const skill = String.raw`
# Code with Agents Skill
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
## Important: delegate ALL coding work
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
- Writing, editing, or refactoring code
- Reading, summarizing, or explaining code
- Debugging and fixing bugs
- Running tests or build commands
- Exploring project structure
- Any other task that involves interacting with a codebase
Do NOT attempt to do any of these yourself no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
## Prerequisites
The user must have one of the following installed on their machine:
- **Claude Code** https://claude.ai/code
- **Codex** https://codex.openai.com
These are external tools that you cannot install for the user.
## Workflow
### Step 1: Gather requirements
Before running anything, confirm the following with the user:
1. **Working directory** Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
2. **Agent choice** Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
### Step 2: Confirm execution plan
Once you know the folder and agent, tell the user:
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
### Step 3: Execute with acpx
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
**For Claude Code:**
` + "`" + `
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
` + "`" + `
**For Codex:**
` + "`" + `
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
` + "`" + `
### Critical: flag order
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
` + "`" + `
npx acpx@latest [global flags] <agent> exec "<prompt>"
` + "`" + `
**Correct:**
` + "`" + `
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
` + "`" + `
**Wrong (will fail):**
` + "`" + `
npx acpx@latest claude --approve-all exec "fix the bug"
` + "`" + `
### Writing good prompts
When constructing the prompt for the coding agent:
- Be specific and detailed about what to build or fix
- Include file names, function signatures, and expected behavior
- Mention any constraints (language, framework, style)
- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
### Step 4: Report results
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
- If the exit code is 5, it means permissions were denied this should not happen with \`--approve-all\`, but if it does, let the user know
`;
export default skill;

View file

@ -7,13 +7,14 @@ import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
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 notifyUserSkill from "./notify-user/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -64,12 +65,6 @@ const definitions: SkillDefinition[] = [
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "background-agents",
title: "Background Agents",
summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.",
content: backgroundAgentsSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",
@ -100,10 +95,16 @@ const definitions: SkillDefinition[] = [
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
content: appNavigationSkill,
},
{
id: "code-with-agents",
title: "Code with Agents",
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
content: codeWithAgentsSkill,
},
{
id: "tracks",
title: "Tracks",
summary: "Create and manage track blocks — YAML-scheduled auto-updating content blocks in notes (weather, news, prices, status, dashboards). Insert at cursor (Cmd+K) or append to notes.",
summary: "Create and manage tracks — frontmatter directives that keep a note's body auto-updated on a schedule, on incoming events, or manually (weather, news, prices, status, dashboards).",
content: tracksSkill,
},
{
@ -112,6 +113,12 @@ const definitions: SkillDefinition[] = [
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
content: browserControlSkill,
},
{
id: "notify-user",
title: "Notify User",
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
content: notifyUserSkill,
},
];
const skillEntries = definitions.map((definition) => ({

View file

@ -0,0 +1,70 @@
export const skill = String.raw`
# Notify User
Load this skill when you need to send a desktop notification to the user e.g. after a long-running task completes, when a track detects something noteworthy, or when an agent wants to ping the user with a clickable result.
## When to use
- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive.
- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit restraint is on you).
## The tool: \`notify-user\`
Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click.
### Parameters
- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top.
- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines.
- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted:
- **\`https://...\` / \`http://...\`** — opens in the default browser
- **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below)
- If omitted, clicking the notification focuses the Rowboat app.
### Examples
Plain alert (no link clicking focuses the app):
\`\`\`json
{
"title": "Backup complete",
"message": "All 142 files synced to iCloud."
}
\`\`\`
External link:
\`\`\`json
{
"title": "New email from Monica",
"message": "Re: Q4 planning — needs your input by Friday",
"link": "https://mail.google.com/mail/u/0/#inbox/abc123"
}
\`\`\`
Deep link into a Rowboat note:
\`\`\`json
{
"message": "Daily brief is ready",
"link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md"
}
\`\`\`
## Deep links: \`rowboat://\`
Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters.
| Target | Format | Example |
|---|---|---|
| Open a file | \`rowboat://open?type=file&path=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` |
| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` |
| Knowledge graph | \`rowboat://open?type=graph\` | — |
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
## Anti-patterns
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
- **Don't repeat what's already on screen.** If the result is already in the chat or in a note the user is viewing, skip the notification.
- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link.
- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done".
`;
export default skill;

View file

@ -1,8 +1,8 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { TrackSchema } from '@x/shared/dist/track.js';
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackSchema)).trimEnd();
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
@ -19,7 +19,7 @@ The track agent can emit *rich blocks* — special fenced blocks the editor rend
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
You **do not** need to write the block body yourself describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
You **do not** need to write the block body yourself describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
@ -28,36 +28,93 @@ You **do not** need to write the block body yourself — describe the desired ou
export const skill = String.raw`
# Tracks Skill
You are helping the user create and manage **track blocks** YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
A track is a directive in a note's YAML frontmatter (under the ` + "`" + `track:` + "`" + ` array) that turns the note's body into a *living* document refreshed on a schedule or reactively when a matching email / calendar event arrives. A note with no ` + "`" + `track:` + "`" + ` key is just static; one or more entries under it make it live. Users manage their tracks in the **Track sidebar** (Radio icon at the top-right of the editor).
## First: Just Do It Do Not Ask About Edit Mode
When this skill is loaded, your job is: set up (or update) a track, run it once so the user immediately sees content, and tell them where to manage it.
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" that prompt belongs to generic document editing, not to tracks.
## Mode: act-first
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
- The Suggested Topics flow below is the one first-turn-confirmation exception leave it intact.
Track creation and editing are action-first. Read the file, update the frontmatter via ` + "`" + `workspace-edit` + "`" + `, run the track once. Do not ask "Should I make edits directly, or show changes first for approval?" that prompt belongs to generic document editing, not to tracks.
## What Is a Track Block
- If another skill or earlier turn was waiting on edit-mode permission, treat the track request as implicit "direct mode" and proceed.
- You may ask **one** short clarifying question only when genuinely ambiguous (e.g. *which* note). Never ask about permission to edit.
- The Suggested Topics and Background Agent setup flows below are first-turn-confirmation exceptions leave those intact.
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
- A sibling "target region" an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
## Reading the user's intent
**Concrete example** (a track that shows the current time in Chicago every hour):
You're loaded any time the user might be asking for something dynamic. Two postures, depending on signal strength:
` + "```" + `track
trackId: chicago-time
### Strong signals act, then confirm
Just build the track. Don't ask permission. Confirm in one line at the end.
- **Cadence words**: "every morning…", "daily…", "each Monday…", "hourly weather here"
- **Living-document verbs**: "keep a running summary of…", "maintain a digest of…", "build up notes on…", "roll up X here"
- **Watch/monitor verbs**: "watch X", "monitor Y", "keep an eye on Z", "follow the Acme deal", "stay on top of…"
- **Pin-live framings**: "pin live updates of…", "always show the latest X here", "keep this fresh"
- **Direct**: "track X" the user used the word; you can too in your reply
- **Event-conditional**: "whenever a relevant email comes in, update…", "if anyone mentions X, capture it here"
### Medium signals answer the one-off, then offer
Answer the user's actual question first. Then add a single-line offer to keep it updated. If they say yes, build the track. If they don't engage, leave it don't push twice.
- **Time-decaying one-offs**: "what's USD/INR right now?", "top HN stories?", "weather?", "status of service X?"
- **Note-anchored snapshots**: "show me my schedule today", "put my open tasks here", "drop the latest commits here" especially when in a note context
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
Offer line shape (one line, concrete):
> "I can keep this updated here, refreshing every morning — want that?"
### Anti-signals do NOT track
- Definitional questions ("what is X?")
- One-off lookups ("look up X for me")
- Manual document work ("help me write…", "edit this paragraph…")
- General how-to ("how do I do Y?")
## What to say to the user
The user knows the feature as **tracks** and finds them in the **Track sidebar**. Speak in those terms; don't expose internals like "frontmatter", "trigger", or "instruction" in user-facing prose unless the user uses them first.
After creating a track, surface where it lives:
> "Done — I've set up a track here that refreshes every morning. Running it once now so you see content right away. You can manage it from the Track sidebar (Radio icon, top-right of the editor)."
After editing one:
> "Updated. Re-running now so you can see the new output."
When skipping a re-run (because the user said not to or "later"):
> "Updated — I'll let it run on its next trigger."
## What Is a Track (concretely)
**Concrete example** a note that shows the current Chicago time, refreshed hourly:
` + "```" + `markdown
---
track:
- id: chicago-time
instruction: |
Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
triggers:
- type: cron
expression: "0 * * * *"
---
# Chicago time
(empty the agent will fill this in on the first run)
` + "```" + `
<!--track-target:chicago-time-->
<!--/track-target:chicago-time-->
After the first run, the body might become:
` + "```" + `markdown
# Chicago time
2:30 PM, Central Time
` + "```" + `
Good use cases:
- Weather / air quality for a location
@ -66,20 +123,35 @@ Good use cases:
- Sports scores
- Service status pages
- Personal dashboards (today's calendar, steps, focus stats)
- Any recurring summary that decays fast
- Living summaries fed by incoming events (emails, meeting notes)
- Any recurring content that decays fast
## Anatomy
Each track has two parts that live next to each other in the note:
A track lives entirely in the note's frontmatter there is no inline marker in the body. The agent writes whatever content the instruction demands into the body itself, choosing where to place it based on the existing structure.
1. The ` + "`" + `track` + "`" + ` code fence contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
2. The target-comment region ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
The frontmatter block is fenced by ` + "`" + `---` + "`" + ` lines at the very top of the file:
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
` + "```" + `markdown
---
track:
- id: <kebab-id>
instruction: |
<what the agent should produce>
active: true
triggers:
- type: cron
expression: "0 * * * *"
---
# Note body
` + "```" + `
A note may have multiple entries under ` + "`" + `track:` + "`" + ` they run independently. Each entry can have multiple triggers (e.g. an hourly cron AND an event trigger). Omit ` + "`" + `triggers` + "`" + ` for a manual-only track.
## Canonical Schema
Below is the authoritative schema for a track block (generated at build time from the TypeScript source never out of date). Use it to validate every field name, type, and constraint before writing YAML:
Below is the authoritative schema for a single track entry (generated at build time from the TypeScript source never out of date). Use it to validate every field name, type, and constraint before writing YAML:
` + "```" + `yaml
${schemaYaml}
@ -100,18 +172,15 @@ Things that are **not** reasons to set these:
- "Tracks should be fast" / "I want a small model" that's a global preference, not a per-track one. Leave it; the global default exists.
- "This track is complex" write a clearer instruction; don't reach for a different model.
- "Just to be safe" / "in case it matters" this is the antipattern. Leave them out.
- The user changed their main chat model that has nothing to do with tracks. Leave them out.
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest. If you find yourself adding them as a sensible default, stop you're wrong.
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest.
## Choosing a trackId
## Choosing an ` + "`" + `id` + "`" + `
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
- **Must be unique within the note file.** Before inserting, read the file and check:
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
- **Must be unique within the note's ` + "`" + `track:` + "`" + ` array.** Before inserting, read the file and check existing ` + "`" + `id:` + "`" + ` values.
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
- Don't reuse an old ID even if the previous block was deleted pick a fresh one.
- Don't reuse an old ID even if a previous entry was deleted pick a fresh one.
## Writing a Good Instruction
@ -122,7 +191,7 @@ Track output lives in a personal knowledge base the user scans frequently. Aim f
### Core Rules
- **Specific and actionable.** State exactly what to fetch or compute.
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
- **Single-focus.** One track = one purpose. Split "weather + news + stocks" into three tracks, don't bundle.
- **Imperative voice, 1-3 sentences.**
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
@ -163,106 +232,131 @@ ${richBlockMenu}
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" they tell the agent nothing concrete.
- **References to past state** without a mechanism to access it ("as before", "same as last time").
- **Bundling multiple purposes** into one instruction split into separate track blocks.
- **Bundling multiple purposes** into one instruction split into separate tracks.
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
## YAML String Style (critical read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
## YAML String Style (critical read before writing any ` + "`" + `instruction` + "`" + ` or event-trigger ` + "`" + `matchCriteria` + "`" + `)
The two free-form fields ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
Real failure seen in the wild an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
The two free-form fields ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
### The rule: always use a safe scalar style
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `, every time.**
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
` + "```" + `yaml
track:
- id: world-clock
instruction: |
Show current local time for India, Chicago, and Indianapolis as a
3-column markdown table: Location | Local Time | Offset vs India.
One row per location, 24-hour time (HH:MM), no extra prose.
Note: when a location is in DST, reflect that in the offset column.
eventMatchCriteria: |
active: true
triggers:
- type: cron
expression: "0 * * * *"
- type: event
matchCriteria: |
Emails from the finance team about Q3 budget or OKRs.
` + "```" + `
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs all literal. No escaping needed.
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
- Leave a real newline after ` + "`" + `|` + "`" + ` content starts on the next line, not the same line.
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
- **Indent every content line by 2 spaces** relative to the key. Use spaces, never tabs.
- Leave a real newline after ` + "`" + `|` + "`" + ` content starts on the next line.
### Acceptable alternative: double-quoted on a single line
Fine for short single-sentence fields with no newline needs:
Fine for short single-sentence fields:
` + "```" + `yaml
track:
- id: chicago-time
instruction: "Show the current time in Chicago, IL in 12-hour format."
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
active: true
` + "```" + `
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
` + "```" + `yaml
instruction: 'He said "hi" at 9:00.'
` + "```" + `
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
- No other escape sequences work.
### Do NOT use plain (unquoted) scalars for these two fields
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits plain scalars are not.
### Editing an existing track
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
Even if the current value looks safe, a future edit may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits.
### Never-hand-write fields
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
## Schedules
## Triggers
Schedule is an **optional** discriminated union. Three types:
A track has zero or more **triggers** under a single ` + "`" + `triggers:` + "`" + ` array. Each trigger is one of four types:
### ` + "`" + `cron` + "`" + ` recurring at exact times
- ` + "`" + `cron` + "`" + ` fires at an exact time, recurring
- ` + "`" + `window` + "`" + ` once per day, anywhere inside a time-of-day band
- ` + "`" + `once` + "`" + ` one-shot at a future time
- ` + "`" + `event` + "`" + ` fires when a matching event arrives (emails, calendar, etc.)
A track can carry **multiple triggers** of any mix. Omit ` + "`" + `triggers` + "`" + ` (or use an empty array) for a **manual-only** track the user triggers it via the Run button in the sidebar.
### ` + "`" + `cron` + "`" + ` trigger
` + "```" + `yaml
schedule:
type: cron
triggers:
- type: cron
expression: "0 * * * *"
` + "```" + `
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
### ` + "`" + `window` + "`" + ` recurring within a time-of-day range
### ` + "`" + `window` + "`" + ` trigger
` + "```" + `yaml
schedule:
type: window
cron: "0 0 * * 1-5"
triggers:
- type: window
startTime: "09:00"
endTime: "17:00"
endTime: "12:00"
` + "```" + `
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" flexible timing with bounds.
Fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at ` + "`" + `startTime` + "`" + ` — once a fire lands at-or-after today's start, the trigger is done for the day. Use this when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
### ` + "`" + `once` + "`" + ` one-shot at a future time
### ` + "`" + `once` + "`" + ` trigger
` + "```" + `yaml
schedule:
type: once
triggers:
- type: once
runAt: "2026-04-14T09:00:00"
` + "```" + `
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
Local time, no ` + "`" + `Z` + "`" + ` suffix.
### ` + "`" + `event` + "`" + ` trigger
` + "```" + `yaml
triggers:
- type: event
matchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
How event triggers work:
1. When a new event arrives, a fast LLM classifier checks each event trigger's ` + "`" + `matchCriteria` + "`" + ` against the event content.
2. If it might match, the track-run agent receives both the event payload and the existing note body, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update no fabricated content.
### Combining multiple triggers
A single track can have any combination e.g. an hourly cron AND an event trigger:
` + "```" + `yaml
track:
- id: q3-emails
instruction: |
Maintain a running summary of decisions and open questions about Q3 planning.
active: true
triggers:
- type: cron
expression: "0 9 * * 1-5"
- type: event
matchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
This track refreshes on schedule (weekdays at 9am) AND on every relevant incoming email.
### Cron cookbook
@ -273,62 +367,25 @@ Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`
- ` + "`" + `"0 0 * * 0"` + "`" + ` Sundays at midnight
- ` + "`" + `"0 0 1 * *"` + "`" + ` first of month at midnight
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** the user triggers it via the Play button in the UI.
## Event Triggers (third trigger type)
In addition to manual and scheduled, a track can be triggered by **events** incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
` + "```" + `track
trackId: q3-planning-emails
instruction: |
Maintain a running summary of decisions and open questions about Q3
planning, drawn from emails on the topic.
active: true
eventMatchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
How it works:
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update no fabricated content.
When to suggest event triggers:
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
- Be descriptive but not overly narrow Pass 1 routing is liberal by design.
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely they'll only run on schedule or manually.
## Insertion Workflow
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
### Cmd+K with cursor context
### Adding a track to an existing note
When the user invokes Cmd+K, the context includes an attachment mention like:
> User has attached the following files:
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
Workflow:
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` always re-read fresh.
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
5. Construct the full track block (YAML + target pair).
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
1. ` + "`" + `workspace-readFile({ path })` + "`" + ` re-read fresh.
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any). Note the existing ` + "`" + `track:` + "`" + ` ids if present.
3. Construct the new track entry as YAML.
4. ` + "`" + `workspace-edit` + "`" + `:
- **If the note has frontmatter and a ` + "`" + `track:` + "`" + ` array already**: anchor on a unique line in/near the array and splice your new entry in.
- **If the note has frontmatter but no ` + "`" + `track:` + "`" + ` array**: anchor on the closing ` + "`" + `---` + "`" + ` of the frontmatter, and insert ` + "`" + `track:\n - id: ...` + "`" + ` etc. just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (` + "`" + `---\n` + "`" + ` ... ` + "`" + `\n---\n` + "`" + ` followed by the original first line).
### Sidebar chat with a specific note
1. If a file is mentioned/attached, read it.
2. If ambiguous, ask one question: "Which note should I add the track to?"
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
3. Update the note's frontmatter ` + "`" + `track:` + "`" + ` array using the workflow above.
### No note context at all
@ -344,149 +401,135 @@ In that flow:
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
6. Keep the surrounding note scaffolding minimal but useful. The track entry should be the core of the note.
## The Exact Text to Insert
### Background agent setup flow
Write it verbatim like this (including the blank line between fence and target):
Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet.
` + "```" + `track
trackId: <id>
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
schedule:
type: cron
triggers:
- type: cron
expression: "0 * * * *"
---
# <Note title>
` + "```" + `
<!--track-target:<id>-->
<!--/track-target:<id>-->
**Rules:**
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
- Target pair is **empty on creation**. The runner fills it on the first run.
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar see the YAML String Style section above for why.
- ` + "`" + `track:` + "`" + ` is at the top level of the frontmatter, never nested.
- Each entry is a list item starting with ` + "`" + `- id:` + "`" + `. 2-space YAML indent. No tabs.
- ` + "`" + `triggers:` + "`" + ` is an array. Omit it for a manual-only track. Multiple entries are allowed (any mix of cron / window / once / event).
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + `.
- **Always quote cron expressions** in YAML they contain spaces and ` + "`" + `*` + "`" + `.
- Use 2-space YAML indent. No tabs.
- Top-level markdown only never inside a code fence, blockquote, or table.
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The track agent will edit the body on its first run.
## After Insertion
## After Creating or Editing a Track
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
- **Then offer to run it once now** (see "Running a Track" below) especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the ` + "`" + `run-track` + "`" + ` tool same as the user clicking Run in the sidebar.
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
Why default-on:
- For event-driven tracks (with ` + "`" + `event` + "`" + ` triggers), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
- For tracks that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill ` + "`" + `context` + "`" + ` (see below) seeds rich initial content.
- After an edit, the user expects to see the updated output without an extra round-trip.
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
Confirm in one line and tell the user where to find it:
> "Done — I've set up a track refreshing hourly. Running it once now so you see content right away. You can manage it from the Track sidebar."
### When to proactively offer to run
For an edit:
> "Updated. Re-running now so you can see the new output."
These are upsells ask first, don't run silently.
If you skipped the re-run (user said not to):
> "Updated — I'll let it run on its next trigger."
- **Just created a new track block.** Before declaring done, offer:
> "Want me to run it once now to seed the initial content?"
**Do not** write content into the note body yourself that's the track agent's job, delegated via ` + "`" + `run-track` + "`" + `.
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) otherwise the target region stays empty until the next matching event arrives.
## Using the ` + "`" + `run-track` + "`" + ` tool
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
` + "`" + `run-track` + "`" + ` triggers a single run right now. You can pass an optional ` + "`" + `context` + "`" + ` string to bias *this run only* without modifying the track's instruction the difference between a stock refresh and a smart backfill.
- **Just edited an existing track.** Offer:
> "Want me to run it now to see the updated output?"
### Backfill ` + "`" + `context` + "`" + ` examples
- **Explicit user request.** "run the X track", "test it", "refresh that block" call the tool directly.
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
**Examples:**
- New track: "Track emails about Q3 planning" after creating it, run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
- New track: "Summarize this week's customer calls" run with:
- New event-driven track on Q3 emails run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
- New track on this week's customer calls run with:
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
- Manual refresh after the user mentions a recent change:
> context: "Focus on changes from the last 7 days only."
- Plain refresh (user said "run it now"): **omit ` + "`" + `context` + "`" + `**. Don't invent it.
- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context it can mislead the agent.
### What to do with the result
### Reading the result
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
- **` + "`" + `action: 'replace'` + "`" + `** the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
> "Done — track now shows: 72°F, partly cloudy in Chicago."
- **` + "`" + `action: 'no_update'` + "`" + `** the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
- **` + "`" + `error` + "`" + ` set** surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
- ` + "`" + `action: 'replace'` + "`" + ` body changed. Confirm in one line; optionally cite the first line of ` + "`" + `contentAfter` + "`" + `.
- ` + "`" + `action: 'no_update'` + "`" + ` agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` usually explains why.
- ` + "`" + `error: 'Already running'` + "`" + ` another run is in flight; tell the user to retry shortly.
- Other ` + "`" + `error` + "`" + ` surface concisely.
### Don'ts
- **Don't auto-run** after every edit ask first.
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn one run per user-facing action.
## Proactive Suggestions
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
- "I want to track / monitor / watch / keep an eye on / follow X"
- "Can you check on X every morning / hourly / weekly?"
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
Suggestion style one line, concrete:
> "I can turn this into a track block that refreshes hourly — want that?"
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
- **Don't run more than once** per user-facing action one tool call per turn.
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh it can mislead the agent.
- **Don't write content into the note body yourself** always delegate via ` + "`" + `run-track` + "`" + `.
## Don'ts
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
- **Don't reuse** an existing ` + "`" + `id` + "`" + ` in the same note's ` + "`" + `track:` + "`" + ` array.
- **Don't add ` + "`" + `triggers` + "`" + `** if the user explicitly wants a manual-only track.
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` runtime-managed.
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` local time only.
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
## Editing or Removing an Existing Track
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
**Change triggers or instruction:** ` + "`" + `workspace-edit` + "`" + ` the relevant fields inside the ` + "`" + `track:` + "`" + ` array. Anchor on the unique ` + "`" + `id: <id>` + "`" + ` line plus a few surrounding lines.
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full track entry (from its ` + "`" + `- id:` + "`" + ` line down to just before the next ` + "`" + `- id:` + "`" + ` line or the closing ` + "`" + `---` + "`" + ` of the frontmatter), ` + "`" + `newString` + "`" + ` = empty. The note body is left alone if you want to clear leftover agent output, do that as a separate edit.
## Quick Reference
Minimal template:
Minimal template (frontmatter only):
` + "```" + `track
trackId: <kebab-id>
` + "```" + `yaml
track:
- id: <kebab-id>
instruction: |
<what to produce always use ` + "`" + `|` + "`" + `, indented 2 spaces>
active: true
schedule:
type: cron
triggers:
- type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:<kebab-id>-->
<!--/track-target:<kebab-id>-->
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
YAML style reminder: ` + "`" + `instruction` + "`" + ` and event-trigger ` + "`" + `matchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
`;
export default skill;

View file

@ -0,0 +1,3 @@
export { ensureLoaded, readSkillContent, refreshFromRemote } from './loader.js';
export type { SkillEntry, SkillsIndex, LoaderStatus } from './loader.js';
export { matchSkillsForUrl } from './matcher.js';

View file

@ -0,0 +1,215 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { WorkDir } from '../../config/config.js';
const REPO_OWNER = 'browser-use';
const REPO_NAME = 'browser-harness';
const REPO_BRANCH = 'main';
const DOMAIN_SKILLS_PREFIX = 'domain-skills/';
const MANIFEST_TTL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 20_000;
export type SkillEntry = {
id: string; // e.g. "github/repo-actions"
site: string; // e.g. "github"
fileName: string; // e.g. "repo-actions.md"
title: string; // first H1 from the markdown, or a derived title
path: string; // relative repo path, e.g. "domain-skills/github/repo-actions.md"
localPath: string; // absolute path on disk
};
export type SkillsIndex = {
fetchedAt: number;
treeSha: string;
entries: SkillEntry[];
};
export type LoaderStatus =
| { status: 'ready'; index: SkillsIndex }
| { status: 'stale'; index: SkillsIndex; refreshing: boolean }
| { status: 'empty' }
| { status: 'error'; error: string };
const cacheRoot = () => path.join(WorkDir, 'cache', 'browser-skills');
const skillsDir = () => path.join(cacheRoot(), 'domain-skills');
const manifestPath = () => path.join(cacheRoot(), 'manifest.json');
async function ensureCacheDir(): Promise<void> {
await fs.mkdir(skillsDir(), { recursive: true });
}
async function readManifest(): Promise<SkillsIndex | null> {
try {
const raw = await fs.readFile(manifestPath(), 'utf8');
const parsed = JSON.parse(raw) as SkillsIndex;
if (!parsed.entries || !Array.isArray(parsed.entries)) return null;
return parsed;
} catch {
return null;
}
}
async function writeManifest(index: SkillsIndex): Promise<void> {
await ensureCacheDir();
await fs.writeFile(manifestPath(), JSON.stringify(index, null, 2), 'utf8');
}
function extractTitle(markdown: string, fallback: string): string {
const match = markdown.match(/^#\s+(.+?)\s*$/m);
if (match?.[1]) return match[1].trim();
return fallback;
}
async function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
...init,
signal: controller.signal,
headers: {
'User-Agent': 'rowboat-browser-skills',
Accept: 'application/vnd.github+json',
...(init?.headers ?? {}),
},
});
} finally {
clearTimeout(timer);
}
}
type GithubTreeNode = { path: string; type: string; sha: string };
async function fetchRepoTree(): Promise<{ treeSha: string; skillPaths: string[] }> {
const branchUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches/${REPO_BRANCH}`;
const branchRes = await fetchWithTimeout(branchUrl);
if (!branchRes.ok) {
throw new Error(`GitHub branch fetch failed: ${branchRes.status} ${branchRes.statusText}`);
}
const branch = (await branchRes.json()) as { commit: { commit: { tree: { sha: string } } } };
const treeSha = branch.commit?.commit?.tree?.sha;
if (!treeSha) throw new Error('Could not resolve tree SHA from branch response.');
const treeUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/${treeSha}?recursive=1`;
const treeRes = await fetchWithTimeout(treeUrl);
if (!treeRes.ok) {
throw new Error(`GitHub tree fetch failed: ${treeRes.status} ${treeRes.statusText}`);
}
const tree = (await treeRes.json()) as { tree: GithubTreeNode[]; truncated: boolean };
const skillPaths = tree.tree
.filter((n) => n.type === 'blob' && n.path.startsWith(DOMAIN_SKILLS_PREFIX) && n.path.endsWith('.md'))
.map((n) => n.path);
return { treeSha, skillPaths };
}
async function fetchRawFile(repoPath: string): Promise<string> {
const url = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}/${repoPath}`;
const res = await fetchWithTimeout(url, { headers: { Accept: 'text/plain' } });
if (!res.ok) {
throw new Error(`Raw file fetch failed for ${repoPath}: ${res.status} ${res.statusText}`);
}
return res.text();
}
function parseRepoPath(repoPath: string): { id: string; site: string; fileName: string } | null {
const rel = repoPath.slice(DOMAIN_SKILLS_PREFIX.length);
const parts = rel.split('/');
if (parts.length < 2) return null;
const site = parts[0];
const fileName = parts.slice(1).join('/');
const id = rel.replace(/\.md$/, '');
return { id, site, fileName };
}
export async function refreshFromRemote(): Promise<SkillsIndex> {
await ensureCacheDir();
const { treeSha, skillPaths } = await fetchRepoTree();
const entries: SkillEntry[] = [];
await Promise.all(skillPaths.map(async (repoPath) => {
const parsed = parseRepoPath(repoPath);
if (!parsed) return;
try {
const content = await fetchRawFile(repoPath);
const localRel = path.join(parsed.site, parsed.fileName);
const localPath = path.join(skillsDir(), localRel);
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, content, 'utf8');
entries.push({
id: parsed.id,
site: parsed.site,
fileName: parsed.fileName,
title: extractTitle(content, parsed.id),
path: repoPath,
localPath,
});
} catch (err) {
console.warn(`[browser-skills] Failed to fetch ${repoPath}:`, err);
}
}));
entries.sort((a, b) => a.id.localeCompare(b.id));
const index: SkillsIndex = {
fetchedAt: Date.now(),
treeSha,
entries,
};
await writeManifest(index);
return index;
}
let inFlightRefresh: Promise<SkillsIndex> | null = null;
export async function ensureLoaded(options?: { forceRefresh?: boolean }): Promise<LoaderStatus> {
try {
const existing = await readManifest();
const fresh = existing && Date.now() - existing.fetchedAt < MANIFEST_TTL_MS;
if (existing && fresh && !options?.forceRefresh) {
return { status: 'ready', index: existing };
}
if (existing && !options?.forceRefresh) {
if (!inFlightRefresh) {
inFlightRefresh = refreshFromRemote()
.catch((err) => {
console.warn('[browser-skills] Background refresh failed:', err);
return existing;
})
.finally(() => { inFlightRefresh = null; });
}
return { status: 'stale', index: existing, refreshing: true };
}
if (!inFlightRefresh) {
inFlightRefresh = refreshFromRemote().finally(() => { inFlightRefresh = null; });
}
try {
const index = await inFlightRefresh;
return { status: 'ready', index };
} catch (err) {
return { status: 'error', error: err instanceof Error ? err.message : 'Failed to load skills.' };
}
} catch (err) {
return { status: 'error', error: err instanceof Error ? err.message : 'Skill loader failed.' };
}
}
export async function readSkillContent(id: string): Promise<{ ok: true; content: string; entry: SkillEntry } | { ok: false; error: string }> {
const status = await ensureLoaded();
if (status.status === 'error' || status.status === 'empty') {
return { ok: false, error: status.status === 'error' ? status.error : 'No skills cached yet.' };
}
const entry = status.index.entries.find((e) => e.id === id);
if (!entry) return { ok: false, error: `Skill '${id}' not found.` };
try {
const content = await fs.readFile(entry.localPath, 'utf8');
return { ok: true, content, entry };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Failed to read skill file.' };
}
}

View file

@ -0,0 +1,56 @@
import type { SkillEntry, SkillsIndex } from './loader.js';
/**
* Map browser-harness `domain-skills/<site>/` folder names to hostname tokens we
* match against the current tab's URL.
*
* Heuristic: for each site folder we generate candidate hostnames like
* "booking-com" -> ["booking-com", "bookingcom", "booking.com"]
* "github" -> ["github", "github.com"]
* "dev-to" -> ["dev-to", "devto", "dev.to"]
* Then we check whether any candidate is a substring of the tab hostname.
*/
function siteCandidates(site: string): string[] {
const candidates = new Set<string>();
candidates.add(site);
candidates.add(site.replace(/-/g, ''));
candidates.add(site.replace(/-/g, '.'));
if (site.endsWith('-com')) {
candidates.add(`${site.slice(0, -4)}.com`);
}
if (site.endsWith('-org')) {
candidates.add(`${site.slice(0, -4)}.org`);
}
if (site.endsWith('-io')) {
candidates.add(`${site.slice(0, -3)}.io`);
}
return Array.from(candidates);
}
function extractHostname(url: string): string | null {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return null;
}
}
export function matchSkillsForUrl(index: SkillsIndex, url: string, limit = 5): SkillEntry[] {
const hostname = extractHostname(url);
if (!hostname) return [];
const bySite = new Map<string, SkillEntry[]>();
for (const entry of index.entries) {
if (!bySite.has(entry.site)) bySite.set(entry.site, []);
bySite.get(entry.site)!.push(entry);
}
const matched: SkillEntry[] = [];
for (const [site, entries] of bySite) {
const candidates = siteCandidates(site);
const hit = candidates.some((c) => hostname === c || hostname.endsWith(`.${c}`) || hostname.includes(c));
if (hit) matched.push(...entries);
}
return matched.slice(0, limit);
}

View file

@ -18,6 +18,7 @@ import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
import { createProvider } from "../../models/models.js";
@ -27,8 +28,8 @@ import { getCurrentUseCase } from "../../analytics/use_case.js";
import { isSignedIn } from "../../account/account.js";
import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js";
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
import type { IBrowserControlService } from "../browser-control/service.js";
import type { INotificationService } from "../notification/service.js";
// Parser libraries are loaded dynamically inside parseFile.execute()
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
// Import paths are computed so esbuild cannot statically resolve them.
@ -967,6 +968,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const { promise, process: proc } = executeCommandAbortable(command, {
cwd: workingDir,
signal: ctx.signal,
onData: (chunk: string) => {
ctx.publish({
runId: ctx.runId,
type: "tool-output-stream",
toolCallId: ctx.toolCallId,
toolName: "executeCommand",
output: chunk,
subflow: [],
});
},
});
// Register process with abort registry for force-kill
@ -1006,6 +1017,71 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
// ============================================================================
// Browser Skills (browser-use/browser-harness domain-skills cache)
// ============================================================================
'load-browser-skill': {
description: 'Load a site-specific browser skill (from the browser-use/browser-harness domain-skills library) by id. Returns the full markdown content with selectors, gotchas, and recipes for the target site. Call this after browser-control responses surface a matching skill in suggestedSkills. Pass action="list" to see all available skills. Skills are fetched on first use and cached locally; pass action="refresh" to force an update from upstream.',
inputSchema: z.object({
action: z.enum(['load', 'list', 'refresh']).optional().describe('load: fetch a skill by id (default). list: list all cached skills. refresh: re-fetch the library from upstream.'),
id: z.string().optional().describe('Skill id (e.g., "github/repo-actions") — required for load.'),
site: z.string().optional().describe('Filter list results to a single site (e.g., "github").'),
}),
execute: async (input: { action?: 'load' | 'list' | 'refresh'; id?: string; site?: string }) => {
const action = input.action ?? 'load';
try {
if (action === 'refresh') {
const index = await refreshBrowserSkills();
return {
success: true,
message: `Refreshed ${index.entries.length} skill${index.entries.length === 1 ? '' : 's'} from upstream.`,
count: index.entries.length,
treeSha: index.treeSha,
};
}
if (action === 'list') {
const status = await ensureBrowserSkillsLoaded();
if (status.status === 'error') {
return { success: false, error: status.error };
}
if (status.status === 'empty') {
return { success: false, error: 'No browser skills cached yet.' };
}
const entries = status.index.entries
.filter((e) => !input.site || e.site === input.site)
.map((e) => ({ id: e.id, title: e.title, site: e.site }));
return {
success: true,
count: entries.length,
skills: entries,
cacheAgeMs: Date.now() - status.index.fetchedAt,
refreshing: status.status === 'stale' ? status.refreshing : false,
};
}
if (!input.id) {
return { success: false, error: 'id is required for load.' };
}
const result = await readBrowserSkillContent(input.id);
if (!result.ok) {
return { success: false, error: result.error };
}
return {
success: true,
id: result.entry.id,
title: result.entry.title,
site: result.entry.site,
path: result.entry.path,
content: result.content,
};
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Failed to load browser skill.' };
}
},
},
// ============================================================================
// Browser Control
// ============================================================================
@ -1474,44 +1550,25 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
isAvailable: async () => isComposioConfigured(),
},
'update-track-content': {
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
'run-track': {
description: "Manually trigger a track to run now on its host note. Equivalent to the user clicking the Run button on the track in the sidebar, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking track from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId"),
content: z.string().describe("The new content to place inside the track's target region"),
}),
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
try {
await updateContent(filePath, trackId, content);
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
'run-track-block': {
description: "Manually trigger a track block to run now. Equivalent to the user clicking the Play button on the block, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking block from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new content.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId (must exist in the file)"),
id: z.string().describe("The track's id (must exist in the note's frontmatter `track:` array)"),
context: z.string().optional().describe(
"Optional extra context for the track agent to consider for THIS run only — does not modify the block's instruction. " +
"Optional extra context for the track agent to consider for THIS run only — does not modify the track's instruction. " +
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
"Omit for a plain refresh."
),
}),
execute: async ({ filePath, trackId, context }: { filePath: string; trackId: string; context?: string }) => {
execute: async ({ filePath, id, context }: { filePath: string; id: string; context?: string }) => {
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
try {
// Lazy import to break a module-init cycle:
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
const result = await triggerTrackUpdate(trackId, knowledgeRelativePath, context, 'manual');
const result = await triggerTrackUpdate(id, knowledgeRelativePath, context, 'manual');
return {
success: !result.error,
runId: result.runId,
@ -1526,4 +1583,44 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
},
},
'notify-user': {
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
inputSchema: z.object({
title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."),
message: z.string().min(1).describe("Body text of the notification."),
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
message: "link must be an http(s):// or rowboat:// URL",
}).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."),
actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."),
secondaryActions: z.array(z.object({
label: z.string().min(1).max(30),
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
message: "secondary action link must be an http(s):// or rowboat:// URL",
}),
})).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."),
}),
isAvailable: async () => {
try {
return container.resolve<INotificationService>('notificationService').isSupported();
} catch {
return false;
}
},
execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => {
try {
const service = container.resolve<INotificationService>('notificationService');
if (!service.isSupported()) {
return { success: false, error: 'Notifications are not supported on this system' };
}
service.notify({ title, message, link, actionLabel, secondaryActions });
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
};

View file

@ -8,7 +8,6 @@ const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string {
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
@ -84,11 +83,12 @@ export async function executeCommand(
}
): Promise<CommandResult> {
try {
const shell = getExecutionShell();
const { stdout, stderr } = await execPromise(command, {
cwd: options?.cwd,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: EXECUTION_SHELL,
shell,
});
return {
@ -143,6 +143,7 @@ export function executeCommandAbortable(
timeout?: number;
maxBuffer?: number;
signal?: AbortSignal;
onData?: (chunk: string) => void;
}
): { promise: Promise<AbortableCommandResult>; process: ChildProcess } {
// Check if already aborted before spawning
@ -161,8 +162,9 @@ export function executeCommandAbortable(
};
}
const shell = getExecutionShell();
const proc = spawn(command, [], {
shell: EXECUTION_SHELL,
shell,
cwd: options?.cwd,
detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'],
@ -176,16 +178,20 @@ export function executeCommandAbortable(
// Collect output
proc.stdout?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
const maxBuffer = options?.maxBuffer || 1024 * 1024;
if (stdout.length < maxBuffer) {
stdout += chunk.toString();
stdout += text;
}
options?.onData?.(text);
});
proc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
const maxBuffer = options?.maxBuffer || 1024 * 1024;
if (stderr.length < maxBuffer) {
stderr += chunk.toString();
stderr += text;
}
options?.onData?.(text);
});
// Abort handler
@ -272,11 +278,12 @@ export function executeCommandSync(
}
): CommandResult {
try {
const shell = getExecutionShell();
const stdout = execSync(command, {
cwd: options?.cwd,
timeout: options?.timeout,
encoding: 'utf-8',
shell: EXECUTION_SHELL,
shell,
});
return {

View file

@ -1,4 +1,5 @@
import { ToolAttachment } from "@x/shared/dist/agent.js";
import { RunEvent } from "@x/shared/dist/runs.js";
import { z } from "zod";
import { BuiltinTools } from "./builtin-tools.js";
import { executeTool } from "../../mcp/mcp.js";
@ -9,8 +10,10 @@ import { IAbortRegistry } from "../../runs/abort-registry.js";
*/
export interface ToolContext {
runId: string;
toolCallId: string;
signal: AbortSignal;
abortRegistry: IAbortRegistry;
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
}
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {

View file

@ -1,9 +1,10 @@
import { parse as parseYaml } from "yaml";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
/**
* Parse the YAML frontmatter from the input string. Returns the frontmatter and content.
* @param input - The input string to parse.
* @returns The frontmatter and content.
* Parse the YAML frontmatter from the input string. Trims the body handy
* when you only care about the frontmatter or treat the body as opaque
* markdown (e.g. agent instructions). Use {@link splitFrontmatter} when you
* need to round-trip the body byte-for-byte.
*/
export function parseFrontmatter(input: string): {
frontmatter: unknown | null;
@ -25,3 +26,54 @@ export function parseFrontmatter(input: string): {
content: input,
};
}
/**
* Split a file's frontmatter from its body without trimming or reformatting
* the body. Used by callers that round-trip the file (read mutate
* frontmatter re-emit) preserving body bytes prevents whitespace drift
* across writes. Pair with {@link joinFrontmatter} on the way out.
*
* - `frontmatter` is always an object (empty `{}` if absent or not a map).
* - `body` is the rest of the file verbatim, including any leading/trailing
* whitespace.
*/
export function splitFrontmatter(content: string): {
frontmatter: Record<string, unknown>;
body: string;
} {
if (!content.startsWith('---')) {
return { frontmatter: {}, body: content };
}
const close = /\r?\n---\r?\n/.exec(content);
if (!close) {
return { frontmatter: {}, body: content };
}
const yamlText = content.slice(3, close.index).trim();
const body = content.slice(close.index + close[0].length);
let parsed: unknown = {};
if (yamlText) {
try {
parsed = parseYaml(yamlText);
} catch {
return { frontmatter: {}, body: content };
}
}
const frontmatter = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
? parsed as Record<string, unknown>
: {};
return { frontmatter, body };
}
/**
* Re-emit a file with the given frontmatter object and body. If the
* frontmatter object is empty, no `---` fence is written the file is body
* only. Pairs with {@link splitFrontmatter}.
*/
export function joinFrontmatter(
frontmatter: Record<string, unknown>,
body: string,
): string {
if (Object.keys(frontmatter).length === 0) return body;
const yamlText = stringifyYaml(frontmatter).replace(/\n$/, '');
return `---\n${yamlText}\n---\n${body}`;
}

View file

@ -0,0 +1,12 @@
export interface NotifyInput {
title?: string;
message: string;
link?: string;
actionLabel?: string;
secondaryActions?: Array<{ label: string; link: string }>;
}
export interface INotificationService {
isSupported(): boolean;
notify(input: NotifyInput): void;
}

View file

@ -0,0 +1,113 @@
import { API_URL } from "../config/env.js";
import { getAccessToken } from "./tokens.js";
import { OAuthTokens } from "./types.js";
/**
* Client for the rowboat-mode Google OAuth endpoints on the api:
* POST /v1/google-oauth/claim one-shot retrieval of tokens parked by
* the webapp callback under a `state` ticket
* POST /v1/google-oauth/refresh exchange a refresh_token for fresh tokens
* (the secret-requiring step that can't
* happen on the desktop)
*
* Both are called with the user's Rowboat Supabase bearer (via getAccessToken).
*
* The api response shape uses `scope: string` (space-delimited); we convert
* to the desktop's `scopes: string[]`. On refresh, api may omit `scope` and
* `refresh_token` caller-provided existingScopes / refreshToken are
* preserved in those cases (Google rarely rotates refresh tokens).
*/
/** Thrown when the api signals the user must reconnect (Google `invalid_grant`). */
export class ReconnectRequiredError extends Error {
constructor(message: string) {
super(message);
this.name = "ReconnectRequiredError";
}
}
interface ApiTokenResponse {
access_token: string;
refresh_token?: string;
expires_at: number;
scope?: string;
token_type?: string;
}
function toOAuthTokens(
body: ApiTokenResponse,
fallbackRefreshToken: string | null = null,
fallbackScopes?: string[],
): OAuthTokens {
const refresh_token = body.refresh_token ?? fallbackRefreshToken;
const scopes = body.scope
? body.scope.split(" ").filter((s) => s.length > 0)
: fallbackScopes;
return {
access_token: body.access_token,
refresh_token,
expires_at: body.expires_at,
token_type: "Bearer",
scopes,
};
}
async function postWithBearer(path: string, body: unknown): Promise<Response> {
const bearer = await getAccessToken();
return fetch(`${API_URL}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${bearer}`,
},
body: JSON.stringify(body),
});
}
interface ErrorBody {
error?: string;
reconnectRequired?: boolean;
}
async function readError(res: Response): Promise<ErrorBody> {
try {
return (await res.json()) as ErrorBody;
} catch {
return {};
}
}
/** Claim the tokens parked under `state` after the webapp finished its callback. */
export async function claimTokensViaBackend(state: string): Promise<OAuthTokens> {
const res = await postWithBearer("/v1/google-oauth/claim", { session: state });
if (!res.ok) {
const err = await readError(res);
throw new Error(`claim failed: ${res.status} ${err.error ?? ""}`.trim());
}
const body = (await res.json()) as ApiTokenResponse;
return toOAuthTokens(body);
}
/**
* Refresh an access token via the api. Preserves caller's `refreshToken` and
* `existingScopes` when Google omits them on the refresh response.
*/
export async function refreshTokensViaBackend(
refreshToken: string,
existingScopes?: string[],
): Promise<OAuthTokens> {
const res = await postWithBearer("/v1/google-oauth/refresh", { refreshToken });
if (res.status === 409) {
const err = await readError(res);
if (err.reconnectRequired) {
throw new ReconnectRequiredError(err.error ?? "Reconnect required");
}
throw new Error(`refresh failed: 409 ${err.error ?? ""}`.trim());
}
if (!res.ok) {
const err = await readError(res);
throw new Error(`refresh failed: ${res.status} ${err.error ?? ""}`.trim());
}
const body = (await res.json()) as ApiTokenResponse;
return toOAuthTokens(body, refreshToken, existingScopes);
}

View file

@ -8,6 +8,13 @@ const ProviderConnectionSchema = z.object({
tokens: OAuthTokens.nullable().optional(),
clientId: z.string().nullable().optional(),
clientSecret: z.string().nullable().optional(),
/**
* `byok` (default for absent) user provides their own client_id+secret;
* tokens stored locally; refresh handled locally via openid-client.
* `rowboat` signed-in user; client_id+secret never on the desktop;
* tokens stored locally but refresh goes through the api.
*/
mode: z.enum(['byok', 'rowboat']).optional(),
error: z.string().nullable().optional(),
});

View file

@ -49,8 +49,6 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
*/
const ZComposioConfig = z.object({
apiKey: z.string().optional(),
use_composio_for_google: z.boolean().optional(),
use_composio_for_google_calendar: z.boolean().optional(),
});
type ComposioConfig = z.infer<typeof ZComposioConfig>;
@ -106,24 +104,6 @@ export async function isConfigured(): Promise<boolean> {
return !!getApiKey();
}
/**
* Check if Composio should be used for Google services (Gmail, etc.)
*/
export async function useComposioForGoogle(): Promise<boolean> {
if (await isSignedIn()) return true;
const config = loadConfig();
return config.use_composio_for_google === true;
}
/**
* Check if Composio should be used for Google Calendar
*/
export async function useComposioForGoogleCalendar(): Promise<boolean> {
if (await isSignedIn()) return true;
const config = loadConfig();
return config.use_composio_for_google_calendar === true;
}
/**
* Make an API call to Composio
*/

View file

@ -0,0 +1,51 @@
import { API_URL } from "./env.js";
/**
* Per-process cache of the unauthenticated `GET /v1/config` response from
* the api. The api returns `{ appUrl, supabaseUrl, websocketApiUrl }`
* we use this to discover the webapp host (where the rowboat-mode OAuth
* flow runs) without hardcoding it on the desktop side.
*
* Cached as a Promise so concurrent first-callers all await the same fetch
* (no thundering herd). On failure the cache is cleared so the next call
* can retry.
*/
interface RemoteConfig {
appUrl: string;
supabaseUrl: string;
websocketApiUrl: string;
}
let _cached: Promise<RemoteConfig> | null = null;
async function fetchRemoteConfig(): Promise<RemoteConfig> {
const res = await fetch(`${API_URL}/v1/config`);
if (!res.ok) {
throw new Error(`/v1/config returned ${res.status}`);
}
const body = (await res.json()) as Partial<RemoteConfig>;
if (!body.appUrl) {
throw new Error("/v1/config response missing appUrl");
}
return {
appUrl: body.appUrl,
supabaseUrl: body.supabaseUrl ?? "",
websocketApiUrl: body.websocketApiUrl ?? "",
};
}
export async function getRemoteConfig(): Promise<RemoteConfig> {
if (!_cached) {
_cached = fetchRemoteConfig().catch((err) => {
_cached = null; // allow retry
throw err;
});
}
return _cached;
}
export async function getWebappUrl(): Promise<string> {
const config = await getRemoteConfig();
return config.appUrl;
}

View file

@ -16,6 +16,7 @@ import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService):
browserControlService: asValue(service),
});
}
export function registerNotificationService(service: INotificationService): void {
container.register({
notificationService: asValue(service),
});
}

View file

@ -4,12 +4,10 @@ import { google } from 'googleapis';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { getKgModel } from '../models/defaults.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { useComposioForGoogle, executeAction } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import {
loadAgentNotesState,
saveAgentNotesState,
@ -199,30 +197,7 @@ async function ensureUserEmail(): Promise<string | null> {
return existing.email;
}
// Try Composio (used when signed in or composio configured)
try {
if (await useComposioForGoogle()) {
const account = composioAccountsRepo.getAccount('gmail');
if (account && account.status === 'ACTIVE') {
const result = await executeAction('GMAIL_GET_PROFILE', {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: { user_id: 'me' },
});
const email = (result.data as Record<string, unknown>)?.emailAddress as string | undefined;
if (email) {
updateUserEmail(email);
console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`);
return email;
}
}
}
} catch (error) {
console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error);
}
// Try direct Google OAuth
// Try direct Google OAuth (covers both BYOK and rowboat modes)
try {
const auth = await GoogleClientFactory.getClient();
if (auth) {
@ -313,7 +288,7 @@ async function processAgentNotes(): Promise<void> {
subUseCase: 'agent_notes',
});
await createMessage(agentRun.id, message);
await waitForRunCompletion(agentRun.id);
await waitForRunCompletion(agentRun.id, { throwOnError: true });
// Mark everything as processed
for (const p of emailPaths) {
@ -351,7 +326,16 @@ async function processAgentNotes(): Promise<void> {
runId: serviceRun.runId,
level: 'error',
message: 'Error processing agent notes',
error: error instanceof Error ? error.message : String(error),
error: getErrorDetails(error),
});
await serviceLogger.log({
type: 'run_complete',
service: serviceRun.service,
runId: serviceRun.runId,
level: 'error',
message: 'Agent notes processing failed',
durationMs: Date.now() - serviceRun.startedAt,
outcome: 'error',
});
}
}

View file

@ -3,7 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import {
loadState,
@ -312,8 +312,11 @@ async function createNotesFromBatch(
await createMessage(run.id, message);
// Wait for the run to complete
await waitForRunCompletion(run.id);
try {
await waitForRunCompletion(run.id, { throwOnError: true });
} finally {
unsubscribe();
}
return { runId: run.id, notesCreated, notesModified };
}
@ -428,7 +431,7 @@ async function buildGraphWithFiles(
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
error: getErrorDetails(error),
context: { batchNumber },
});
}
@ -600,7 +603,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
runId: run.runId,
level: 'error',
message: `Error processing voice memo batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
error: getErrorDetails(error),
context: { batchNumber },
});
}

View file

@ -1,161 +1,154 @@
import path from 'path';
import fs from 'fs';
import { stringify as stringifyYaml } from 'yaml';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { TrackSchema } from '@x/shared/dist/track.js';
import { WorkDir } from '../config/config.js';
import { splitFrontmatter } from '../application/lib/parse-frontmatter.js';
import z from 'zod';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
interface Section {
heading: string;
track: z.infer<typeof TrackBlockSchema>;
}
// Bump this whenever the canonical Today.md template changes (TRACKS list,
// instructions, default body, etc.). On app start, ensureDailyNote() compares
// the on-disk `templateVersion` against this constant — if older or missing,
// the existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with
// the new template, preserving the body byte-for-byte.
const CANONICAL_DAILY_NOTE_VERSION = 1;
const SECTIONS: Section[] = [
// Window triggers below fire once per day, anywhere inside their time-of-day
// band — so the user opening the app late in the morning still gets the
// morning run. See schedule-utils.ts for the exact semantics.
const TRACKS: z.infer<typeof TrackSchema>[] = [
{
heading: '## ⏱ Up Next',
track: {
trackId: 'up-next',
id: 'overview',
instruction:
`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today.
This section refreshes on calendar changes, not on a clock tick do NOT promise live minute countdowns. Frame urgency in buckets based on the event's start time relative to now:
- Start time is in the past or within roughly half an hour imminent: name the meeting and say it's starting soon (e.g. "Standup is starting — join link in the Calendar section below.").
- Start time is later this morning or this afternoon upcoming: name the meeting and roughly when (e.g. "Design review later this morning." / "1:1 with Sam this afternoon.").
- Start time is several hours out or nothing before then focus block: frame the gap (e.g. "Next up is the all-hands at 3pm — good long focus block until then.").
Use the event's start time of day ("at 3pm", "this afternoon") rather than a countdown ("in 40 minutes"). Countdowns go stale between syncs.
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't ended yet for finding the next event, pick the earliest upcoming one; if all have passed, treat as clear.
If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research.
If nothing remains today, output exactly: Clear for the rest of the day.
Plain markdown prose only no calendar block, no email block, no headings.`,
eventMatchCriteria:
`Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`,
`In a section titled "Overview" at the top of the note: 23 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from calendar_sync/ and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Skip the update if the prior content is still suitable and less than 24h old. VERY IMPORTANT: Ensure that image is wide / low-height!`,
active: true,
},
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
],
},
{
heading: '## 📅 Calendar',
track: {
trackId: 'calendar',
id: 'calendar',
instruction:
`Emit today's meetings as a calendar block titled "Today's Meetings".
Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended only include meetings that haven't ended yet.
This section refreshes on calendar changes, not on a clock tick the "drop ended meetings" rule applies on each refresh, so an ended meeting disappears the next time any calendar event changes (not exactly on the clock hour). That's fine.
Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink.
After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight one line each, only when meaningful. Skip routine/recurring meetings.`,
eventMatchCriteria:
`In a section titled "Calendar", emit today's meetings as a \`calendar\` block titled "Today's Meetings". Read calendar_sync/ via workspace-readdir → workspace-readFile each .json. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a conferenceLink.`,
active: true,
triggers: [{
type: 'event',
matchCriteria:
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
active: true,
},
}],
},
{
heading: '## 📧 Emails',
track: {
trackId: 'emails',
id: 'emails',
instruction:
`Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread).
`In a section titled "Emails", maintain a digest of email threads worth attention today. Output everything as a **single** fenced code block with language \`emails\` (plural — never individual \`email\` blocks per thread). The body must be JSON shaped \`{"title":"Today's Emails","emails":[...]}\`.
Event-driven path (primary): the agent message will include a "Gmail sync update" digest payload describing one or more freshly-synced threads from a single sync run. The digest lists each thread with its subject, sender, date, threadId, and body. Iterate over every thread in the payload and decide per thread whether it warrants surfacing. Skip marketing, auto-notifications, closed-out threads, and other low-signal mail. For threads that are attention-worthy, integrate them into the existing digest: add a new email block for a new threadId, or update the existing block if the threadId is already shown. If NONE of the threads in the payload are attention-worthy, skip the update do NOT call update-track-content. Emit at most one update-track-content call that covers the full set of changes from this event.
Each entry in the array: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads that need a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`.
Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads.
Skip marketing, auto-notifications, and closed threads. Without an event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/), prioritizing threads with frontmatter action = "reply" or "respond". With an event payload, integrate any qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if the threadId is already shown). Do not re-list threads the user has already seen unless their state changed.
Each email block should include threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add a draft_response written in the user's voice direct, informal, no fluff. For FYI threads, omit draft_response.
If there is genuinely nothing to surface, output the single line: No new emails.
Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`,
eventMatchCriteria:
If nothing qualifies: "No new emails."`,
active: true,
triggers: [{
type: 'event',
matchCriteria:
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
active: true,
},
}],
},
{
heading: '## 📰 What You Missed',
track: {
trackId: 'what-you-missed',
id: 'what-you-missed',
instruction:
`Short markdown summary of what happened yesterday that matters this morning.
Data sources:
- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments.
- gmail_sync/ skim for threads from yesterday that went unresolved or still need a reply.
Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them.
Write concise markdown a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today.
If nothing notable happened, output exactly: Quiet day yesterday nothing to flag.
Do NOT manufacture content to fill the section.`,
`In a section titled "What you missed", write a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from knowledge/Meetings/<source>/<yesterday>/ (workspace-readdir recursive on knowledge/Meetings, filter folders matching yesterday's date, read each file). Skim gmail_sync/ for threads that went unresolved. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."`,
active: true,
schedule: {
type: 'cron',
expression: '0 7 * * *',
},
},
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
],
},
{
heading: '## ✅ Today\'s Priorities',
track: {
trackId: 'priorities',
id: 'priorities',
instruction:
`Ranked markdown list of the real, actionable items the user should focus on today.
`In a section titled "Priorities", a ranked markdown list of actionable items the user should focus on today.
Data sources:
- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ action items assigned to the user are often the most important source.
- knowledge/ use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups.
- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section useful for alignment.
Sources: yesterday's meeting action items (knowledge/Meetings/<source>/<yesterday>/), open follow-ups across knowledge/ (workspace-grep for "- [ ]"), the "What you missed" section.
Rules:
- Do NOT list calendar events as tasks they're already in the Calendar section.
- Do NOT list trivial admin (filing small invoices, archiving spam).
- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review").
- Add a brief reason for each item when it's not self-evident.
Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline.
If nothing genuinely needs attention, output exactly: No pressing tasks today good day to make progress on bigger items.
With an event payload (gmail or calendar): re-emit the full list only if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). Otherwise skip the update.
Do NOT invent busywork.`,
If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."`,
active: true,
schedule: {
type: 'cron',
expression: '30 7 * * *',
triggers: [
// Three windows give the user a fresh ranking morning, midday, and
// post-lunch even with no events landing in between.
{ type: 'window', startTime: '08:00', endTime: '12:00' },
{ type: 'window', startTime: '12:00', endTime: '15:00' },
{ type: 'window', startTime: '15:00', endTime: '18:00' },
{
type: 'event',
matchCriteria:
`New or updated email threads that may shift today's priorities — urgent reply requests, deadline-bearing items, escalations from people the user cares about.`,
},
{
type: 'event',
matchCriteria:
`Calendar changes today that may shift priorities — a meeting moved to clash with a deadline, an unexpected event added, a key meeting cancelled freeing up time.`,
},
],
},
];
function buildDailyNoteContent(): string {
const parts: string[] = ['# Today', ''];
for (const { heading, track } of SECTIONS) {
const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd();
parts.push(
heading,
'',
'```track',
yaml,
'```',
'',
`<!--track-target:${track.trackId}-->`,
`<!--/track-target:${track.trackId}-->`,
'',
);
function buildDailyNoteContent(body: string = '# Today\n'): string {
const fm = stringifyYaml(
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, track: TRACKS },
{ lineWidth: 0, blockQuote: 'literal' },
).trimEnd();
return `---\n${fm}\n---\n${body}`;
}
return parts.join('\n');
function readCurrentTemplateVersion(): number {
if (!fs.existsSync(DAILY_NOTE_PATH)) return -1;
const raw = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8');
const { frontmatter } = splitFrontmatter(raw);
const v = frontmatter.templateVersion;
return typeof v === 'number' ? v : 0;
}
export function ensureDailyNote(): void {
if (fs.existsSync(DAILY_NOTE_PATH)) return;
// Fresh install — no existing file.
if (!fs.existsSync(DAILY_NOTE_PATH)) {
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
console.log('[DailyNote] Created today.md');
console.log(`[DailyNote] Created Today.md (v${CANONICAL_DAILY_NOTE_VERSION})`);
return;
}
// Up-to-date — nothing to do.
const currentVersion = readCurrentTemplateVersion();
if (currentVersion >= CANONICAL_DAILY_NOTE_VERSION) return;
// Migrate aggressively: rename existing → backup, write a fresh canonical
// template (no body carried over). Today.md is a flagship demo whose
// content is meant to be regenerated by the tracks anyway — preserving the
// old body just leaves orphan sections behind on rename/restructure. The
// .bkp file is the recovery path; its name doesn't end in `.md`, so the
// scheduler and event router naturally skip it. Pre-rewrite inline-fence
// notes are caught by this same path.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`;
fs.renameSync(DAILY_NOTE_PATH, backupPath);
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
console.log(
`[DailyNote] Migrated v${currentVersion} → v${CANONICAL_DAILY_NOTE_VERSION}; ` +
`previous version saved to ${backupPath}`,
);
}

View file

@ -6,20 +6,44 @@ import { getProviderConfig } from '../auth/providers.js';
import * as oauthClient from '../auth/oauth-client.js';
import type { Configuration } from '../auth/oauth-client.js';
import { OAuthTokens } from '../auth/types.js';
import {
ReconnectRequiredError,
refreshTokensViaBackend,
} from '../auth/google-backend-oauth.js';
type Mode = 'byok' | 'rowboat';
/**
* Factory for creating and managing Google OAuth2Client instances.
* Handles caching, token refresh, and client reuse for Google API SDKs.
*
* Two connection modes share the same `oauth.json` provider entry:
* - `byok` user supplied client_id+secret; refresh runs locally via
* openid-client; OAuth2Client built with creds.
* - `rowboat` signed-in user; client_id+secret never on the desktop;
* refresh goes through the api at /v1/google-oauth/refresh;
* OAuth2Client built without creds and without refresh_token
* (we own all refreshes see note below).
*
* **Auto-refresh disabled in rowboat mode:** google-auth-library's
* OAuth2Client will, on a 401 from a Google API call, try to refresh using
* the refresh_token + client secret it has on hand. In rowboat mode we have
* no secret, so that would 401-loop. We block this by passing only
* access_token + expiry_date in setCredentials (no refresh_token), which
* leaves the library nothing to refresh with. Our proactive expiry check
* in getClient() is the only refresh path.
*/
export class GoogleClientFactory {
private static readonly PROVIDER_NAME = 'google';
private static cache: {
mode: Mode | null;
config: Configuration | null;
client: OAuth2Client | null;
tokens: OAuthTokens | null;
clientId: string | null;
clientSecret: string | null;
} = {
mode: null,
config: null,
client: null,
tokens: null,
@ -27,7 +51,14 @@ export class GoogleClientFactory {
clientSecret: null,
};
private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
/**
* Promise singleton so a burst of getClient() calls during the brief
* expiry window all wait on a single refresh round-trip rather than
* fanning out parallel refreshes.
*/
private static refreshInFlight: Promise<OAuth2Client | null> | null = null;
private static async resolveByokCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const connection = await oauthRepo.read(this.PROVIDER_NAME);
if (!connection.clientId) {
@ -41,80 +72,116 @@ export class GoogleClientFactory {
* Get or create OAuth2Client, reusing cached instance when possible
*/
static async getClient(): Promise<OAuth2Client | null> {
if (this.refreshInFlight) {
return this.refreshInFlight;
}
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
const connection = await oauthRepo.read(this.PROVIDER_NAME);
const tokens = connection.tokens ?? null;
const mode: Mode = connection.mode ?? 'byok';
if (!tokens) {
this.clearCache();
return null;
}
// Initialize config cache if needed
// Mode flipped (e.g. user disconnected then reconnected differently) — invalidate.
if (this.cache.mode && this.cache.mode !== mode) {
this.clearCache();
}
// BYOK needs an openid-client Configuration for local refresh; rowboat doesn't.
if (mode === 'byok') {
try {
await this.initializeConfigCache();
} catch (error) {
console.error("[OAuth] Failed to initialize Google OAuth configuration:", error);
console.error('[OAuth] Failed to initialize Google OAuth configuration:', error);
this.clearCache();
return null;
}
if (!this.cache.config) {
return null;
}
}
// Check if token is expired
// Check expiry against the cached tokens. Note: oauthClient.isTokenExpired
// applies a small clock-skew margin so we refresh slightly before real
// expiry — keeps long-running calls from racing the boundary.
if (oauthClient.isTokenExpired(tokens)) {
// Token expired, try to refresh
if (!tokens.refresh_token) {
console.log("[OAuth] Token expired and no refresh token available for Google.");
console.log('[OAuth] Token expired and no refresh token available for Google.');
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
this.clearCache();
return null;
}
try {
console.log(`[OAuth] Token expired, refreshing access token...`);
const existingScopes = tokens.scopes;
const refreshedTokens = await oauthClient.refreshTokens(
this.cache.config,
tokens.refresh_token,
existingScopes
);
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
if (!this.cache.clientId) {
const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
this.refreshInFlight = this.refreshAndBuild(tokens, mode).finally(() => {
this.refreshInFlight = null;
});
return this.refreshInFlight;
}
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
console.log(`[OAuth] Token refreshed successfully`);
// Reuse client if tokens haven't changed
if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token && this.cache.mode === mode) {
return this.cache.client;
}
// Build a fresh client for current tokens
return this.buildAndCacheClient(tokens, mode);
}
private static async refreshAndBuild(tokens: OAuthTokens, mode: Mode): Promise<OAuth2Client | null> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
try {
console.log(`[OAuth] Token expired, refreshing via ${mode}...`);
const existingScopes = tokens.scopes;
let refreshedTokens: OAuthTokens;
if (mode === 'rowboat') {
refreshedTokens = await refreshTokensViaBackend(tokens.refresh_token!, existingScopes);
} else {
if (!this.cache.config) {
// Should not happen — initializeConfigCache ran above for byok.
throw new Error('Google OAuth config not initialized');
}
refreshedTokens = await oauthClient.refreshTokens(this.cache.config, tokens.refresh_token!, existingScopes);
}
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens, error: null });
console.log('[OAuth] Token refreshed successfully');
return this.buildAndCacheClient(refreshedTokens, mode);
} catch (error) {
if (error instanceof ReconnectRequiredError) {
console.log('[OAuth] Reconnect required for Google');
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Reconnect Google' });
this.clearCache();
return null;
}
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
console.error("[OAuth] Failed to refresh token for Google:", error);
console.error('[OAuth] Failed to refresh token for Google:', error);
this.clearCache();
return null;
}
}
// Reuse client if tokens haven't changed
if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token) {
return this.cache.client;
}
// Create new client with current tokens
console.log(`[OAuth] Creating new OAuth2Client instance`);
this.cache.tokens = tokens;
if (!this.cache.clientId) {
const creds = await this.resolveCredentials();
private static async buildAndCacheClient(tokens: OAuthTokens, mode: Mode): Promise<OAuth2Client> {
if (mode === 'byok' && !this.cache.clientId) {
const creds = await this.resolveByokCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
}
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
return this.cache.client;
const client = mode === 'rowboat'
? this.createRowboatClient(tokens)
: this.createByokClient(tokens, this.cache.clientId!, this.cache.clientSecret ?? undefined);
this.cache.mode = mode;
this.cache.tokens = tokens;
this.cache.client = client;
return client;
}
/**
@ -139,7 +206,8 @@ export class GoogleClientFactory {
* Clear cache (useful for testing or when credentials are revoked)
*/
static clearCache(): void {
console.log(`[OAuth] Clearing Google auth cache`);
console.log('[OAuth] Clearing Google auth cache');
this.cache.mode = null;
this.cache.config = null;
this.cache.client = null;
this.cache.tokens = null;
@ -148,10 +216,10 @@ export class GoogleClientFactory {
}
/**
* Initialize cached configuration (called once)
* Initialize cached configuration for BYOK mode (rowboat doesn't need it).
*/
private static async initializeConfigCache(): Promise<void> {
const { clientId, clientSecret } = await this.resolveCredentials();
const { clientId, clientSecret } = await this.resolveByokCredentials();
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
return; // Already initialized for these credentials
@ -161,13 +229,13 @@ export class GoogleClientFactory {
this.clearCache();
}
console.log(`[OAuth] Initializing Google OAuth configuration...`);
console.log('[OAuth] Initializing Google OAuth configuration...');
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
if (providerConfig.discovery.mode === 'issuer') {
if (providerConfig.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
console.log('[OAuth] Discovery mode: issuer with static client ID');
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
clientId,
@ -175,7 +243,7 @@ export class GoogleClientFactory {
);
} else {
// DCR mode - need existing registration
console.log(`[OAuth] Discovery mode: issuer with DCR`);
console.log('[OAuth] Discovery mode: issuer with DCR');
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
@ -194,7 +262,7 @@ export class GoogleClientFactory {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
console.log(`[OAuth] Using static endpoints (no discovery)`);
console.log('[OAuth] Using static endpoints (no discovery)');
this.cache.config = oauthClient.createStaticConfiguration(
providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint,
@ -206,27 +274,33 @@ export class GoogleClientFactory {
this.cache.clientId = clientId;
this.cache.clientSecret = clientSecret ?? null;
console.log(`[OAuth] Google OAuth configuration initialized`);
console.log('[OAuth] Google OAuth configuration initialized');
}
/**
* Create OAuth2Client from OAuthTokens
*/
private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
const client = new OAuth2Client(
clientId,
clientSecret ?? undefined,
undefined // redirect_uri not needed for token usage
);
// Set credentials
/** BYOK OAuth2Client — has client_id + secret + refresh_token. */
private static createByokClient(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
const client = new OAuth2Client(clientId, clientSecret ?? undefined, undefined);
client.setCredentials({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token || undefined,
expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds
expiry_date: tokens.expires_at * 1000,
scope: tokens.scopes?.join(' ') || undefined,
});
return client;
}
/**
* Rowboat OAuth2Client no client_id/secret, no refresh_token.
* Library auto-refresh is disabled by absence of refresh_token; our
* proactive refresh in getClient() is the only refresh path.
*/
private static createRowboatClient(tokens: OAuthTokens): OAuth2Client {
const client = new OAuth2Client();
client.setCredentials({
access_token: tokens.access_token,
expiry_date: tokens.expires_at * 1000,
scope: tokens.scopes?.join(' ') || undefined,
});
return client;
}
}

View file

@ -163,15 +163,15 @@ If there are events, include them:
1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
4. For emails needing a response, output \\\`\\\`\\\`email blocks with a \`draft_response\`. Write the draft in the user's voice — direct, informal, no fluff. Example:
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
\`\`\`
\\\`\\\`\\\`email
{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."}
\\\`\\\`\\\`emails
{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"Security alert","subject":"New sign-in from Chrome","from":"Google <no-reply@accounts.google.com>","date":"2026-04-01T09:15:00+05:30","latest_email":"A new sign-in to your account was detected."}]}
\\\`\\\`\\\`
\`\`\`
5. For other important/recent emails, output \\\`\\\`\\\`email blocks without \`draft_response\` as FYI items
5. FYI emails go in the same \`emails\` array without a \`draft_response\`
6. **Recency matters.** Since this refreshes every 15 minutes, prioritize emails that arrived since the last refresh. On the first run of the day (morning), include notable emails from the last 24 hours. On subsequent runs, focus on what's new — don't re-list emails the user has already seen unless their status changed (e.g., a thread got a new reply).
7. Add a brief take on emails where it's helpful — flag what's worth reading vs. what's noise. Be direct: "This is a cold pitch, probably skip" or "Worth reading — they're asking about pricing for a team of 50."
8. If no new emails have come in since the last refresh, just say "No new emails" or omit the section entirely. Don't re-surface stale items.
@ -200,7 +200,7 @@ This is NOT a generic task list. These are the things the user should actually f
## Output format
- Start with the date heading as described above
- Use clean markdown with the section headers (## Up Next, ## Calendar, ## Emails, ## What You Missed, ## Today's Priorities)
- Use \\\`\\\`\\\`calendar and \\\`\\\`\\\`email code blocks where specified — these render as interactive UI blocks
- Use \\\`\\\`\\\`calendar and \\\`\\\`\\\`emails (plural) code blocks where specified — these render as interactive UI blocks. Never use \\\`\\\`\\\`email (singular)
- Keep the overall brief **scannable and concise** this should take under 30 seconds to read on a refresh, under 60 seconds for the morning brief
- Write in a natural, conversational tone throughout you're briefing a person, not generating a report
- **Sections can be omitted** if they have nothing to show. Don't include empty sections with filler text. The brief should get shorter as the day goes on and things get resolved.

View file

@ -4,7 +4,7 @@ import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { getKgModel } from '../models/defaults.js';
import { bus } from '../runs/bus.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
@ -112,8 +112,11 @@ async function labelEmailBatch(
});
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
try {
await waitForRunCompletion(run.id, { throwOnError: true });
} finally {
unsubscribe();
}
return { runId: run.id, filesEdited };
}
@ -175,6 +178,7 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
const totalBatches = batches.length;
let totalEdited = 0;
let hadError = false;
let failedBatches = 0;
// Process batches with concurrency limit
for (let i = 0; i < batches.length; i += concurrency) {
@ -209,14 +213,16 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
return result.filesEdited.size;
} catch (error) {
hadError = true;
failedBatches++;
const errorDetails = getErrorDetails(error);
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
message: `Email labeling batch ${batchNumber}/${totalBatches} failed`,
error: errorDetails,
context: { batchNumber },
});
return 0;
@ -238,12 +244,15 @@ export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCU
service: run.service,
runId: run.runId,
level: hadError ? 'error' : 'info',
message: `Email labeling complete: ${totalEdited} files labeled`,
message: hadError
? `Email labeling finished with errors: ${totalEdited} files labeled`
: `Email labeling complete: ${totalEdited} files labeled`,
durationMs: Date.now() - run.startedAt,
outcome: hadError ? 'error' : 'ok',
summary: {
totalEmails: unlabeled.length,
filesLabeled: totalEdited,
failedBatches,
},
});

View file

@ -807,6 +807,43 @@ The summary should answer: **"Who is this person and why do I know them?"**
**Focus on the relationship, not the communication method.**
## Knowing Vs Meeting
Distinguish between **knowing someone** and **having met or heard from them once**.
- Use **"I know X through Y"** only when there is an actual ongoing relationship
- In that construction, **Y** should be a person, organization, or recurring context such as YC, an investor relationship, a customer relationship, or an ongoing project
- For one-off encounters, use **"I met X at/on/during..."** or lead with what they did, such as **"X reached out about..."**, **"X joined..."**, or **"X was part of..."**
- Do **not** use **"I know X through [an event]"** when the thing is a specific meeting, dinner, conference, demo day, call, or other one-off event
- Events are **when or where I met someone**, not **how I know them**
- If the source only shows a single meeting, a single inbound email, or a one-time introduction, do not imply an ongoing relationship unless the broader context clearly supports it
Examples:
- Incorrect: \`I know him through a YC dinner.\`
- Correct: \`I met him at a YC dinner.\`
- Incorrect: \`I know her through a call about pricing.\`
- Correct: \`She reached out about pricing.\`
- Correct: \`I know her through YC and ongoing investor conversations.\`
## Perspective And Self-Reference
These knowledge notes are written from the **user's first-person perspective**.
- When the user's identity is known, **"I / me / my" refer to the user**
- When the company or team is the actor, use **"we / us / our"** when natural
- Name other participants normally
- **Do not refer to the user by name, email, or in third person inside first-person narration**
- Do not write broken combinations like **"I know him ... that met with Arjun"** when Arjun is the user
- Apply this consistently across **all note types and sections**: summaries, activity entries, timelines, decisions, open items, and any narrative prose
Examples:
- Incorrect: \`I know him as part of the Standard Capital team that met with Arjun and Ramnique.\`
- Correct: \`I know him as part of the Standard Capital team that met with me and Ramnique.\`
- Incorrect: \`Arjun discussed pricing with [[People/Sarah Chen]].\`
- Correct: \`I discussed pricing with [[People/Sarah Chen]].\`
## Activity Summary
One line summarizing this source's relevance to the entity:

View file

@ -26,7 +26,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them, what you're working on together.}
{2-3 sentences: Who they are, whether I know them through an ongoing relationship or met them in a specific encounter, and what we're discussing or working on together if applicable.}
## Connected to
- [[Organizations/{Organization}]] works at
@ -59,7 +59,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
{2-3 sentences: What this org is, how I know or work with them.}
## People
- [[People/{Person}]] {role}
@ -93,7 +93,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
{2-3 sentences: What this project is, the goal, current state, and my/our involvement where relevant.}
## People
- [[People/{Person}]] {role}

View file

@ -0,0 +1,180 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import { WorkDir } from "../config/config.js";
import container from "../di/container.js";
import type { INotificationService } from "../application/notification/service.js";
const TICK_INTERVAL_MS = 30_000;
// Notify when an event is between 30s in the past (started just now) and
// 90s in the future (about to start). The window is wider than 60s so we
// don't miss an event if the tick lands slightly off the start time.
const NOTIFY_LEAD_MS = 90_000;
const NOTIFY_GRACE_MS = 30_000;
// Drop state entries older than 24h so the file doesn't grow forever.
const STATE_TTL_MS = 24 * 60 * 60 * 1000;
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json");
interface NotificationState {
notifiedEventIds: Record<string, { notifiedAt: string; startTime: string }>;
}
interface CalendarEvent {
id?: string;
summary?: string;
status?: string;
start?: { dateTime?: string; date?: string; timeZone?: string };
end?: { dateTime?: string; date?: string };
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>;
hangoutLink?: string;
conferenceData?: {
entryPoints?: Array<{ entryPointType?: string; uri?: string }>;
};
}
async function loadState(): Promise<NotificationState> {
try {
const raw = await fs.readFile(STATE_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) {
return parsed as NotificationState;
}
} catch {
// No state file yet, or corrupt — start fresh.
}
return { notifiedEventIds: {} };
}
async function saveState(state: NotificationState): Promise<void> {
// Write to a sibling tmp file then rename so a mid-write crash can't leave
// the JSON corrupt — a corrupt state file would make every event in the
// 90s notify window re-fire on next start.
const tmp = `${STATE_FILE}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
await fs.rename(tmp, STATE_FILE);
}
function gcState(state: NotificationState): NotificationState {
const cutoff = Date.now() - STATE_TTL_MS;
const fresh: NotificationState["notifiedEventIds"] = {};
for (const [id, entry] of Object.entries(state.notifiedEventIds)) {
const ts = Date.parse(entry.notifiedAt);
if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry;
}
return { notifiedEventIds: fresh };
}
function isAllDay(event: CalendarEvent): boolean {
// Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`.
return !event.start?.dateTime;
}
function isDeclinedBySelf(event: CalendarEvent): boolean {
if (!event.attendees) return false;
const self = event.attendees.find((a) => a.self);
return self?.responseStatus === "declined";
}
async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> {
let entries: Dirent[];
try {
entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true });
} catch {
return { state, dirty: false };
}
let service: INotificationService;
try {
service = container.resolve<INotificationService>("notificationService");
} catch {
// Notification service not registered yet (very early startup) — skip this tick.
return { state, dirty: false };
}
if (!service.isSupported()) return { state, dirty: false };
const now = Date.now();
let dirty = false;
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue;
const eventId = entry.name.replace(/\.json$/, "");
if (state.notifiedEventIds[eventId]) continue;
const filePath = path.join(CALENDAR_SYNC_DIR, entry.name);
let event: CalendarEvent;
try {
event = JSON.parse(await fs.readFile(filePath, "utf-8"));
} catch {
continue;
}
if (event.status === "cancelled") continue;
if (isAllDay(event)) continue;
if (isDeclinedBySelf(event)) continue;
const startStr = event.start?.dateTime;
if (!startStr) continue;
const startMs = Date.parse(startStr);
if (!Number.isFinite(startMs)) continue;
const msUntilStart = startMs - now;
if (msUntilStart > NOTIFY_LEAD_MS) continue;
if (msUntilStart < -NOTIFY_GRACE_MS) continue;
const summary = event.summary?.trim() || "Untitled meeting";
const eid = encodeURIComponent(eventId);
try {
service.notify({
title: "Upcoming meeting",
message: `${summary} starts in 1 minute. Click to join and take notes.`,
// Single labeled button — adding a secondary action would force
// macOS to bundle them into an "Options" dropdown, hiding the
// primary label.
link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`,
actionLabel: "Join & Notes",
});
console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`);
} catch (err) {
console.error(`[CalendarNotify] notify failed for ${eventId}:`, err);
continue;
}
state.notifiedEventIds[eventId] = {
notifiedAt: new Date().toISOString(),
startTime: startStr,
};
dirty = true;
}
return { state, dirty };
}
export async function init(): Promise<void> {
console.log("[CalendarNotify] starting calendar notification service");
console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`);
let state = gcState(await loadState());
while (true) {
try {
const result = await tick(state);
state = result.state;
if (result.dirty) {
state = gcState(state);
try {
await saveState(state);
} catch (err) {
console.error("[CalendarNotify] failed to save state:", err);
}
}
} catch (err) {
console.error("[CalendarNotify] tick failed:", err);
}
await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS));
}
}

View file

@ -5,10 +5,8 @@ import { OAuth2Client } from 'google-auth-library';
import { NodeHtmlMarkdown } from 'node-html-markdown'
import { WorkDir } from '../config/config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { createEvent } from './track/events.js';
const MAX_EVENTS_IN_DIGEST = 50;
@ -138,7 +136,6 @@ async function publishCalendarSyncEvent(
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const LOOKBACK_DAYS = 7;
const COMPOSIO_LOOKBACK_DAYS = 7;
const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly'
@ -477,287 +474,18 @@ async function performSync(syncDir: string, lookbackDays: number) {
}
}
// --- Composio-based Sync ---
interface ComposioCalendarState {
last_sync: string; // ISO string
}
function loadComposioState(stateFile: string): ComposioCalendarState | null {
if (fs.existsSync(stateFile)) {
try {
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (data.last_sync) {
return { last_sync: data.last_sync };
}
} catch (e) {
console.error('[Calendar] Failed to load composio state:', e);
}
}
return null;
}
function saveComposioState(stateFile: string, lastSync: string): void {
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
}
/**
* Save a Composio calendar event as JSON (same format used by Google OAuth path).
* The event data from Composio is already structured similarly to Google Calendar API.
*/
function saveComposioEvent(eventData: Record<string, unknown>, syncDir: string): { changed: boolean; isNew: boolean; title: string } {
const eventId = eventData.id as string | undefined;
if (!eventId) return { changed: false, isNew: false, title: 'Unknown' };
const filePath = path.join(syncDir, `${eventId}.json`);
const content = JSON.stringify(eventData, null, 2);
const exists = fs.existsSync(filePath);
try {
if (exists) {
const existing = fs.readFileSync(filePath, 'utf-8');
if (existing === content) {
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
}
}
fs.writeFileSync(filePath, content);
return { changed: true, isNew: !exists, title: (eventData.summary as string) || eventId };
} catch (e) {
console.error(`[Calendar] Error saving event ${eventId}:`, e);
return { changed: false, isNew: false, title: (eventData.summary as string) || eventId };
}
}
async function performSyncComposio() {
const STATE_FILE = path.join(SYNC_DIR, 'composio_state.json');
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
const account = composioAccountsRepo.getAccount('googlecalendar');
if (!account || account.status !== 'ACTIVE') {
console.log('[Calendar] Google Calendar not connected via Composio. Skipping sync.');
return;
}
const connectedAccountId = account.id;
// Calculate time window: lookback + 14 days forward
const now = new Date();
const lookbackMs = COMPOSIO_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000;
const timeMin = new Date(now.getTime() - lookbackMs).toISOString();
const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString();
console.log(`[Calendar] Syncing via Composio from ${timeMin} to ${timeMax} (lookback: ${COMPOSIO_LOOKBACK_DAYS} days)...`);
let run: ServiceRunContext | null = null;
const ensureRun = async (): Promise<ServiceRunContext> => {
if (!run) {
run = await serviceLogger.startRun({
service: 'calendar',
message: 'Syncing calendar (Composio)',
trigger: 'timer',
});
}
return run;
};
try {
const currentEventIds = new Set<string>();
let newCount = 0;
let updatedCount = 0;
const changedTitles: string[] = [];
const newEvents: AnyEvent[] = [];
const updatedEvents: AnyEvent[] = [];
let pageToken: string | null = null;
const MAX_PAGES = 20;
for (let page = 0; page < MAX_PAGES; page++) {
// Re-check connection in case user disconnected mid-sync
if (!composioAccountsRepo.isConnected('googlecalendar')) {
console.log('[Calendar] Account disconnected during sync. Stopping.');
return;
}
const args: Record<string, unknown> = {
calendar_id: 'primary',
time_min: timeMin,
time_max: timeMax,
single_events: true,
order_by: 'startTime',
};
if (pageToken) {
args.page_token = pageToken;
}
const result = await executeAction(
'GOOGLECALENDAR_FIND_EVENT',
{
connected_account_id: connectedAccountId,
user_id: 'rowboat-user',
version: 'latest',
arguments: args,
}
);
if (!result.successful || !result.data) {
console.error('[Calendar] Failed to list events via Composio:', result.error);
return;
}
const data = result.data as Record<string, unknown>;
// Composio may return events in different structures
let events: Array<Record<string, unknown>> = [];
if (Array.isArray(data.items)) {
events = data.items as Array<Record<string, unknown>>;
} else if (Array.isArray(data.events)) {
events = data.events as Array<Record<string, unknown>>;
} else if (data.event_data && typeof data.event_data === 'object') {
const nested = data.event_data as Record<string, unknown>;
if (Array.isArray(nested.event_data)) {
events = nested.event_data as Array<Record<string, unknown>>;
} else if (Array.isArray(data.event_data)) {
events = data.event_data as Array<Record<string, unknown>>;
}
} else if (Array.isArray(data)) {
events = data as unknown as Array<Record<string, unknown>>;
}
if (events.length === 0 && page === 0) {
console.log('[Calendar] No events found in this window.');
} else if (events.length > 0) {
console.log(`[Calendar] Page ${page + 1}: found ${events.length} events.`);
for (const event of events) {
const eventId = event.id as string | undefined;
if (eventId) {
const saveResult = saveComposioEvent(event, SYNC_DIR);
currentEventIds.add(eventId);
if (saveResult.changed) {
await ensureRun();
changedTitles.push(saveResult.title);
if (saveResult.isNew) {
newCount++;
newEvents.push(event);
} else {
updatedCount++;
updatedEvents.push(event);
}
}
}
}
}
// Check for next page
const nextToken = data.nextPageToken as string | undefined;
if (nextToken) {
pageToken = nextToken;
console.log(`[Calendar] Fetching next page...`);
} else {
break;
}
}
// Clean up events no longer in the window
const deletedFiles = cleanUpOldFiles(currentEventIds, SYNC_DIR);
let deletedCount = 0;
if (deletedFiles.length > 0) {
await ensureRun();
deletedCount = deletedFiles.length;
}
// Publish a single bundled event capturing all changes from this sync.
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
// Log results if any changes were detected (run was started by ensureRun)
if (run) {
const r = run as ServiceRunContext;
const totalChanges = newCount + updatedCount + deletedCount;
const limitedTitles = limitEventItems(changedTitles);
await serviceLogger.log({
type: 'changes_identified',
service: r.service,
runId: r.runId,
level: 'info',
message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
counts: {
newEvents: newCount,
updatedEvents: updatedCount,
deletedFiles: deletedCount,
},
items: limitedTitles.items,
truncated: limitedTitles.truncated,
});
await serviceLogger.log({
type: 'run_complete',
service: r.service,
runId: r.runId,
level: 'info',
message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`,
durationMs: Date.now() - r.startedAt,
outcome: 'ok',
summary: {
newEvents: newCount,
updatedEvents: updatedCount,
deletedFiles: deletedCount,
},
});
}
// Save state
saveComposioState(STATE_FILE, new Date().toISOString());
console.log(`[Calendar] Composio sync completed. ${newCount} new, ${updatedCount} updated, ${deletedCount} deleted.`);
} catch (error) {
console.error('[Calendar] Error during Composio sync:', error);
const errRun = await ensureRun();
await serviceLogger.log({
type: 'error',
service: errRun.service,
runId: errRun.runId,
level: 'error',
message: 'Calendar sync error',
error: error instanceof Error ? error.message : String(error),
});
await serviceLogger.log({
type: 'run_complete',
service: errRun.service,
runId: errRun.runId,
level: 'error',
message: 'Calendar sync failed',
durationMs: Date.now() - errRun.startedAt,
outcome: 'error',
});
}
}
export async function init() {
console.log("Starting Google Calendar & Notes Sync (TS)...");
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
while (true) {
try {
const composioMode = await useComposioForGoogleCalendar();
if (composioMode) {
const isConnected = composioAccountsRepo.isConnected('googlecalendar');
if (!isConnected) {
console.log('[Calendar] Google Calendar not connected via Composio. Sleeping...');
} else {
await performSyncComposio();
}
} else {
// Check if credentials are available with required scopes
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
if (!hasCredentials) {
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");
} else {
// Perform one sync
await performSync(SYNC_DIR, LOOKBACK_DAYS);
}
}
} catch (error) {
console.error("Error in main loop:", error);
}

View file

@ -7,8 +7,6 @@ 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 { executeAction, useComposioForGoogle } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { createEvent } from './track/events.js';
// Configuration
@ -225,7 +223,7 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
}
}
function loadState(stateFile: string): { historyId?: string } {
function loadState(stateFile: string): { historyId?: string; last_sync?: string } {
if (fs.existsSync(stateFile)) {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
@ -240,9 +238,24 @@ function saveState(historyId: string, stateFile: string) {
}
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
console.log(`Performing full sync of last ${lookbackDays} days...`);
const gmail = google.gmail({ version: 'v1', auth });
// If the state file holds a last_sync timestamp (e.g. left over from a
// prior Composio sync, or from a previous successful native sync that
// we're falling back to after a history.list 404), use that as the
// floor instead of the default lookback. Carries forward Composio's
// last_sync on first migration so we don't refetch the last 7 days.
const state = loadState(stateFile);
let pastDate: Date;
if (state.last_sync) {
pastDate = new Date(state.last_sync);
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
} else {
pastDate = new Date();
pastDate.setDate(pastDate.getDate() - lookbackDays);
console.log(`Performing full sync of last ${lookbackDays} days...`);
}
let run: ServiceRunContext | null = null;
const ensureRun = async () => {
if (!run) {
@ -255,8 +268,6 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
};
try {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - lookbackDays);
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
// Get History ID
@ -498,387 +509,18 @@ async function performSync() {
}
}
// --- Composio-based Sync ---
const COMPOSIO_LOOKBACK_DAYS = 7;
interface ComposioSyncState {
last_sync: string; // ISO string
}
function loadComposioState(stateFile: string): ComposioSyncState | null {
if (fs.existsSync(stateFile)) {
try {
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (data.last_sync) {
return { last_sync: data.last_sync };
}
} catch (e) {
console.error('[Gmail] Failed to load composio state:', e);
}
}
return null;
}
function saveComposioState(stateFile: string, lastSync: string): void {
fs.writeFileSync(stateFile, JSON.stringify({ last_sync: lastSync }, null, 2));
}
function tryParseDate(dateStr: string): Date | null {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d;
}
interface ParsedMessage {
from: string;
date: string;
subject: string;
body: string;
}
function parseMessageData(messageData: Record<string, unknown>): ParsedMessage {
const headers = messageData.payload && typeof messageData.payload === 'object'
? (messageData.payload as Record<string, unknown>).headers as Array<{ name: string; value: string }> | undefined
: undefined;
const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown');
const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown');
const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)');
let body = '';
if (messageData.payload && typeof messageData.payload === 'object') {
body = extractBodyFromPayload(messageData.payload as Record<string, unknown>);
}
if (!body) {
if (typeof messageData.body === 'string') {
body = messageData.body;
} else if (typeof messageData.snippet === 'string') {
body = messageData.snippet;
} else if (typeof messageData.text === 'string') {
body = messageData.text;
}
}
if (body && (body.includes('<html') || body.includes('<div') || body.includes('<p'))) {
body = nhm.translate(body);
}
if (body) {
body = body.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
}
return { from, date, subject, body };
}
function extractBodyFromPayload(payload: Record<string, unknown>): string {
const parts = payload.parts as Array<Record<string, unknown>> | undefined;
if (parts) {
for (const part of parts) {
const mimeType = part.mimeType as string | undefined;
const bodyData = part.body && typeof part.body === 'object'
? (part.body as Record<string, unknown>).data as string | undefined
: undefined;
if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) {
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
if (mimeType === 'text/html') {
return nhm.translate(decoded);
}
return decoded;
}
if (part.parts) {
const result = extractBodyFromPayload(part as Record<string, unknown>);
if (result) return result;
}
}
}
const bodyData = payload.body && typeof payload.body === 'object'
? (payload.body as Record<string, unknown>).data as string | undefined
: undefined;
if (bodyData) {
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
const mimeType = payload.mimeType as string | undefined;
if (mimeType === 'text/html') {
return nhm.translate(decoded);
}
return decoded;
}
return '';
}
interface ComposioThreadResult {
synced: SyncedThread | null;
newestIsoPlusOne: string | null;
}
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<ComposioThreadResult> {
let threadResult;
try {
threadResult = await executeAction(
'GMAIL_FETCH_MESSAGE_BY_THREAD_ID',
{
connected_account_id: connectedAccountId,
user_id: 'rowboat-user',
version: 'latest',
arguments: { thread_id: threadId, user_id: 'me' },
}
);
} catch (error) {
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
return { synced: null, newestIsoPlusOne: null };
}
if (!threadResult.successful || !threadResult.data) {
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
return { synced: null, newestIsoPlusOne: null };
}
const data = threadResult.data as Record<string, unknown>;
const messages = data.messages as Array<Record<string, unknown>> | undefined;
let newestDate: Date | null = null;
let mdContent: string;
let subjectForLog: string;
if (!messages || messages.length === 0) {
const parsed = parseMessageData(data);
mdContent = `# ${parsed.subject}\n\n` +
`**Thread ID:** ${threadId}\n` +
`**Message Count:** 1\n\n---\n\n` +
`### From: ${parsed.from}\n` +
`**Date:** ${parsed.date}\n\n` +
`${parsed.body}\n\n---\n\n`;
subjectForLog = parsed.subject;
newestDate = tryParseDate(parsed.date);
} else {
const firstParsed = parseMessageData(messages[0]);
mdContent = `# ${firstParsed.subject}\n\n`;
mdContent += `**Thread ID:** ${threadId}\n`;
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
for (const msg of messages) {
const parsed = parseMessageData(msg);
mdContent += `### From: ${parsed.from}\n`;
mdContent += `**Date:** ${parsed.date}\n\n`;
mdContent += `${parsed.body}\n\n`;
mdContent += `---\n\n`;
const msgDate = tryParseDate(parsed.date);
if (msgDate && (!newestDate || msgDate > newestDate)) {
newestDate = msgDate;
}
}
subjectForLog = firstParsed.subject;
}
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`);
const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null;
return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne };
}
async function performSyncComposio() {
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const account = composioAccountsRepo.getAccount('gmail');
if (!account || account.status !== 'ACTIVE') {
console.log('[Gmail] Gmail not connected via Composio. Skipping sync.');
return;
}
const connectedAccountId = account.id;
const state = loadComposioState(STATE_FILE);
let afterEpochSeconds: number;
if (state) {
afterEpochSeconds = Math.floor(new Date(state.last_sync).getTime() / 1000);
console.log(`[Gmail] Syncing messages since ${state.last_sync}...`);
} else {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - COMPOSIO_LOOKBACK_DAYS);
afterEpochSeconds = Math.floor(pastDate.getTime() / 1000);
console.log(`[Gmail] First sync - fetching last ${COMPOSIO_LOOKBACK_DAYS} days...`);
}
let run: ServiceRunContext | null = null;
const ensureRun = async () => {
if (!run) {
run = await serviceLogger.startRun({
service: 'gmail',
message: 'Syncing Gmail (Composio)',
trigger: 'timer',
});
}
};
try {
const allThreadIds: string[] = [];
let pageToken: string | undefined;
do {
const params: Record<string, unknown> = {
query: `after:${afterEpochSeconds}`,
max_results: 20,
user_id: 'me',
};
if (pageToken) {
params.page_token = pageToken;
}
const result = await executeAction(
'GMAIL_LIST_THREADS',
{
connected_account_id: connectedAccountId,
user_id: 'rowboat-user',
version: 'latest',
arguments: params,
}
);
if (!result.successful || !result.data) {
console.error('[Gmail] Failed to list threads:', result.error);
return;
}
const data = result.data as Record<string, unknown>;
const threads = data.threads as Array<Record<string, unknown>> | undefined;
if (threads && threads.length > 0) {
for (const thread of threads) {
const threadId = thread.id as string | undefined;
if (threadId) {
allThreadIds.push(threadId);
}
}
}
pageToken = data.nextPageToken as string | undefined;
} while (pageToken);
if (allThreadIds.length === 0) {
console.log('[Gmail] No new threads.');
return;
}
console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`);
await ensureRun();
const limitedThreads = limitEventItems(allThreadIds);
await serviceLogger.log({
type: 'changes_identified',
service: run!.service,
runId: run!.runId,
level: 'info',
message: `Found ${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'} to sync`,
counts: { threads: allThreadIds.length },
items: limitedThreads.items,
truncated: limitedThreads.truncated,
});
// Process oldest first so high-water mark advances chronologically
allThreadIds.reverse();
let highWaterMark: string | null = state?.last_sync ?? null;
let processedCount = 0;
const synced: SyncedThread[] = [];
for (const threadId of allThreadIds) {
// Re-check connection in case user disconnected mid-sync
if (!composioAccountsRepo.isConnected('gmail')) {
console.log('[Gmail] Account disconnected during sync. Stopping.');
break;
}
try {
const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
processedCount++;
if (result.synced) synced.push(result.synced);
if (result.newestIsoPlusOne) {
if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) {
highWaterMark = result.newestIsoPlusOne;
}
saveComposioState(STATE_FILE, highWaterMark);
}
} catch (error) {
console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error);
}
}
await publishGmailSyncEvent(synced);
await serviceLogger.log({
type: 'run_complete',
service: run!.service,
runId: run!.runId,
level: 'info',
message: `Gmail sync complete: ${processedCount}/${allThreadIds.length} thread${allThreadIds.length === 1 ? '' : 's'}`,
durationMs: Date.now() - run!.startedAt,
outcome: 'ok',
summary: { threads: processedCount },
});
console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`);
} catch (error) {
console.error('[Gmail] Error during sync:', error);
await ensureRun();
await serviceLogger.log({
type: 'error',
service: run!.service,
runId: run!.runId,
level: 'error',
message: 'Gmail sync error',
error: error instanceof Error ? error.message : String(error),
});
await serviceLogger.log({
type: 'run_complete',
service: run!.service,
runId: run!.runId,
level: 'error',
message: 'Gmail sync failed',
durationMs: Date.now() - run!.startedAt,
outcome: 'error',
});
}
}
export async function init() {
console.log("Starting Gmail Sync (TS)...");
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
while (true) {
try {
const composioMode = await useComposioForGoogle();
if (composioMode) {
const isConnected = composioAccountsRepo.isConnected('gmail');
if (!isConnected) {
console.log('[Gmail] Gmail not connected via Composio. Sleeping...');
} else {
await performSyncComposio();
}
} else {
// Check if credentials are available with required scopes
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
if (!hasCredentials) {
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
} else {
// Perform one sync
await performSync();
}
}
} catch (error) {
console.error("Error in main loop:", error);
}

View file

@ -4,7 +4,7 @@ import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { getKgModel } from '../models/defaults.js';
import { bus } from '../runs/bus.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
@ -125,8 +125,11 @@ async function tagNoteBatch(
});
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
try {
await waitForRunCompletion(run.id, { throwOnError: true });
} finally {
unsubscribe();
}
return { runId: run.id, filesEdited };
}
@ -169,6 +172,7 @@ export async function processUntaggedNotes(): Promise<void> {
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
let totalEdited = 0;
let hadError = false;
let failedBatches = 0;
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
const batchPaths = untagged.slice(i, i + BATCH_SIZE);
@ -217,14 +221,16 @@ export async function processUntaggedNotes(): Promise<void> {
console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`);
} catch (error) {
hadError = true;
failedBatches++;
const errorDetails = getErrorDetails(error);
console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
message: `Note tagging batch ${batchNumber}/${totalBatches} failed`,
error: errorDetails,
context: { batchNumber },
});
}
@ -238,12 +244,15 @@ export async function processUntaggedNotes(): Promise<void> {
service: run.service,
runId: run.runId,
level: hadError ? 'error' : 'info',
message: `Note tagging complete: ${totalEdited} notes tagged`,
message: hadError
? `Note tagging finished with errors: ${totalEdited} notes tagged`
: `Note tagging complete: ${totalEdited} notes tagged`,
durationMs: Date.now() - run.startedAt,
outcome: hadError ? 'error' : 'ok',
summary: {
totalNotes: untagged.length,
notesTagged: totalEdited,
failedBatches,
},
});

View file

@ -1,4 +1,4 @@
import type { TrackEventType } from '@x/shared/dist/track-block.js';
import type { TrackEventType } from '@x/shared/dist/track.js';
type Handler = (event: TrackEventType) => void;

View file

@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import { PrefixLogger, trackBlock } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import { PrefixLogger, track } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
import { WorkDir } from '../../config/config.js';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
@ -59,10 +59,17 @@ async function listAllTracks(): Promise<ParsedTrack[]> {
continue;
}
for (const t of parsedTracks) {
const eventCriteria = (t.track.triggers ?? [])
.filter(trig => trig.type === 'event')
.map(trig => trig.matchCriteria)
.filter(Boolean)
.join('; ');
// Skip tracks with no event triggers — they're not event-eligible.
if (!eventCriteria) continue;
tracks.push({
trackId: t.track.trackId,
trackId: t.track.id,
filePath,
eventMatchCriteria: t.track.eventMatchCriteria ?? '',
eventMatchCriteria: eventCriteria,
instruction: t.track.instruction,
active: t.track.active,
});
@ -89,7 +96,7 @@ async function processOneEvent(filename: string): Promise<void> {
try {
const raw = fs.readFileSync(pendingPath, 'utf-8');
const parsed = JSON.parse(raw);
event = trackBlock.KnowledgeEventSchema.parse(parsed);
event = track.KnowledgeEventSchema.parse(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.log(`Malformed event ${filename}, moving to done with error:`, msg);

View file

@ -3,9 +3,10 @@ import fs from 'fs/promises';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { WorkDir } from '../../config/config.js';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { TrackSchema } from '@x/shared/dist/track.js';
import { TrackStateSchema } from './types.js';
import { withFileLock } from '../file-lock.js';
import { splitFrontmatter, joinFrontmatter } from '../../application/lib/parse-frontmatter.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
@ -13,6 +14,29 @@ function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
// ---------------------------------------------------------------------------
// Track-array helpers (read/write the `track:` key in a parsed frontmatter)
// ---------------------------------------------------------------------------
function getTrackArray(fm: Record<string, unknown>): unknown[] {
const raw = fm.track;
return Array.isArray(raw) ? raw : [];
}
function setTrackArray(fm: Record<string, unknown>, tracks: unknown[]): Record<string, unknown> {
const next = { ...fm };
if (tracks.length === 0) {
delete next.track;
} else {
next.track = tracks;
}
return next;
}
// ---------------------------------------------------------------------------
// Read
// ---------------------------------------------------------------------------
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
let content: string;
try {
@ -20,180 +44,224 @@ export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackSt
} 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;
}
const lines = content.split('\n');
const blocks: z.infer<typeof TrackStateSchema>[] = [];
let i = 0;
const contentFenceStartMatcher = /<!--track-target:(.+)-->/;
const contentFenceEndMatcher = /<!--\/track-target:(.+)-->/;
while (i < lines.length) {
if (lines[i].trim() === '```track') {
const fenceStart = i;
i++;
const blockLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
blockLines.push(lines[i]);
i++;
export async function fetch(filePath: string, id: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const all = await fetchAll(filePath);
return all.find(t => t.track.id === id) ?? null;
}
export async function fetchYaml(filePath: string, id: string): Promise<string | null> {
const t = await fetch(filePath, id);
if (!t) return null;
return stringifyYaml(t.track).trimEnd();
}
export async function readNoteBody(filePath: string): Promise<string> {
let content: string;
try {
const data = parseYaml(blockLines.join('\n'));
const result = TrackBlockSchema.safeParse(data);
if (result.success) {
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return '';
}
} catch { /* skip */ }
} else if (contentFenceStartMatcher.test(lines[i])) {
const match = contentFenceStartMatcher.exec(lines[i]);
if (match) {
const trackId = match[1];
// have we already collected this track block?
const existingBlock = blocks.find(b => b.track.trackId === trackId);
if (!existingBlock) {
i++;
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;
}
const contentStart = i + 1;
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
i++;
}
const contentEnd = i;
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
files.push(childRelPath);
}
}
i++;
return files;
} catch {
return [];
}
return blocks;
}
export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const blocks = await fetchAll(filePath);
return blocks.find(b => b.track.trackId === trackId) ?? null;
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;
}
}));
/**
* Fetch a track block and return its canonical YAML string (or null if not found).
* Useful for IPC handlers that need to return the fresh YAML without taking a
* dependency on the `yaml` package themselves.
*/
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
const block = await fetch(filePath, trackId);
if (!block) return null;
return stringifyYaml(block.track).trimEnd();
}
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
let content = await fs.readFile(absPath(filePath), 'utf-8');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
const openIdx = content.indexOf(openTag);
const closeIdx = content.indexOf(closeTag);
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
} else {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const lines = content.split('\n');
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
const contentFence = [openTag, newContent, closeTag];
lines.splice(insertAt, 0, ...contentFence);
content = lines.join('\n');
}
await fs.writeFile(absPath(filePath), content, 'utf-8');
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 updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
return withFileLock(absPath(filepath), async () => {
const block = await fetch(filepath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filepath}`);
}
block.track = { ...block.track, ...updates };
// read file contents
let content = await fs.readFile(absPath(filepath), 'utf-8');
const lines = content.split('\n');
const yaml = stringifyYaml(block.track).trimEnd();
const yamlLines = yaml ? yaml.split('\n') : [];
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
content = lines.join('\n');
await fs.writeFile(absPath(filepath), content, 'utf-8');
});
}
/**
* Replace the entire YAML of a track block on disk with a new string.
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim
* used when the user explicitly edits raw YAML in the modal.
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
* otherwise the write is rejected.
*/
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
export async function setNoteTracksActive(
filePath: string,
active: boolean,
): Promise<TrackNoteSummary | null> {
return withFileLock(absPath(filePath), async () => {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
if (!parsed.success) {
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
}
if (parsed.data.trackId !== trackId) {
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const yamlLines = newYaml.trimEnd().split('\n');
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
});
}
/**
* Remove a track block and its sibling target region from the file.
*/
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const block = await fetch(filePath, trackId);
if (!block) {
// Already gone — treat as success.
return;
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
// Find target region (may not exist)
let targetStart = -1;
let targetEnd = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(openTag)) { targetStart = i; }
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
}
// Build a list of [start, end] ranges to remove, sorted descending so
// indices stay valid as we splice.
const ranges: Array<[number, number]> = [];
ranges.push([block.fenceStart, block.fenceEnd]);
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
ranges.push([targetStart, targetEnd]);
}
ranges.sort((a, b) => b[0] - a[0]);
for (const [start, end] of ranges) {
lines.splice(start, end - start + 1);
// Also drop a trailing blank line if the removal left two in a row.
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
lines.splice(start, 1);
}
}
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const rawTracks = getTrackArray(frontmatter);
if (rawTracks.length === 0) return null;
const allMatch = rawTracks.every(
(raw) => raw && typeof raw === 'object'
&& ((raw as Record<string, unknown>).active !== false) === active,
);
if (!allMatch) {
const updated = rawTracks.map((raw) =>
raw && typeof raw === 'object'
? { ...(raw as Record<string, unknown>), active }
: raw,
);
const nextFm = setTrackArray(frontmatter, updated);
await fs.writeFile(absPath(filePath), joinFrontmatter(nextFm, body), 'utf-8');
}
const validated = await fetchAll(filePath);
return summarizeTrackNote(filePath, validated);
});
}

View file

@ -1,6 +1,6 @@
import { generateObject } from 'ai';
import { trackBlock, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import { track, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track.js';
import { createProvider } from '../../models/models.js';
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
import { captureLlmUsage } from '../../analytics/usage.js';
@ -19,12 +19,12 @@ export interface ParsedTrack {
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
You will receive an event (something that happened an email, meeting, message, etc.) and a list of track blocks. Each track block has:
You will receive an event (something that happened an email, meeting, message, etc.) and a list of tracks. Each track has:
- trackId: an identifier (only unique within its file)
- filePath: the note file the track lives in
- eventMatchCriteria: a description of what kinds of signals are relevant to this track
- matchCriteria: a description of what kinds of signals are relevant to this track (collected from the track's event triggers)
Your job is to identify which track blocks MIGHT be relevant to this event.
Your job is to identify which tracks MIGHT be relevant to this event.
Rules:
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
@ -47,7 +47,7 @@ async function resolveModel() {
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
const trackList = batch
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n eventMatchCriteria: ${t.eventMatchCriteria}`)
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n matchCriteria: ${t.eventMatchCriteria}`)
.join('\n\n');
return `## Event
@ -58,7 +58,7 @@ Time: ${event.createdAt}
${event.payload}
## Track Blocks
## Tracks
${trackList}`;
}
@ -99,7 +99,7 @@ export async function findCandidates(
model,
system: ROUTING_SYSTEM_PROMPT,
prompt: buildRoutingPrompt(event, batch),
schema: trackBlock.Pass1OutputSchema,
schema: track.Pass1OutputSchema,
});
captureLlmUsage({
useCase: 'track_block',

View file

@ -3,32 +3,29 @@ import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
import { WorkDir } from '../../config/config.js';
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date.
const TRACK_RUN_INSTRUCTIONS = `You are a track runner — a background agent that keeps a live note in the user's personal knowledge base up to date.
Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others optimize for **information density and scannability**, not conversational prose.
Your goal on each run: update the body of the note so that, given the track's instruction, the content is the most useful and up-to-date version it can be. The user is maintaining a personal knowledge base and will scan this note alongside many others optimize for **information density and scannability**, not conversational prose.
# Background Mode
You are running as a scheduled or event-triggered background task **there is no user present** to clarify, approve, or watch.
- Do NOT ask clarifying questions make the most reasonable interpretation of the instruction and proceed.
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line.
- Do NOT produce chat-style output. The user sees only the changes you make to the note plus your final summary line.
# Message Anatomy
Every run message has this shape:
Update track **<trackId>** in \`<filePath>\`.
Update track **<id>** in \`<filePath>\`.
**Time:** <localized datetime> (<timezone>)
**Instruction:**
<the user-authored track instruction usually 1-3 sentences describing what to produce>
**Current content:**
<the existing contents of the target region, or "(empty — first run)">
Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`.
Start by calling \`workspace-readFile\` on \`<filePath>\` to read the current note (frontmatter + body). Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file.
For **manual** runs, an optional trailing block may appear:
@ -40,20 +37,49 @@ Apply context for this run only — it is not a permanent edit to the instructio
For **event-triggered** runs, a trailing block appears instead:
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
**Event match criteria for this track:** <from the track's YAML>
**Event match criteria for this track:** <from the track's frontmatter>
**Event payload:** <the event body e.g., an email>
**Decision:** ... skip if not relevant ...
On event runs you are the Pass 2 judge see "The No-Update Decision" below.
# Editing the Note
You have full read/write access to the note body via the standard workspace tools:
- \`workspace-readFile\` — read the current state of the note (frontmatter included; you can ignore the frontmatter).
- \`workspace-edit\` — apply patches.
- \`workspace-writeFile\` — replace the entire file (use sparingly; prefer \`workspace-edit\`).
**Do NOT modify the YAML frontmatter at the top of the file** (the \`---\`-delimited block). It contains the track configuration and runtime state owned by the user and the runtime. Editing it can corrupt the track's schedule, history, or the note's metadata.
# Section Placement
Each track's instruction may name a **section** in the note where its content lives — e.g. *"in a section titled 'Overview' at the top"* or *"in a section titled 'Photo' right after Overview"*. You own that section and only that section.
How to handle sections:
- Sections are H2 headings (\`## Section Name\`). Match by exact heading text.
- **If the named section exists**: replace its content (everything between that heading and the next H2 or end of file) with your new output. Heading itself stays intact.
- **If the section is missing**: create it. Use the placement hint to decide where:
- "at the top" just below the H1 title (or first line if there's none).
- "after X" immediately after section X. If X doesn't exist either, fall back to natural reading order.
- no hint append to the end of the body.
- **Never modify another track's section content.** Other agents own those.
- **Never duplicate a section.** If two H2 headings match yours, consolidate into the first.
- The user may rename your section's heading. If you can't find it by exact name on a later run, recreate it per the placement hint.
After writing your section, **re-check its position**. The first time tracks run on a fresh note, sections land in firing order rather than reading order, so the file ends up out of sequence. If your section is now in the wrong place relative to your placement hint (e.g. your "Photo" section is meant to sit right after "Overview" but ended up at the bottom), **move your own section block** (your H2 heading + its content, no surrounding blank lines lost) to the correct position. Cut-and-paste only never rewrite or reorder *other* tracks' sections; they will self-correct on their own next runs.
A section can hold prose, lists, or rich blocks (calendar/email/image/etc.) per the instruction. You always write a **complete** replacement for the section you own not a diff.
# What Good Output Looks Like
This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
This is a personal knowledge tracker. The user scans many such notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative do not improvise a different layout.
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape.
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know.
- **No commentary or caveats** unless the data itself is genuinely uncertain.
- **No self-reference.** Do not write "I updated this at X" the system records timestamps separately.
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
@ -62,7 +88,7 @@ If the instruction does not specify a format, pick the tightest shape that fits:
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
## \`table\` — tabular data (JSON)
@ -178,7 +204,7 @@ Use for: linking to a video or design that should render inline.
}
\`\`\`
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form.
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`.
## \`iframe\` — arbitrary embedded webpage (JSON)
@ -227,42 +253,26 @@ The instruction was authored in a prior conversation you cannot see. Treat it as
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") these are decoration.
# Current Content Handling
The **Current content** block shows what lives in the target region right now. Three cases:
1. **"(empty — first run)"** produce the content from scratch.
2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep.
3. **Content that does NOT match the instruction's format** the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch.
You always write a **complete** replacement, not a diff.
# The No-Update Decision
You may finish a run without calling \`update-track-content\`. Two legitimate cases:
You may finish a run without writing anything. Two legitimate cases:
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update.
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically.
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information, skip the update.
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system records "no update" automatically.
When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*.
# Writing the Result
Call \`update-track-content\` **at most once per run**:
- Pass \`filePath\` and \`trackId\` exactly as given in the message.
- Pass the **complete** new content as \`content\` — the entire replacement for the target region.
- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those.
- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only.
# Tools
You have the full workspace toolkit. Quick reference for common cases:
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off.
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data.
- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data.
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes.
# The Knowledge Graph
@ -283,7 +293,7 @@ Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
- Do **not** fabricate or speculate.
- Do **not** write partial or placeholder content into the target region leave existing content intact by not calling \`update-track-content\`.
- Do **not** write partial or placeholder content leave the existing body intact by skipping the edit.
- Explain the failure in the summary line.
# Final Summary
@ -309,7 +319,7 @@ export function buildTrackRunAgent(): z.infer<typeof Agent> {
return {
name: 'track-run',
description: 'Background agent that updates track block content',
description: 'Background agent that keeps a track-driven note up to date',
instructions: TRACK_RUN_INSTRUCTIONS,
tools,
};

View file

@ -1,5 +1,5 @@
import z from 'zod';
import { fetchAll, updateTrackBlock } from './fileops.js';
import { fetchAll, updateTrack, readNoteBody } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { getTrackBlockModel } from '../../models/defaults.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
@ -31,30 +31,41 @@ function buildMessage(
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
// Workspace-relative path the agent's tools (workspace-readFile,
// workspace-edit) expect. Internal fileops storage is knowledge/-relative,
// so always prefix here when handing it to the agent.
const wsPath = `knowledge/${filePath}`;
let msg = `Update track **${track.track.id}** in \`${wsPath}\`.
**Time:** ${localNow} (${tz})
**Instruction:**
${track.track.instruction}
**Current content:**
${track.content || '(empty — first run)'}
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then use \`workspace-edit\` to make whatever content changes the instruction requires. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
if (trigger === 'event') {
const eventCriteria = (track.track.triggers ?? [])
.filter(t => t.type === 'event')
.map(t => t.matchCriteria)
.filter(Boolean);
const criteriaText = eventCriteria.length === 0
? '(none — should not happen for event-triggered runs)'
: eventCriteria.length === 1
? eventCriteria[0]
: eventCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
msg += `
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
**Event match criteria for this track:**
${track.track.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)'}
${criteriaText}
**Event payload:**
${context ?? '(no payload)'}
**Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update do NOT call \`update-track-content\`. Only call the tool if the event provides new or changed information that should be reflected in the track.`;
**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update do not call \`workspace-edit\`. Only edit the file if the event provides new or changed information that should be reflected in the note.`;
} else if (context) {
msg += `\n\n**Context:**\n${context}`;
}
@ -73,7 +84,7 @@ const runningTracks = new Set<string>();
// ---------------------------------------------------------------------------
/**
* Trigger an update for a specific track block.
* Trigger an update for a specific track.
* Can be called by any trigger system (manual, cron, event matching).
*/
export async function triggerTrackUpdate(
@ -94,17 +105,14 @@ export async function triggerTrackUpdate(
try {
const tracks = await fetchAll(filePath);
logger.log('fetched tracks from file', tracks);
const track = tracks.find(t => t.track.trackId === trackId);
const track = tracks.find(t => t.track.id === trackId);
if (!track) {
logger.log('track not found', trackId, filePath, trigger, context);
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
}
const contentBefore = track.content;
const bodyBefore = await readNoteBody(filePath);
// Per-track model/provider overrides win when set; otherwise fall back
// to the configured trackBlockModel default and the run-creation
// provider default (signed-in: rowboat; BYOK: active provider).
const model = track.track.model ?? await getTrackBlockModel();
const agentRun = await createRun({
agentId: 'track-run',
@ -116,7 +124,7 @@ export async function triggerTrackUpdate(
// Set lastRunAt and lastRunId immediately (before agent executes) so
// the scheduler's next poll won't re-trigger this track.
await updateTrackBlock(filePath, trackId, {
await updateTrack(filePath, trackId, {
lastRunAt: new Date().toISOString(),
lastRunId: agentRun.id,
});
@ -134,12 +142,11 @@ export async function triggerTrackUpdate(
await waitForRunCompletion(agentRun.id);
const summary = await extractAgentResponse(agentRun.id);
const updatedTracks = await fetchAll(filePath);
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
const didUpdate = contentAfter !== contentBefore;
const bodyAfter = await readNoteBody(filePath);
const didUpdate = bodyAfter !== bodyBefore;
// Update summary on completion
await updateTrackBlock(filePath, trackId, {
// Patch summary into frontmatter on completion.
await updateTrack(filePath, trackId, {
lastRunSummary: summary ?? undefined,
});
@ -155,8 +162,8 @@ export async function triggerTrackUpdate(
trackId,
runId: agentRun.id,
action: didUpdate ? 'replace' : 'no_update',
contentBefore: contentBefore ?? null,
contentAfter: contentAfter ?? null,
contentBefore: bodyBefore,
contentAfter: bodyAfter,
summary,
};
} catch (err) {
@ -170,7 +177,7 @@ export async function triggerTrackUpdate(
error: msg,
});
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: bodyBefore, contentAfter: null, summary: null, error: msg };
}
} finally {
runningTracks.delete(key);

View file

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

View file

@ -2,7 +2,7 @@ import { PrefixLogger } from '@x/shared';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
import { triggerTrackUpdate } from './runner.js';
import { isTrackScheduleDue } from './schedule-utils.js';
import { isTriggerDue, type TimedTrigger } from './schedule-utils.js';
const log = new PrefixLogger('TrackScheduler');
const POLL_INTERVAL_MS = 15_000; // 15 seconds
@ -33,17 +33,23 @@ async function processScheduledTracks(): Promise<void> {
for (const trackState of tracks) {
const { track } = trackState;
if (!track.active) continue;
if (!track.schedule) continue;
if (!track.triggers || track.triggers.length === 0) continue;
const due = isTrackScheduleDue(track.schedule, track.lastRunAt ?? null);
log.log(`Track "${track.trackId}" in ${relativePath}: schedule=${track.schedule.type}, lastRunAt=${track.lastRunAt ?? 'never'}, due=${due}`);
const timed: TimedTrigger[] = track.triggers.filter(
(t): t is TimedTrigger => t.type !== 'event',
);
if (timed.length === 0) continue;
if (due) {
log.log(`Triggering "${track.trackId}" in ${relativePath}`);
triggerTrackUpdate(track.trackId, relativePath, undefined, 'timed').catch(err => {
log.log(`Error running ${track.trackId}:`, err);
});
const dueTrigger = timed.find(t => isTriggerDue(t, track.lastRunAt ?? null));
if (!dueTrigger) {
log.log(`Track "${track.id}" in ${relativePath}: ${timed.length} timed trigger(s), none due`);
continue;
}
log.log(`Triggering "${track.id}" in ${relativePath} (matched ${dueTrigger.type})`);
triggerTrackUpdate(track.id, relativePath, undefined, 'timed').catch(err => {
log.log(`Error running ${track.id}:`, err);
});
}
}
}

View file

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

View file

@ -0,0 +1,132 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { WorkDir } from '../config/config.js';
import { isSignedIn } from '../account/account.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { deleteConnectedAccount } from '../composio/client.js';
import container from '../di/container.js';
import { IOAuthRepo } from '../auth/repo.js';
/**
* One-time migration that moves Composio-connected Gmail/Calendar users
* to the native rowboat-mode Google OAuth flow.
*
* Triggered by the renderer on app launch and after Rowboat sign-in. The
* single guard is `dismissed_at` in the migration state file once set,
* none of the migration's side effects run again. This protects users who
* later re-add Composio Google for non-sync purposes (e.g. a tool that
* happens to use the Gmail toolkit) from having that connection blown
* away on a future launch.
*/
const STATE_FILE = path.join(WorkDir, 'config', 'composio-google-migration.json');
const ZState = z.object({
dismissed_at: z.string().min(1).optional(),
});
type State = z.infer<typeof ZState>;
function loadState(): State {
try {
if (fs.existsSync(STATE_FILE)) {
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
return ZState.parse(JSON.parse(raw));
}
} catch (error) {
console.error('[composio-google-migration] failed to load state:', error);
}
return {};
}
function saveState(state: State): void {
const dir = path.dirname(STATE_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
function markDismissed(): void {
saveState({ dismissed_at: new Date().toISOString() });
}
async function disconnectComposioGoogle(): Promise<void> {
for (const slug of ['gmail', 'googlecalendar'] as const) {
const account = composioAccountsRepo.getAccount(slug);
if (!account?.id) continue;
try {
await deleteConnectedAccount(account.id);
console.log(`[composio-google-migration] composio: deleted ${slug} (${account.id})`);
} catch (error) {
// Best-effort — logged but doesn't block the local cleanup.
console.warn(`[composio-google-migration] composio delete failed for ${slug}:`, error);
}
try {
composioAccountsRepo.deleteAccount(slug);
} catch (error) {
console.warn(`[composio-google-migration] local delete failed for ${slug}:`, error);
}
}
}
function cleanupCalendarComposioState(): void {
const file = path.join(WorkDir, 'calendar_sync', 'composio_state.json');
try {
if (fs.existsSync(file)) {
fs.unlinkSync(file);
console.log('[composio-google-migration] removed stale calendar composio_state.json');
}
} catch (error) {
console.warn('[composio-google-migration] failed to remove composio_state.json:', error);
}
}
/**
* Check whether the user qualifies for the migration. If they do, atomically
* mark `dismissed_at`, fire-and-forget the Composio disconnect, and return
* `{shouldShow: true}` so the renderer can show the modal.
*
* Idempotent: subsequent calls return `{shouldShow: false}` once `dismissed_at`
* is set, regardless of whether the modal was actually shown or the user
* completed the OAuth flow.
*/
export async function qualifyAndDisconnectComposioGoogle(): Promise<{ shouldShow: boolean }> {
// Rule 4 — already processed
const state = loadState();
if (state.dismissed_at) {
return { shouldShow: false };
}
// Rule 1 — must be signed in to Rowboat
if (!(await isSignedIn())) {
return { shouldShow: false };
}
// Rule 3 — already on native rowboat-mode Google → silently mark dismissed
// (so we stop re-checking) and bail before touching Composio state.
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const googleConnection = await oauthRepo.read('google');
if (googleConnection.tokens && googleConnection.mode === 'rowboat') {
markDismissed();
return { shouldShow: false };
}
// Rule 2 — must have at least one Composio Google toolkit connected
const hasGmail = composioAccountsRepo.isConnected('gmail');
const hasCalendar = composioAccountsRepo.isConnected('googlecalendar');
if (!hasGmail && !hasCalendar) {
return { shouldShow: false };
}
// All rules pass. Mark dismissed atomically before any side effects so
// a crash mid-migration leaves us in a deterministic post-migration state.
markDismissed();
// Fire-and-forget: disconnect Composio Google + clean up the stale
// calendar state file. Both are best-effort.
void disconnectComposioGoogle();
cleanupCalendarComposioState();
return { shouldShow: true };
}

View file

@ -6,7 +6,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 = "anthropic/claude-haiku-4.5";
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview";
const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5";
/**

View file

@ -101,6 +101,13 @@ export const EmailBlockSchema = z.object({
export type EmailBlock = z.infer<typeof EmailBlockSchema>;
export const EmailsBlockSchema = z.object({
title: z.string().optional(),
emails: z.array(EmailBlockSchema),
});
export type EmailsBlock = z.infer<typeof EmailsBlockSchema>;
export const TranscriptBlockSchema = z.object({
transcript: z.string(),
});

View file

@ -116,6 +116,12 @@ export const BrowserControlInputSchema = z.object({
}
});
export const SuggestedBrowserSkillSchema = z.object({
id: z.string(),
title: z.string(),
path: z.string(),
});
export const BrowserControlResultSchema = z.object({
success: z.boolean(),
action: BrowserControlActionSchema,
@ -123,6 +129,7 @@ export const BrowserControlResultSchema = z.object({
error: z.string().optional(),
browser: BrowserStateSchema,
page: BrowserPageSnapshotSchema.optional(),
suggestedSkills: z.array(SuggestedBrowserSkillSchema).optional(),
});
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
@ -132,3 +139,4 @@ export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>;
export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;
export type BrowserControlInput = z.infer<typeof BrowserControlInputSchema>;
export type BrowserControlResult = z.infer<typeof BrowserControlResultSchema>;
export type SuggestedBrowserSkill = z.infer<typeof SuggestedBrowserSkillSchema>;

View file

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

View file

@ -6,7 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { TrackEvent } from './track-block.js';
import { TrackEvent } from './track.js';
import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
@ -299,6 +299,28 @@ const ipcSchemas = {
}),
res: z.null(),
},
'app:openUrl': {
req: z.object({
url: z.string(),
}),
res: z.null(),
},
'app:takeMeetingNotes': {
req: z.object({
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
event: z.unknown(),
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
// in addition to triggering the take-notes flow.
openMeeting: z.boolean().optional(),
}),
res: z.null(),
},
'app:consumePendingDeepLink': {
req: z.null(),
res: z.object({
url: z.string().nullable(),
}),
},
'granola:getConfig': {
req: z.null(),
res: z.object({
@ -407,16 +429,10 @@ const ipcSchemas = {
toolkits: z.array(z.string()),
}),
},
'composio:use-composio-for-google': {
'migration:check-composio-google': {
req: z.null(),
res: z.object({
enabled: z.boolean(),
}),
},
'composio:use-composio-for-google-calendar': {
req: z.null(),
res: z.object({
enabled: z.boolean(),
shouldShow: z.boolean(),
}),
},
'composio:didConnect': {
@ -467,6 +483,16 @@ const ipcSchemas = {
req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
},
// Native dialog channels
'dialog:openDirectory': {
req: z.object({
defaultPath: z.string().optional(),
title: z.string().optional(),
}),
res: z.object({
path: z.string().nullable(),
}),
},
// Knowledge version history channels
'knowledge:history': {
req: z.object({ path: RelPath }),
@ -588,7 +614,7 @@ const ipcSchemas = {
// Track channels
'track:run': {
req: z.object({
trackId: z.string(),
id: z.string(),
filePath: z.string(),
}),
res: z.object({
@ -599,22 +625,22 @@ const ipcSchemas = {
},
'track:get': {
req: z.object({
trackId: z.string(),
id: z.string(),
filePath: z.string(),
}),
res: z.object({
success: z.boolean(),
// Fresh, authoritative YAML of the track block from disk.
// Renderer should use this for display/edit — never its Tiptap node attr.
// Fresh, authoritative YAML of the track from frontmatter.
// Renderer should use this for display/edit — never a stale cached copy.
yaml: z.string().optional(),
error: z.string().optional(),
}),
},
'track:update': {
req: z.object({
trackId: z.string(),
id: z.string(),
filePath: z.string(),
// Partial TrackBlock updates — merged into the block's YAML on disk.
// Partial Track updates — merged into the entry on disk.
// Backend is the sole writer; avoids races with scheduler/runner writes.
updates: z.record(z.string(), z.unknown()),
}),
@ -626,7 +652,7 @@ const ipcSchemas = {
},
'track:replaceYaml': {
req: z.object({
trackId: z.string(),
id: z.string(),
filePath: z.string(),
yaml: z.string(),
}),
@ -638,7 +664,7 @@ const ipcSchemas = {
},
'track:delete': {
req: z.object({
trackId: z.string(),
id: z.string(),
filePath: z.string(),
}),
res: z.object({
@ -646,6 +672,35 @@ const ipcSchemas = {
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(),
error: z.string().optional(),
}),
},
'track: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(),
})),
}),
},
// Embedded browser (WebContentsView) channels
'browser:setBounds': {
req: z.object({

View file

@ -63,6 +63,13 @@ export const ToolResultEvent = BaseRunEvent.extend({
result: z.any(),
});
export const ToolOutputStreamEvent = BaseRunEvent.extend({
type: z.literal("tool-output-stream"),
toolCallId: z.string(),
toolName: z.string(),
output: z.string(),
});
export const AskHumanRequestEvent = BaseRunEvent.extend({
type: z.literal("ask-human-request"),
toolCallId: z.string(),
@ -106,6 +113,7 @@ export const RunEvent = z.union([
MessageEvent,
ToolInvocationEvent,
ToolResultEvent,
ToolOutputStreamEvent,
AskHumanRequestEvent,
AskHumanResponseEvent,
ToolPermissionRequestEvent,

View file

@ -1,32 +1,54 @@
import z from 'zod';
export const TrackScheduleSchema = z.discriminatedUnion('type', [
// ---------------------------------------------------------------------------
// Triggers — when a track fires
// ---------------------------------------------------------------------------
//
// A track can carry zero or more triggers under the `triggers:` key.
// Each trigger is one of:
// - cron: exact time, recurring
// - window: once per day, anywhere inside a time-of-day band
// - once: one-shot at a future time
// - event: driven by incoming signals (emails, calendar events, etc.)
//
// A track can have multiple triggers — e.g. a daily cron trigger AND an event
// trigger. Omit `triggers` (or pass an empty array) for a manual-only track.
// ---------------------------------------------------------------------------
export const TriggerSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('cron').describe('Fires at exact cron times'),
expression: z.string().describe('5-field cron expression, quoted (e.g. "0 * * * *")'),
}).describe('Recurring at exact times'),
z.object({
type: z.literal('window').describe('Fires at most once per cron occurrence, only within a time-of-day window'),
cron: z.string().describe('5-field cron expression, quoted'),
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time'),
}).describe('Recurring within a time-of-day window'),
type: z.literal('window').describe('Fires once per day, anywhere inside a time-of-day band'),
startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. Also the daily cycle anchor — once the track fires after this time, it won\'t fire again until the next day.'),
endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).describe('24h HH:MM, local time. After this, the window is closed for the day.'),
}).describe('Recurring within a daily time-of-day window'),
z.object({
type: z.literal('once').describe('Fires once and never again'),
runAt: z.string().describe('ISO 8601 datetime, local time, no Z suffix (e.g. "2026-04-14T09:00:00")'),
}).describe('One-shot future run'),
]).describe('Optional schedule. Omit entirely for manual-only tracks.');
z.object({
type: z.literal('event').describe('Fires when a matching event arrives'),
matchCriteria: z.string().describe('Describe the kinds of events that should consider this track for an update (e.g. "Emails about Q3 planning"). Pass 1 routing uses this to decide candidacy; the agent does Pass 2 on the event payload.'),
}).describe('Event-driven'),
]);
export type TrackSchedule = z.infer<typeof TrackScheduleSchema>;
export type Trigger = z.infer<typeof TriggerSchema>;
export const TrackBlockSchema = z.object({
trackId: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
// ---------------------------------------------------------------------------
// Track entity
// ---------------------------------------------------------------------------
export const TrackSchema = z.object({
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe('Kebab-case identifier, unique within the note file'),
instruction: z.string().min(1).describe('What the agent should produce each run — specific, single-focus, imperative'),
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
active: z.boolean().default(true).describe('Set false to pause without deleting'),
schedule: TrackScheduleSchema.optional(),
triggers: z.array(TriggerSchema).optional().describe('When this track fires. A track can have multiple triggers — e.g. an hourly cron AND an event trigger. Omit (or use an empty array) for a manual-only track.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
icon: z.string().optional().describe('Lucide icon name for 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'),
@ -58,7 +80,7 @@ export type KnowledgeEvent = z.infer<typeof KnowledgeEventSchema>;
export const Pass1OutputSchema = z.object({
candidates: z.array(z.object({
trackId: z.string().describe('The track block identifier'),
trackId: z.string().describe('The track identifier'),
filePath: z.string().describe('The note file path the track lives in'),
})).describe('Tracks that may be relevant to this event. trackIds are only unique within a file, so always return both fields.'),
});
@ -85,5 +107,5 @@ export const TrackRunCompleteEvent = z.object({
export const TrackEvent = z.union([TrackRunStartEvent, TrackRunCompleteEvent]);
export type TrackBlock = z.infer<typeof TrackBlockSchema>;
export type Track = z.infer<typeof TrackSchema>;
export type TrackEventType = z.infer<typeof TrackEvent>;

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

@ -175,33 +175,36 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@tiptap/core':
specifier: 3.22.4
version: 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-image':
specifier: ^3.16.0
version: 3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
specifier: 3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-link':
specifier: ^3.15.3
version: 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
specifier: 3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder':
specifier: ^3.15.3
version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
specifier: 3.22.4
version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-table':
specifier: ^3.22.4
version: 3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
specifier: 3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-task-item':
specifier: ^3.15.3
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
specifier: 3.22.4
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-task-list':
specifier: ^3.15.3
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
specifier: 3.22.4
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/pm':
specifier: ^3.15.3
version: 3.15.3
specifier: 3.22.4
version: 3.22.4
'@tiptap/react':
specifier: ^3.15.3
version: 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: 3.22.4
version: 3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tiptap/starter-kit':
specifier: ^3.15.3
version: 3.15.3
specifier: 3.22.4
version: 3.22.4
'@x/preload':
specifier: workspace:*
version: link:../preload
@ -264,7 +267,7 @@ importers:
version: 4.1.18
tiptap-markdown:
specifier: ^0.9.0
version: 0.9.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
version: 0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
tokenlens:
specifier: ^1.3.1
version: 1.3.1
@ -2588,9 +2591,6 @@ packages:
react-redux:
optional: true
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@ -3048,132 +3048,132 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tiptap/core@3.15.3':
resolution: {integrity: sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==}
'@tiptap/core@3.22.4':
resolution: {integrity: sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==}
peerDependencies:
'@tiptap/pm': ^3.15.3
'@tiptap/pm': 3.22.4
'@tiptap/extension-blockquote@3.15.3':
resolution: {integrity: sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==}
'@tiptap/extension-blockquote@3.22.5':
resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-bold@3.15.3':
resolution: {integrity: sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==}
'@tiptap/extension-bold@3.22.5':
resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-bubble-menu@3.15.3':
resolution: {integrity: sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==}
'@tiptap/extension-bubble-menu@3.22.5':
resolution: {integrity: sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-bullet-list@3.15.3':
resolution: {integrity: sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==}
'@tiptap/extension-bullet-list@3.22.5':
resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-code-block@3.15.3':
resolution: {integrity: sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==}
'@tiptap/extension-code-block@3.22.5':
resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-code@3.15.3':
resolution: {integrity: sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==}
'@tiptap/extension-code@3.22.5':
resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-document@3.15.3':
resolution: {integrity: sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==}
'@tiptap/extension-document@3.22.5':
resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-dropcursor@3.15.3':
resolution: {integrity: sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==}
'@tiptap/extension-dropcursor@3.22.5':
resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==}
peerDependencies:
'@tiptap/extensions': ^3.15.3
'@tiptap/extensions': 3.22.5
'@tiptap/extension-floating-menu@3.15.3':
resolution: {integrity: sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==}
'@tiptap/extension-floating-menu@3.22.5':
resolution: {integrity: sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-gapcursor@3.15.3':
resolution: {integrity: sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==}
'@tiptap/extension-gapcursor@3.22.5':
resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==}
peerDependencies:
'@tiptap/extensions': ^3.15.3
'@tiptap/extensions': 3.22.5
'@tiptap/extension-hard-break@3.15.3':
resolution: {integrity: sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==}
'@tiptap/extension-hard-break@3.22.5':
resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-heading@3.15.3':
resolution: {integrity: sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==}
'@tiptap/extension-heading@3.22.5':
resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-horizontal-rule@3.15.3':
resolution: {integrity: sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg==}
'@tiptap/extension-horizontal-rule@3.22.5':
resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-image@3.16.0':
resolution: {integrity: sha512-mTjt4kdyVtY/2dJcfxAgBae/dkH+r6GwARl7NlPtnI3EzpELFR65FNuOQyTxFXP3yfV9uMtPpq6Wevk8aLTsxQ==}
'@tiptap/extension-image@3.22.4':
resolution: {integrity: sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA==}
peerDependencies:
'@tiptap/core': ^3.16.0
'@tiptap/core': 3.22.4
'@tiptap/extension-italic@3.15.3':
resolution: {integrity: sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==}
'@tiptap/extension-italic@3.22.5':
resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-link@3.15.3':
resolution: {integrity: sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==}
'@tiptap/extension-link@3.22.4':
resolution: {integrity: sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-list-item@3.15.3':
resolution: {integrity: sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==}
'@tiptap/extension-list-item@3.22.5':
resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-list-keymap@3.15.3':
resolution: {integrity: sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==}
'@tiptap/extension-list-keymap@3.22.5':
resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-list@3.15.3':
resolution: {integrity: sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==}
'@tiptap/extension-list@3.22.5':
resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/extension-ordered-list@3.15.3':
resolution: {integrity: sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==}
'@tiptap/extension-ordered-list@3.22.5':
resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-paragraph@3.15.3':
resolution: {integrity: sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==}
'@tiptap/extension-paragraph@3.22.5':
resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-placeholder@3.15.3':
resolution: {integrity: sha512-XcHHnojT186hKIoOgcPBesXk89+caNGVUdMtc171Vcr/5s0dpnr4q5LfE+YRC+S85CpCxCRRnh84Ou+XRtOqrw==}
'@tiptap/extension-placeholder@3.22.4':
resolution: {integrity: sha512-Z3wtWL+KufwkC7CkJge5enAxx4q8C3oOYixme02snY9zfjX3V/1pjAmEfP4wxScgM5GIuTEJ83B9Yz3wRzPA6Q==}
peerDependencies:
'@tiptap/extensions': ^3.15.3
'@tiptap/extensions': 3.22.4
'@tiptap/extension-strike@3.15.3':
resolution: {integrity: sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==}
'@tiptap/extension-strike@3.22.5':
resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-table@3.22.4':
resolution: {integrity: sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==}
@ -3181,47 +3181,47 @@ packages:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-task-item@3.15.3':
resolution: {integrity: sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==}
'@tiptap/extension-task-item@3.22.4':
resolution: {integrity: sha512-PhoiOMatdRXJU1HJz0fMP5N7wv0eYAz/Id/gphby/gdxjYQaMhJ7vQiLTR28EkVBkdntTUb1bwZ4XQn9thFtpw==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-task-list@3.15.3':
resolution: {integrity: sha512-nh8iBk1LHVIoqxphLoqZlLAN9fF2i9ZeK+2TjGSS35lfh7sYzRoSjNW0E81Uy48YuCzM1NQYghYR5Qfc7vm4jA==}
'@tiptap/extension-task-list@3.22.4':
resolution: {integrity: sha512-5M3XiZMZJ2mwWSUKPG4mb90g86rpgYw7yf8lBEkaCgke9XxsLg8mXmYRpCc6n/v1TQXryB+WDKuenCzJTx/4/A==}
peerDependencies:
'@tiptap/extension-list': ^3.15.3
'@tiptap/extension-list': 3.22.4
'@tiptap/extension-text@3.15.3':
resolution: {integrity: sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==}
'@tiptap/extension-text@3.22.5':
resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extension-underline@3.15.3':
resolution: {integrity: sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==}
'@tiptap/extension-underline@3.22.5':
resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/extensions@3.15.3':
resolution: {integrity: sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==}
'@tiptap/extensions@3.22.5':
resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.5
'@tiptap/pm': 3.22.5
'@tiptap/pm@3.15.3':
resolution: {integrity: sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==}
'@tiptap/pm@3.22.4':
resolution: {integrity: sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==}
'@tiptap/react@3.15.3':
resolution: {integrity: sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==}
'@tiptap/react@3.22.4':
resolution: {integrity: sha512-XIQZPwLakR1t8+Q1UeCpr+kUHDWxpJzGy9r2xUi3mpPd6Wh8dtNltScBkUlCcr0sqc6J1GF6Is02JJVQGmCZMA==}
peerDependencies:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.15.3':
resolution: {integrity: sha512-ia+eQr9Mt1ln2UO+kK4kFTJOrZK4GhvZXFjpCCYuHtco3rhr2fZAIxEEY4cl/vo5VO5WWyPqxhkFeLcoWmNjSw==}
'@tiptap/starter-kit@3.22.4':
resolution: {integrity: sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==}
'@tokenlens/core@1.3.0':
resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==}
@ -4140,9 +4140,6 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cron-parser@5.5.0:
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
engines: {node: '>=18'}
@ -6531,9 +6528,6 @@ packages:
prosemirror-changeset@2.3.1:
resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
@ -6546,24 +6540,15 @@ packages:
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.2:
resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==}
prosemirror-menu@1.2.5:
resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
@ -6573,13 +6558,6 @@ packages:
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.10.5:
resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==}
@ -10546,8 +10524,6 @@ snapshots:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
'@remirror/core-constants@3.0.0': {}
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rollup/rollup-android-arm-eabi@4.54.0':
@ -11069,164 +11045,158 @@ snapshots:
tailwindcss: 4.1.18
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
'@tiptap/core@3.15.3(@tiptap/pm@3.15.3)':
'@tiptap/core@3.22.4(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/pm': 3.15.3
'@tiptap/pm': 3.22.4
'@tiptap/extension-blockquote@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-bold@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-bubble-menu@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-bubble-menu@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
optional: true
'@tiptap/extension-bullet-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-code-block@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-code@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-code@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-document@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-document@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-dropcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-floating-menu@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-floating-menu@3.22.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
optional: true
'@tiptap/extension-gapcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-hard-break@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-heading@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-horizontal-rule@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-image@3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-image@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-italic@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-link@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-link@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list-keymap@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-ordered-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-paragraph@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-strike@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-table@3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extension-table@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-task-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-text@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-underline@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
'@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
'@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/pm@3.15.3':
'@tiptap/pm@3.22.4':
dependencies:
prosemirror-changeset: 2.3.1
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.0
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.2
prosemirror-menu: 1.2.5
prosemirror-model: 1.25.4
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)
prosemirror-transform: 1.10.5
prosemirror-view: 1.41.4
'@tiptap/react@3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@tiptap/react@3.22.4(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@types/use-sync-external-store': 0.0.6
@ -11235,37 +11205,37 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-floating-menu': 3.15.3(@floating-ui/dom@1.7.4)(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.15.3':
'@tiptap/starter-kit@3.22.4':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/extension-blockquote': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-bold': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-bullet-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-code': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-code-block': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-document': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-dropcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-gapcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-hard-break': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-heading': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-horizontal-rule': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-italic': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-link': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/extension-list-item': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-list-keymap': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-ordered-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
'@tiptap/extension-paragraph': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-strike': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-text': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-underline': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extensions': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-link': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tokenlens/core@1.3.0': {}
@ -12311,8 +12281,6 @@ snapshots:
crc-32@1.2.2: {}
crelt@1.0.6: {}
cron-parser@5.5.0:
dependencies:
luxon: 3.7.2
@ -15253,10 +15221,6 @@ snapshots:
dependencies:
prosemirror-transform: 1.10.5
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
@ -15283,11 +15247,6 @@ snapshots:
prosemirror-view: 1.41.4
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.10.5
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
@ -15299,21 +15258,10 @@ snapshots:
markdown-it: 14.1.0
prosemirror-model: 1.25.4
prosemirror-menu@1.2.5:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.5.0
prosemirror-state: 1.4.4
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
@ -15334,14 +15282,6 @@ snapshots:
prosemirror-transform: 1.10.5
prosemirror-view: 1.41.4
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.4
prosemirror-transform@1.10.5:
dependencies:
prosemirror-model: 1.25.4
@ -16221,9 +16161,9 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tiptap-markdown@0.9.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3)):
tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)):
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1