import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import { NOTE_ENTRY_TYPE } from "./constants.ts"; export interface NoteData { action: "write" | "delete"; key: string; content?: string; } export interface NoteRecord { key: string; content: string; entryId: string; updatedAt: string; epochIndexAtWrite: number | null; } export interface NoteWriteValidation { ok: boolean; error?: string; key?: string; content?: string; } /** * Validate a note write. Keys are short identifiers; content is bounded so a * runaway model cannot turn notes into a second transcript. */ export function validateNoteWrite(key: unknown, content: unknown, maxChars: number): NoteWriteValidation { if (typeof key !== "string" || key.trim().length === 0) { return { ok: false, error: "context_notes write requires a non-empty 'key' (e.g. \"active-task\")." }; } const normalizedKey = key.trim(); if (!/^[\w][\w.-]{0,63}$/.test(normalizedKey)) { return { ok: false, error: "Invalid note key. Use 1-64 characters: letters, digits, underscore, dot or dash (e.g. \"user-constraints\").", }; } if (typeof content !== "string" || content.trim().length === 0) { return { ok: false, error: "context_notes write requires non-empty string 'content'." }; } if (content.length > maxChars) { return { ok: false, error: `Note content is ${content.length} chars; the limit is ${maxChars}. Keep notes small: decisions, constraints, blockers - not copies of the workspace.`, }; } return { ok: true, key: normalizedKey, content }; } /** * Replay note events from a branch (root -> leaf). Branch-scoped by * construction: /tree or fork to another branch replays only that branch's * facts, never another branch's future. */ export function replayNotes(branch: SessionEntry[]): Map { const notes = new Map(); for (const entry of branch) { if (entry.type !== "custom") continue; if (entry.customType !== NOTE_ENTRY_TYPE) continue; const data = entry.data as NoteData | undefined; if (!data || (data.action !== "write" && data.action !== "delete")) continue; if (typeof data.key !== "string") continue; if (data.action === "delete") { notes.delete(data.key); continue; } notes.set(data.key, { key: data.key, content: typeof data.content === "string" ? data.content : "", entryId: entry.id, updatedAt: entry.timestamp, epochIndexAtWrite: null, }); } return notes; } export function listNotes(notes: Map): Array { return [...notes.values()].sort((a, b) => a.key.localeCompare(b.key)); } export function notesIndexLine(notes: Map): string[] { return listNotes(notes).map((note) => `- ${note.key} (${note.content.length} chars)`); }