Deprecate generated Today.md live note

This commit is contained in:
Ramnique Singh 2026-05-19 15:13:05 +05:30
parent fe5e67f810
commit 95c313de89
6 changed files with 111 additions and 109 deletions

View file

@ -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). `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`) ### 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 missing → mark processed and do nothing.
- File at-or-above canonical → no-op. - File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body.
- 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 body-from-scratch (live notes regenerate their own body). - Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again.
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.
--- ---
@ -393,7 +391,7 @@ Conventions:
| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` | | 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 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` | | 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` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` | | Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |

View file

@ -42,8 +42,8 @@ function isSamePath(a: string, b: string | undefined): boolean {
* - Ticks every minute so callers using `formatRelativeTime` get a fresh label * - Ticks every minute so callers using `formatRelativeTime` get a fresh label
* without the underlying data changing. * without the underlying data changing.
* *
* `notePath` may be either knowledge-relative (`Today.md`) or workspace-rooted * `notePath` may be either knowledge-relative (`Digest.md`) or workspace-rooted
* (`knowledge/Today.md`); the hook normalises internally. * (`knowledge/Digest.md`); the hook normalises internally.
*/ */
export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult { export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult {
const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null) const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null)

View file

@ -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. - \`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. - \`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: How to write it use plain conditional language inside the objective:

View file

@ -49,9 +49,10 @@ function ensureDefaultConfigs() {
ensureDirs(); ensureDirs();
ensureDefaultConfigs(); ensureDefaultConfigs();
// Ensure default knowledge files exist // One-time deprecation for the old Today.md live dashboard. New installs no
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => { // longer get a generated Today.md; existing files are paused in place.
console.error('[DailyNote] Failed to ensure daily note:', err); 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) // Initialize version history repo (async, fire-and-forget on startup)

View file

@ -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 = '<!-- rowboat-today-md-deprecated -->';
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<typeof StateSchema>;
const TodayNoteFrontmatterSchema = z.object({
live: LiveNoteSchema.optional(),
}).passthrough();
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function loadState(): Promise<State> {
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<void> {
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<void> {
await saveState({ processed_at: new Date().toISOString() });
}
function disableLiveBlock(frontmatter: Record<string, unknown>): Record<string, unknown> {
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<void> {
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();
}

View file

@ -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.<ISO-stamp> 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<typeof LiveNoteSchema> = {
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/<source>/<yesterday>/\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."
5. **Priorities** a ranked markdown list of actionable items the user should focus on today. Sources: yesterday's meeting action items (\`knowledge/Meetings/<source>/<yesterday>/\`), open follow-ups across \`knowledge/\` (\`workspace-grep\` for "- [ ]"), the **What you missed** section above. Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline. With an event payload (gmail or calendar), only re-emit the full list if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."
Treat the note as a coherent artifact. Make small, incremental edits one section at a time rather than rewriting the whole body each run.`,
active: true,
triggers: {
// Three windows give the user a fresh dashboard morning, midday, and
// post-lunch even with no calendar/email events landing in between.
windows: [
{ startTime: '08:00', endTime: '12:00' },
{ startTime: '12:00', endTime: '15:00' },
{ startTime: '15:00', endTime: '18:00' },
],
// Event-driven updates handle in-day shifts (new email threads worth
// attention, calendar reshuffles, urgent escalations).
eventMatchCriteria:
`Email or calendar events that may shift today's dashboard: new or updated email threads needing the user's attention, urgent reply requests, deadline-bearing items, escalations from people the user cares about, calendar additions/cancellations/reschedules affecting today, or anything that changes the user's near-term priorities. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
},
};
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}`,
);
}