// Notes as an AGENT reads them: markdown, every anchor resolved to something a // reader with no page open can act on, and the file to edit named first. // // `umtool notes ` prints this; GET /api/notes/context serves the same // text, so "Copy agent brief" on a page and an agent's CLI hand over the same // words. An anchor is resolved against what is on disk NOW: // // text the section's title and the sentence holding the quote, found // again with the same re-anchoring the page uses (ORPHANED when the // quote is gone -- the note still prints, with its quote) // cite the citation's quote, speaker, date and `/@start-end` // moment the time, and the entry/source it resolved to when it was written // entry the timeline entry's title and quote // take the take's label and summary, and the operator's verdict on it // edit what changed, from → to, to port into the generator's inputs // // A video project's digest also lists every take with its verdict and note // (takes/verdicts.json): the agent that rendered the takes reads them here. import { readFile } from "node:fs/promises"; import path from "node:path"; import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs"; import { listTakes, readVerdicts } from "../report/takes.mjs"; import { locateQuote, sentenceAround } from "./anchor.mjs"; const readJson = (file) => readFile(/* turbopackIgnore: true */ file, "utf8").then(JSON.parse, () => null); /** Markdown to the plain text a reader sees: links to their labels, emphasis and code marks dropped. */ export function plainText(md) { return String(md ?? "") .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/^#{1,6}\s+/gm, "") .replace(/^\s*>\s?/gm, "") .replace(/^\s*[-*+]\s+/gm, "") .replace(/(\*\*|__|\*|_|`)/g, ""); } /** * The plain text of one block of a report, as the page renders it: a section's * title, body and claims; or the title/subtitle/summary/method. */ export function blockText(report, block) { if (!report) return { title: block, text: "" }; if (["title", "subtitle", "summary", "method"].includes(block)) { return { title: block, text: plainText(report[block] ?? "") }; } const s = (report.sections ?? []).find((x) => x.id === block); if (!s) return null; const parts = [s.title, plainText(s.body ?? "")]; for (const c of s.claims ?? []) parts.push(c.title ?? "", plainText(c.text), plainText(c.findings ?? "")); return { title: s.title, text: parts.filter(Boolean).join("\n") }; } const clip = (s, n = 300) => { const t = String(s ?? "").replace(/\s+/g, " ").trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; }; const quoteLine = (s) => `> ${clip(s, 400)}`; const hms = (t) => { const s = Math.max(0, Math.round(Number(t) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = String(s % 60).padStart(2, "0"); return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`; }; /** One anchor, resolved, as markdown lines. */ function anchorLines(a, ctx) { const { report, manifest, takes, verdicts } = ctx; switch (a.kind) { case "whole": return [ctx.kind === "article" ? "**On:** the whole article" : "**On:** the whole project"]; case "section": { const b = blockText(report, a.section); return [`**On:** section “${b?.title ?? a.section}”${b ? "" : " (section no longer exists)"}`]; } case "text": { const b = blockText(report, a.section); if (!b) return [`**On:** text in section \`${a.section}\` — ORPHANED (section no longer exists)`, quoteLine(a.quote)]; const hit = locateQuote(b.text, a); if (!hit.found) return [`**On:** text in “${b.title}” — ORPHANED (quote no longer in the section)`, quoteLine(a.quote)]; const exact = b.text.slice(hit.start, hit.end); return [ `**On:** “${clip(exact, 200)}” in “${b.title}”${hit.how === "exact" ? "" : ` (found ${hit.how})`}`, quoteLine(sentenceAround(b.text, hit.start, hit.end)), ]; } case "cite": { const c = report?.citations?.[a.cite]; if (!c) return [`**On:** citation \`${a.cite}\` (no longer in the report)`]; const who = [c.speaker, c.date].filter(Boolean).join(", "); const where = c.kind === "video" || c.kind === "audio" ? `${c.channel}/${c.id}@${c.start}-${c.end}` : c.kind === "post" ? `post ${c.channel}/${c.id}` : c.kind === "page" ? c.url : `source ${c.source}`; return [`**On:** citation \`${a.cite}\`${c.label ? ` “${c.label}”` : ""} — ${where}${who ? ` (${who})` : ""}`, quoteLine(c.quote)]; } case "moment": { const r = a.resolved ?? {}; const take = a.take ? ` of take \`${a.take}\`` : ""; const lines = [`**On:** ${hms(a.t)} in \`${a.file}\`${take}${r.approx ? " (approximate)" : ""}`]; const entry = a.entry ?? r.entry; if (entry || r.title) lines.push(`entry \`${entry ?? "?"}\`${r.title ? ` “${clip(r.title, 160)}”` : ""}`); if (r.quote) lines.push(quoteLine(r.quote)); if (r.channel && r.video) lines.push(`source ${r.channel}/${r.video}${r.sourceT !== undefined ? ` @ ${hms(r.sourceT)}` : ""}${r.url ? ` — ${r.url}` : ""}`); return lines; } case "entry": { const e = (manifest?.timeline ?? []).find((x) => x.id === a.entry); if (!e) return [`**On:** timeline entry \`${a.entry}\` (no longer in the manifest)`]; const title = e.title ?? e.onscreen?.title; const lines = [`**On:** timeline entry \`${a.entry}\` (${e.type ?? "entry"})${title ? ` “${clip(title, 160)}”` : ""}`]; if (e.quote) lines.push(quoteLine(e.quote)); if (e.type === "clip" && e.channel && e.video) lines.push(`source ${e.channel}/${e.video}@${e.start}-${e.end}`); return lines; } case "take": { const t = takes?.find((x) => x.id === a.take); const v = verdicts?.[a.take]; const lines = [`**On:** take \`${a.take}\`${t ? ` “${t.label}” (${t.group})` : " (no longer in takes/)"}${v?.verdict ? ` — verdict: ${v.verdict}` : ""}`]; if (t?.summary) lines.push(`summary: ${clip(t.summary, 300)}`); return lines; } case "edit": return [ `**On:** an edit made in umtool to a GENERATED manifest — port it into the generator's inputs`, `\`${a.entry ? `${a.entry}.` : ""}${a.field}\`: ${clip(JSON.stringify(a.from), 300)} → ${clip(JSON.stringify(a.to), 300)}`, ]; default: return [`**On:** ${JSON.stringify(a)}`]; } } /** "Edit X; Y is regenerated by Z" -- the line an agent most needs. */ export function sourceLine(kind, source) { if (!source) return kind === "article" ? "**Source:** unknown — find the draft before editing report.json, which a generator may overwrite." : "**Source:** the manifest."; if (kind === "article") { if (source.draft && source.generator) return `**Source:** edit \`${source.draft}\`; \`report.json\` is regenerated by \`${source.generator}\`.`; if (source.draft) return `**Source:** edit \`${source.draft}\`.`; if (source.generator) return `**Source:** \`report.json\` is written by \`${source.generator}\`; edit its inputs.`; } else { if (source.generator) return `**Source:** \`${source.manifest}\` is regenerated by \`${source.generator}\`; edit its inputs (BEATS, drafts), not the manifest.`; if (source.manifest) return `**Source:** edit \`${source.manifest}\`.`; } return `**Source:** ${source.how ?? "unknown"}`; } const STATUS = { open: (n) => n.status === "open", resolved: (n) => n.status !== "open", all: () => true }; /** * The digest of one notes file. * * @param {{ kind: "article" | "video-project", id: string, file: string, doc: any, error?: string }} entry * @param {{ status?: "open" | "resolved" | "all", sitesDir?: string, reportsRoot?: string, projectDir?: string }} [opts] */ export async function digest(entry, { status = "open", sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, projectDir } = {}) { const lines = []; const doc = entry.doc; const ctx = { kind: entry.kind, report: null, manifest: null, takes: null, verdicts: null }; let heading; if (entry.kind === "article") { const [site, report] = entry.id.split("/"); ctx.report = await readJson(path.join(/* turbopackIgnore: true */ sitesDir, site, "reports", report, "report.json")); heading = `# Notes on ${entry.id}${ctx.report?.title ? ` — ${ctx.report.title}` : ""}`; } else { const dir = projectDir ?? path.dirname(/* turbopackIgnore: true */ entry.file); ctx.manifest = await readJson(path.join(/* turbopackIgnore: true */ dir, "video.manifest.json")); const t = await listTakes(dir); ctx.takes = t.takes; ctx.verdicts = await readVerdicts(dir); heading = `# Notes on ${entry.id}${ctx.manifest?.title ? ` — ${ctx.manifest.title}` : ""}`; } lines.push(heading, ""); lines.push(`file: \`${entry.file}\``); if (entry.error) { lines.push("", `**notes.json does not parse:** ${entry.error}. Nothing here may write it until it is fixed by hand.`); return lines.join("\n"); } lines.push(sourceLine(entry.kind, doc?.source)); if (doc?.source?.how) lines.push(`(${doc.source.how})`); const all = doc?.notes ?? []; const shown = all.filter(STATUS[status] ?? STATUS.open); const open = all.filter((n) => n.status === "open").length; lines.push("", `${open} open, ${all.length - open} closed${status === "all" ? "" : `; showing ${status}`}.`); for (const n of shown) { lines.push("", `## ${n.id} — ${n.status}${n.status !== "open" && n.resolvedBy ? ` by ${n.resolvedBy}` : ""} (${n.author}, ${n.at.slice(0, 16).replace("T", " ")})`); lines.push(...anchorLines(n.anchor, ctx)); lines.push("", n.text); for (const r of n.replies) lines.push("", `- **${r.author}** (${r.at.slice(0, 16).replace("T", " ")}): ${r.text.replace(/\n/g, "\n ")}`); } if (entry.kind === "video-project" && ctx.takes?.length) { lines.push("", "## Takes (takes/verdicts.json)"); for (const t of ctx.takes) { const v = ctx.verdicts?.[t.id]; lines.push(`- \`${t.id}\` “${t.label}” (${t.group}, ${t.kind}): ${v?.verdict ?? "no verdict"}${v?.note ? ` — ${clip(v.note, 400)}` : ""}`); } } if (shown.length) { lines.push("", "Act on a note by editing the SOURCE above and regenerating, then:"); lines.push(`\`umtool notes reply "what you changed" --resolve\` (or \`umtool notes reply "question"\` to ask).`); } return lines.join("\n"); }