Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 056cfc2fff24cbf93a81d2962bf1aaf333771b4e
parent 5a717dd1901e8277854225d48f61c207305890e7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:31:47 -0400

umtool S7: `umtool notes` CLI, agent digest, /api/notes/context

The agent's side of operator notes: list, digest (anchors resolved against
disk, the source file named first, take verdicts for a video project), reply
/ resolve / wontfix / reopen / source, every write stamped agent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Aumtool/app/api/notes/context/route.ts | 32++++++++++++++++++++++++++++++++
Mumtool/bin/umtool.mjs | 7+++++++
Aumtool/lib/annotations/cli.mjs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/cli.test.mjs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/annotations/digest.mjs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 489 insertions(+), 0 deletions(-)

diff --git a/umtool/app/api/notes/context/route.ts b/umtool/app/api/notes/context/route.ts @@ -0,0 +1,32 @@ +import { errorResponse, targetFrom } from "@/lib/annotations/server"; +import { digest } from "@/lib/annotations/digest.mjs"; +import { readNotes } from "@/lib/annotations/store.mjs"; + +export const dynamic = "force-dynamic"; + +// The agent brief: the same markdown `umtool notes <target>` prints, as +// text/plain, so "Copy agent brief" and `curl` hand over the words an agent +// would read on the command line. +// +// /api/notes/context?article=<site>/<report>[&status=open|resolved|all] +// /api/notes/context?project=<id>[&status=…] +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const target = await targetFrom({ article: url.searchParams.get("article"), project: url.searchParams.get("project") }); + const s = url.searchParams.get("status"); + const status = s === "resolved" || s === "all" ? s : "open"; + const read = await readNotes(target.file); + const cli = `umtool notes ${target.id}`; + const body = + !read.doc && !read.error + ? `No notes on ${target.id}.\n` + : `${await digest( + { kind: target.kind as "article" | "video-project", id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) }, + { status, projectDir: "dir" in target ? target.dir : undefined }, + )}\n\nRead these again with \`${cli}\` (from the repo checkout).\n`; + return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -36,6 +36,9 @@ // umtool diff <project> <snapshot> what changed since that snapshot // umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V] // umtool check-sources [<project>…] prints the re-check chain +// umtool notes [<site>/<report> | <project> | --all] [--open|--resolved|--all-status] [--json] +// umtool notes reply <id> "<text>" [--resolve] | resolve | wontfix | reopen <id> +// the operator's notes, and the agent's answers import process from "node:process"; import { PROJECT_KINDS, @@ -65,6 +68,7 @@ import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs import { openIndex, signRecord } from "../lib/projects/index-db.mjs"; import { pipelineProcessesFor } from "../lib/report/busy.mjs"; import { probeTools } from "../lib/tools.mjs"; +import { notesCommand } from "../lib/annotations/cli.mjs"; import { scaffoldReportVideo } from "../lib/projects/scaffold.mjs"; import { CACHE_DIR, INDEX_DIR, MEDIA_ROOT, MEDIA_TIERED, OLD_CACHE_DIR } from "../lib/paths.mjs"; import { @@ -698,6 +702,8 @@ function usage() { " umtool diff <project> <snapshot> what changed since that snapshot", " umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]", " umtool check-sources [<project>…] prints the re-check chain (never-checked/old when no args)", + " umtool notes [<site>/<report>|<project>|--all] the operator's notes, as markdown (open ones)", + " umtool notes reply <id> \"<text>\" [--resolve] answer one; resolve | wontfix | reopen <id>", "", `reading ${REPORTS_ROOT} (set REPORTS_DIR to move it)`, "", @@ -725,6 +731,7 @@ const COMMANDS = { diff: cmdDiff, export: cmdExport, "check-sources": cmdCheckSources, + notes: async () => process.exit(await notesCommand(argv.slice(argv.indexOf("notes") + 1))), help: usage, }; diff --git a/umtool/lib/annotations/cli.mjs b/umtool/lib/annotations/cli.mjs @@ -0,0 +1,140 @@ +// `umtool notes` -- the agent's side of the notes the operator writes in the +// app. It reads and writes through the same store (./store.mjs) and targets +// (./targets.mjs) the app does, and stamps every write `author: agent`. An +// agent never hand-edits notes.json: this validates, locks, and keeps the +// operator's page from losing a reply (docs/notes.md). +// +// umtool notes [--all] every notes file, with open counts +// umtool notes <site>/<report> | <project> the digest (open notes) +// [--open | --resolved | --all-status] [--json] +// umtool notes reply <id> "<text>" [--resolve] [--in <target>] +// umtool notes resolve | wontfix | reopen <id> [--in <target>] +// umtool notes source <target> [--draft P] [--generator P] [--how T] +import { REPORTS_ROOT, SITES_DIR } from "../paths.mjs"; +import { digest } from "./digest.mjs"; +import { readNotes } from "./store.mjs"; +import { articleTarget, listNotesFiles, projectTarget, resolveTarget, writeNote } from "./targets.mjs"; + +const USAGE = [ + "usage: umtool notes [--all] every notes file and its open count", + " umtool notes <site>/<report> | <project> the notes, as markdown (open ones)", + " [--open | --resolved | --all-status] [--json]", + " umtool notes reply <id> \"<text>\" [--resolve] answer a note (and close it)", + " umtool notes resolve | wontfix | reopen <id>", + " umtool notes source <target> [--draft P] [--generator P] [--how T]", + "", + "Notes are the operator's, written in umtool (/sites, a video project). Act on one", + "by editing its SOURCE file and regenerating; never edit notes.json by hand.", +].join("\n"); + +class CliError extends Error {} + +/** + * @param {string[]} args everything after `notes` + * @param {{ sitesDir?: string, reportsRoot?: string, log?: (s: string) => void }} [opts] + * @returns {Promise<number>} exit code + */ +export async function notesCommand(args, { sitesDir = SITES_DIR, reportsRoot = REPORTS_ROOT, log = console.log } = {}) { + const flags = new Set(args.filter((a) => a.startsWith("--"))); + const val = (n) => { + const i = args.indexOf(n); + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; + }; + const VALUED = new Set(["--in", "--draft", "--generator", "--how"]); + const pos = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.has(args[i - 1]))); + const json = flags.has("--json"); + const opts = { sitesDir, reportsRoot }; + const print = (v) => log(json ? JSON.stringify(v, null, 2) : v); + + try { + const verb = pos[0]; + if (flags.has("--help") || verb === "help") { + log(USAGE); + return 0; + } + if (verb === "reply" || verb === "resolve" || verb === "wontfix" || verb === "reopen") { + const id = pos[1]; + if (!id) throw new CliError(`which note? \`umtool notes ${verb} <id>\``); + const where = await findNote(id, val("--in"), opts); + let op; + if (verb === "reply") { + const text = pos[2]; + if (!text) throw new CliError('what reply? `umtool notes reply <id> "<text>" [--resolve]`'); + op = { op: "reply", id, text, resolve: flags.has("--resolve") }; + } else { + op = { op: "status", id, status: verb === "reopen" ? "open" : verb === "wontfix" ? "wontfix" : "resolved" }; + } + const r = await writeNote(where.target, op, { by: "agent" }); + if (json) print({ ok: true, target: where.target.id, file: where.target.file, note: r.note }); + else log(`${id} in ${where.target.id}: ${r.note?.status}${verb === "reply" ? `, ${r.note?.replies.length} repl${r.note?.replies.length === 1 ? "y" : "ies"}` : ""}`); + return 0; + } + if (verb === "source") { + const spec = pos[1]; + if (!spec) throw new CliError("which target? `umtool notes source <site>/<report> --draft P`"); + const target = await resolveTarget(spec, opts); + const cur = await readNotes(target.file); + if (!cur.doc) throw new CliError(`${target.id} has no notes; the source is recorded with the first note`); + const source = { ...(cur.doc.source ?? {}) }; + for (const k of ["draft", "generator", "how"]) if (val(`--${k}`) !== undefined) source[k] = val(`--${k}`); + const r = await writeNote(target, { op: "source", source }, { by: "agent" }); + print(json ? { ok: true, source: r.doc?.source ?? null } : `${target.id}: source ${JSON.stringify(r.doc?.source ?? {})}`); + return 0; + } + + const status = flags.has("--all-status") ? "all" : flags.has("--resolved") ? "resolved" : "open"; + if (!verb || flags.has("--all")) { + const files = await listNotesFiles(opts); + if (json) { + print(files.map((f) => ({ kind: f.kind, id: f.id, file: f.file, open: f.doc ? f.doc.notes.filter((n) => n.status === "open").length : null, total: f.doc?.notes.length ?? null, error: f.error }))); + return 0; + } + if (!files.length) { + log(`no notes under ${sitesDir} or ${reportsRoot}`); + return 0; + } + const w = Math.max(...files.map((f) => f.id.length)); + let open = 0; + for (const f of files) { + const o = f.doc ? f.doc.notes.filter((n) => n.status === "open").length : 0; + open += o; + if (status === "open" && !o && !f.error) continue; + log(`${f.id.padEnd(w)} ${f.kind === "article" ? "article" : "video "} ${f.error ? `UNREADABLE: ${f.error}` : `${o} open / ${f.doc.notes.length}`}`); + } + log(`\n${open} open note(s) in ${files.length} file(s). \`umtool notes <id>\` for one.`); + return 0; + } + + const target = await resolveTarget(verb, opts); + const read = await readNotes(target.file); + const entry = { kind: target.kind, id: target.id, file: target.file, doc: read.doc, ...(read.error ? { error: read.error } : {}) }; + if (json) { + print({ ...entry, source: read.doc?.source ?? (await target.source()) ?? null }); + return 0; + } + if (!read.doc && !read.error) { + log(`no notes on ${target.id} (${target.file})`); + return 0; + } + log(await digest(entry, { status, sitesDir, reportsRoot, projectDir: target.dir })); + return 0; + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + return err instanceof CliError || err?.name === "NoteError" || err?.name === "TargetError" ? 2 : 1; + } +} + +/** The notes file holding note `id`: the one named by --in, else a search of every file. */ +async function findNote(id, inSpec, opts) { + if (inSpec) { + const target = await resolveTarget(inSpec, opts); + const r = await readNotes(target.file); + if (!r.doc?.notes.some((n) => n.id === id)) throw new CliError(`no note ${id} in ${target.id}`); + return { target }; + } + const hits = (await listNotesFiles(opts)).filter((f) => f.doc?.notes.some((n) => n.id === id)); + if (!hits.length) throw new CliError(`no note ${id} (see \`umtool notes --all\`)`); + if (hits.length > 1) throw new CliError(`${id} is in ${hits.length} files; say which with --in: ${hits.map((h) => h.id).join(", ")}`); + const hit = hits[0]; + return { target: hit.kind === "article" ? await articleTarget(hit.id, opts) : await projectTarget(hit.id, opts) }; +} diff --git a/umtool/lib/annotations/cli.test.mjs b/umtool/lib/annotations/cli.test.mjs @@ -0,0 +1,103 @@ +// `umtool notes`: the agent's loop, end to end, against a temp SITES_DIR and +// REPORTS_DIR -- read the digest, reply and resolve, reopen, list. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { notesCommand } from "./cli.mjs"; +import { articleTarget, projectTarget, writeNote } from "./targets.mjs"; +import { clearSourcesCache } from "../articles/sources.mjs"; + +const REPORT = { + format: "archilyzer-report", + version: 1, + id: "polemic-x", + kind: "sweep", + title: "On X", + summary: "She said **X** twice. Then she [denied it](cite:c1).", + citations: { c1: { kind: "video", channel: "ch", id: "v1", start: 10, end: 20, quote: "I never said X", speaker: "Her" } }, + sections: [{ id: "s1", title: "The first time", body: "In 2019 she said X on a podcast. Nobody noticed." }], +}; + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "umtool-notes-cli-")); + const sites = path.join(root, "sites"); + const reports = path.join(root, "reports"); + await mkdir(path.join(sites, "priv", "reports", "polemic-x"), { recursive: true }); + await writeFile(path.join(sites, "priv", "site.json"), "{}"); + await writeFile(path.join(sites, "priv", "reports", "polemic-x", "report.json"), JSON.stringify(REPORT)); + await mkdir(path.join(reports, "ws", "polemics", "drafts"), { recursive: true }); + await writeFile(path.join(reports, "ws", "polemics", "drafts", "x.json"), JSON.stringify({ id: "polemic-x" })); + await writeFile(path.join(reports, "ws", "polemics", "make-site.py"), 'SITE = "priv" # drafts\n'); + const proj = path.join(reports, "ws", "polemic-x"); + await mkdir(proj, { recursive: true }); + await writeFile( + path.join(proj, "video.manifest.json"), + JSON.stringify({ schemaVersion: 1, slug: "polemic-x", generatedBy: "polemics/make-site.py", timeline: [{ type: "clip", id: "e1", channel: "ch", video: "v1", start: 10, end: 20, quote: "I never said X", onscreen: { title: "Denial" } }] }), + ); + clearSourcesCache(); + return { root, sites, reports, opts: { sitesDir: sites, reportsRoot: reports } }; +} + +async function run(args, opts) { + const out = []; + const code = await notesCommand(args, { ...opts, log: (s) => out.push(String(s)) }); + return { code, text: out.join("\n") }; +} + +test("the agent loop: digest, reply --resolve, reopen; every write is the agent's", async () => { + const { root, sites, opts } = await fixture(); + const t = await articleTarget("priv/polemic-x", opts); + const a = await writeNote(t, { op: "add", text: "Too strong; say 'claimed'.", anchor: { kind: "text", section: "s1", quote: "she said X", prefix: "In 2019 ", suffix: " on a podcast" } }, { by: "operator" }); + await writeNote(t, { op: "add", text: "Is this the right clip?", anchor: { kind: "cite", cite: "c1" } }, { by: "operator" }); + + const d = await run(["priv/polemic-x"], opts); + assert.equal(d.code, 0); + assert.match(d.text, /# Notes on priv\/polemic-x — On X/); + assert.match(d.text, /edit `.*ws\/polemics\/drafts\/x\.json`; `report\.json` is regenerated by `.*make-site\.py`/); + assert.match(d.text, /“she said X” in “The first time”/); + assert.match(d.text, /> In 2019 she said X on a podcast\./); + assert.match(d.text, /citation `c1` — ch\/v1@10-20 \(Her\)/); + assert.match(d.text, /2 open, 0 closed/); + + const r = await run(["reply", a.note.id, "Changed to 'claimed' in drafts/x.json", "--resolve"], opts); + assert.equal(r.code, 0, r.text); + const doc = JSON.parse(await readFile(path.join(sites, "priv", "reports", "polemic-x", "notes.json"), "utf8")); + const n = doc.notes.find((x) => x.id === a.note.id); + assert.equal(n.status, "resolved"); + assert.equal(n.resolvedBy, "agent"); + assert.deepEqual(n.replies.map((x) => x.author), ["agent"]); + + assert.match((await run(["priv/polemic-x"], opts)).text, /1 open, 1 closed/); + assert.match((await run(["priv/polemic-x", "--resolved"], opts)).text, /Changed to 'claimed'/); + assert.equal((await run(["reopen", a.note.id], opts)).code, 0); + const list = await run(["--all"], opts); + assert.match(list.text, /priv\/polemic-x\s+article\s+2 open \/ 2/); + + // refusals exit 2 and write nothing + assert.equal((await run(["reply", "n_nosuchnote"], opts)).code, 2); + assert.equal((await run(["reply", a.note.id], opts)).code, 2); + assert.equal((await run(["priv/../etc"], opts)).code, 2); + await rm(root, { recursive: true }); +}); + +test("a video project's digest names the generator and lists the takes' verdicts", async () => { + const { root, reports, opts } = await fixture(); + const proj = path.join(reports, "ws", "polemic-x"); + await mkdir(path.join(proj, "takes", "deck"), { recursive: true }); + await writeFile(path.join(proj, "takes", "deck", "take.json"), JSON.stringify({ id: "deck", group: "open", order: 1, label: "Deck first", kind: "similar", preview: "preview.mp4" })); + await writeFile(path.join(proj, "takes", "verdicts.json"), JSON.stringify({ deck: { verdict: "like", note: "keep the beat", at: "x" } })); + const t = await projectTarget("ws/polemic-x", opts); + await writeNote(t, { op: "add", text: "Cut this.", anchor: { kind: "entry", entry: "e1" } }, { by: "operator" }); + await writeNote(t, { op: "add", text: "quote changed", anchor: { kind: "edit", entry: "e1", field: "quote", from: "a", to: "b" } }, { by: "operator" }); + const d = await run(["ws/polemic-x"], opts); + assert.equal(d.code, 0, d.text); + assert.match(d.text, /video\.manifest\.json` is regenerated by `.*ws\/polemics\/make-site\.py`/); + assert.match(d.text, /timeline entry `e1` \(clip\) “Denial”/); + assert.match(d.text, /`e1\.quote`: "a" → "b"/); + assert.match(d.text, /- `deck` “Deck first” \(open, similar\): like — keep the beat/); + await rm(root, { recursive: true }); +}); diff --git a/umtool/lib/annotations/digest.mjs b/umtool/lib/annotations/digest.mjs @@ -0,0 +1,207 @@ +// 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 <target>` 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 `<channel>/<id>@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 <id> "what you changed" --resolve\` (or \`umtool notes reply <id> "question"\` to ask).`); + } + return lines.join("\n"); +}