// Per-channel context notes and the `contextHash` every generated section // records. // // This is the MINIMUM of PLAN.md's Phase 1.5, deliberately: the full // channel-context feature (structured frontmatter, per-channel entity lists) is // not built here, but the KEY is plumbed now. PLAN.md's "do not backfill before // 1.5" trap is exactly this — a digest generated without a contextHash cannot // tell whether it predates a channel's context note, so adding notes later would // invalidate the whole corpus. Hashing the (currently usually empty) note from // day one means adding a note later invalidates only that channel. // // The note is a plain Markdown file in the channel dir, hand-authored. Stage B's // compounding step is writing a correction here — "the co-host is Sam, not Sand" // — rather than patching individual videos, because a note improves every future // generation for that channel. import path from "node:path"; import { readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import type { Paths } from "./paths"; export const DIGEST_CONTEXT_FILENAME = "digest-context.md"; // Cap what reaches the prompt: a note is guidance, not a second transcript, and // an unbounded one would eat the context window the transcript needs. const MAX_CONTEXT_CHARS = 4000; export type DigestContext = { // The note text to embed in the prompt. "" when the channel has none. note: string; // Stable hash of every context input. Recorded in each section's provenance and // compared on re-run. The empty-note hash is a real, stable value — NOT "" — // so "no note" and "note removed" are the same state and neither is confused // with "generated before contextHash existed" (which reads as "" and is stale). hash: string; }; export function digestContextPath(paths: Paths, channelSlug: string): string { return path.join(paths.channelsDir, channelSlug, DIGEST_CONTEXT_FILENAME); } // Hash the context inputs. Versioned by a literal prefix so the hashing scheme // itself can change later without colliding with old values. // // DECIDED 2026-07-29, BEFORE THE BACKFILL SWEEP: this stays at **v1**. // // The decision had to be made explicitly and in advance, because the prefix is // folded into every section's `contextHash` and bumping it after the sweep would // invalidate all ~77k videos — GPU-weeks thrown away by a one-character edit. // // v1 is correct because the EMPTY note already hashes stably. Adding a per-channel // context note later (Phase 1.5) changes `note`, not the scheme, so it invalidates // only the channels that get a note — which is the desired behaviour, not a // problem to be versioned around. **Only a change to the hashing SCHEME itself // needs v2** (a different algorithm, or folding in a second input). export function hashDigestContext(note: string): string { return createHash("sha1") .update(`digest-context-v1\n${note}`) .digest("hex") .slice(0, 16); } export async function readDigestContext( paths: Paths, channelSlug: string, ): Promise { let note = ""; try { const raw = await readFile(digestContextPath(paths, channelSlug), "utf8"); note = raw.trim().slice(0, MAX_CONTEXT_CHARS); } catch { note = ""; } return { note, hash: hashDigestContext(note) }; }