diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index 2fc43786..fe31d019 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -175,7 +175,7 @@ Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | `buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context). -This lets the user-authored objective branch on trigger kind when warranted (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". +This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". ### Run flow (`runLiveNoteAgent`) @@ -254,17 +254,15 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know --- -## Daily-Note Template & Migrations +## Default Note Policy -`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals. +Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block. -**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()`: +**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start: -- File missing → fresh write at canonical version. -- File at-or-above canonical → no-op. -- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body). - -The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced. +- File missing → mark processed and do nothing. +- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body. +- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again. --- @@ -393,7 +391,7 @@ Conventions: | Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` | | Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` | | Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` | -| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | +| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_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/live-note/skill.ts` | diff --git a/apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts b/apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts index 013443d3..6d43246c 100644 --- a/apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts +++ b/apps/x/apps/renderer/src/hooks/use-live-note-for-path.ts @@ -42,8 +42,8 @@ function isSamePath(a: string, b: string | undefined): boolean { * - Ticks every minute so callers using `formatRelativeTime` get a fresh label * without the underlying data changing. * - * `notePath` may be either knowledge-relative (`Today.md`) or workspace-rooted - * (`knowledge/Today.md`); the hook normalises internally. + * `notePath` may be either knowledge-relative (`Digest.md`) or workspace-rooted + * (`knowledge/Digest.md`); the hook normalises internally. */ export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult { const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null) diff --git a/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts b/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts index 715c049d..24c37184 100644 --- a/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts @@ -314,7 +314,7 @@ The agent always receives a \`**Trigger:**\` line in its run message telling it - \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh. - \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive. -**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window — pull a full snapshot from local data) and a *reactive* update (event — integrate one new signal). The flagship case is the **Today.md emails section**: on a window run it scans \`gmail_sync/\` for everything worth attention; on an event run with an incoming email payload it integrates that one thread into the existing digest without re-listing previously-seen threads. Same objective, two branches. +**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window — pull a full snapshot from local data) and a *reactive* update (event — integrate one new signal). For example, an email digest can scan \`gmail_sync/\` for everything worth attention on a window run, then integrate one incoming thread on an event run without re-listing previously-seen threads. Same objective, two branches. How to write it — use plain conditional language inside the objective: diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index 3d320172..7f230514 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -49,9 +49,10 @@ function ensureDefaultConfigs() { ensureDirs(); ensureDefaultConfigs(); -// Ensure default knowledge files exist -import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => { - console.error('[DailyNote] Failed to ensure daily note:', err); +// One-time deprecation for the old Today.md live dashboard. New installs no +// longer get a generated Today.md; existing files are paused in place. +import('../knowledge/deprecate_today_note.js').then(m => m.deprecateTodayNote()).catch(err => { + console.error('[TodayNoteDeprecation] Failed to deprecate Today.md:', err); }); // Initialize version history repo (async, fire-and-forget on startup) diff --git a/apps/x/packages/core/src/knowledge/deprecate_today_note.ts b/apps/x/packages/core/src/knowledge/deprecate_today_note.ts new file mode 100644 index 00000000..00a19120 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/deprecate_today_note.ts @@ -0,0 +1,96 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { z } from 'zod'; +import { LiveNoteSchema } from '@x/shared/dist/live-note.js'; +import { WorkDir } from '../config/config.js'; +import { splitFrontmatter, joinFrontmatter } from '../application/lib/parse-frontmatter.js'; + +const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); +const TODAY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md'); +const STATE_FILE = path.join(WorkDir, 'config', 'today-note-deprecation.json'); +const NOTICE_MARKER = ''; +const DEPRECATION_NOTICE = `${NOTICE_MARKER} +> Rowboat's Today.md live dashboard is paused for now while we work on a better experience. You can keep using this note as a regular markdown file. If you want Rowboat to keep updating it automatically, re-enable the live note settings; automatic updates may use credits. + +`; + +const StateSchema = z.object({ + processed_at: z.string().min(1).optional(), +}); +type State = z.infer; + +const TodayNoteFrontmatterSchema = z.object({ + live: LiveNoteSchema.optional(), +}).passthrough(); + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function loadState(): Promise { + try { + if (!await pathExists(STATE_FILE)) return {}; + const raw = await fs.readFile(STATE_FILE, 'utf-8'); + return StateSchema.parse(JSON.parse(raw)); + } catch (error) { + console.warn('[TodayNoteDeprecation] Failed to load state:', error); + return {}; + } +} + +async function saveState(state: State): Promise { + const dir = path.dirname(STATE_FILE); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8'); +} + +async function markProcessed(): Promise { + await saveState({ processed_at: new Date().toISOString() }); +} + +function disableLiveBlock(frontmatter: Record): Record { + const parsed = TodayNoteFrontmatterSchema.safeParse(frontmatter); + if (!parsed.success || !parsed.data.live) { + return frontmatter; + } + + return { + ...frontmatter, + live: { + ...parsed.data.live, + active: false, + }, + }; +} + +function prependNotice(body: string): string { + if (body.includes(NOTICE_MARKER)) return body; + return `${DEPRECATION_NOTICE}${body}`; +} + +export async function deprecateTodayNote(): Promise { + const state = await loadState(); + if (state.processed_at) return; + + if (!await pathExists(TODAY_NOTE_PATH)) { + await markProcessed(); + return; + } + + const content = await fs.readFile(TODAY_NOTE_PATH, 'utf-8'); + const { frontmatter, body } = splitFrontmatter(content); + const nextFrontmatter = disableLiveBlock(frontmatter); + const nextBody = prependNotice(body); + + if (nextFrontmatter !== frontmatter || nextBody !== body) { + await fs.writeFile(TODAY_NOTE_PATH, joinFrontmatter(nextFrontmatter, nextBody), 'utf-8'); + console.log('[TodayNoteDeprecation] Deprecated Today.md live dashboard'); + } + + await markProcessed(); +} diff --git a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts deleted file mode 100644 index 341ddfd9..00000000 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ /dev/null @@ -1,93 +0,0 @@ -import path from 'path'; -import fs from 'fs'; -import { stringify as stringifyYaml } from 'yaml'; -import { LiveNoteSchema } from '@x/shared/dist/live-note.js'; -import { WorkDir } from '../config/config.js'; -import { splitFrontmatter } from '../application/lib/parse-frontmatter.js'; -import z from 'zod'; - -const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); -const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md'); - -// Bump this whenever the canonical Today.md template changes (objective, -// triggers, default body, etc.). On app start, ensureDailyNote() compares the -// on-disk `templateVersion` against this constant — if older or missing, the -// existing file is renamed to Today.md.bkp. and replaced with the -// new template. v2 is the live-note rewrite (single objective, no `track:`). -const CANONICAL_DAILY_NOTE_VERSION = 2; - -const TODAY_LIVE_NOTE: z.infer = { - objective: -`Keep Today.md current as a living dashboard for the day. Maintain these H2 sections in this order: - -1. **Overview** — 2-3 prose sentences greeting the user and reading the day (warm, confident tone — use today's calendar density from \`calendar_sync/\` and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Keep the image **wide / low-height**. Skip refreshing this section if its content is still suitable and less than 24h old. - -2. **Calendar** — today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\` → \`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`. - -3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails." - -4. **What you missed** — a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings///\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag." - -5. **Priorities** — a ranked markdown list of actionable items the user should focus on today. Sources: yesterday's meeting action items (\`knowledge/Meetings///\`), open follow-ups across \`knowledge/\` (\`workspace-grep\` for "- [ ]"), the **What you missed** section above. Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline. With an event payload (gmail or calendar), only re-emit the full list if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). If nothing pressing: "No pressing tasks today — good day to make progress on bigger items." - -Treat the note as a coherent artifact. Make small, incremental edits — one section at a time — rather than rewriting the whole body each run.`, - active: true, - triggers: { - // Three windows give the user a fresh dashboard morning, midday, and - // post-lunch even with no calendar/email events landing in between. - windows: [ - { startTime: '08:00', endTime: '12:00' }, - { startTime: '12:00', endTime: '15:00' }, - { startTime: '15:00', endTime: '18:00' }, - ], - // Event-driven updates handle in-day shifts (new email threads worth - // attention, calendar reshuffles, urgent escalations). - eventMatchCriteria: -`Email or calendar events that may shift today's dashboard: new or updated email threads needing the user's attention, urgent reply requests, deadline-bearing items, escalations from people the user cares about, calendar additions/cancellations/reschedules affecting today, or anything that changes the user's near-term priorities. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`, - }, -}; - -function buildDailyNoteContent(body: string = '# Today\n'): string { - const fm = stringifyYaml( - { templateVersion: CANONICAL_DAILY_NOTE_VERSION, live: TODAY_LIVE_NOTE }, - { lineWidth: 0, blockQuote: 'literal' }, - ).trimEnd(); - return `---\n${fm}\n---\n${body}`; -} - -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 { - // Fresh install — no existing file. - if (!fs.existsSync(DAILY_NOTE_PATH)) { - fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8'); - console.log(`[DailyNote] Created Today.md (v${CANONICAL_DAILY_NOTE_VERSION})`); - return; - } - - // Up-to-date — nothing to do. - const currentVersion = readCurrentTemplateVersion(); - if (currentVersion >= CANONICAL_DAILY_NOTE_VERSION) return; - - // Migrate aggressively: rename existing → backup, write a fresh canonical - // template (no body carried over). Today.md is a flagship demo whose - // content is meant to be regenerated by the live-note agent anyway — - // preserving the old body just leaves orphan sections behind on - // restructure. The .bkp file is the recovery path; its name doesn't end - // in `.md`, so the scheduler and event router naturally skip it. Pre-v2 - // notes (with the old `track:` array) are caught by this same path. - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`; - fs.renameSync(DAILY_NOTE_PATH, backupPath); - fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8'); - console.log( - `[DailyNote] Migrated v${currentVersion} → v${CANONICAL_DAILY_NOTE_VERSION}; ` + - `previous version saved to ${backupPath}`, - ); -}