/** * Memory layers, v0 (#27). Reginald's context is tiered so the model always sees * what matters without ever copying ticket data into the prompt: * * - HOT (this module) — charter + active directives + the focus snapshot, packed * under a hard token budget. Assembled fresh each turn; it's the system-prompt seed. * - WARM — the append-only directive/event ledger + periodic digest, in pm-state. * Not inlined; summarized on demand. * - COLD — gitea + the sidecar, reached through `query_project` tools. Ticket bodies, * comments, and per-issue detail live here and are NEVER copied into memory — * the model fetches them by number when it needs them. * * The invariant: HOT stays under budget, and nothing ticket-shaped is inlined. */ import type { DirectiveEntry } from '../directives/record-directive-v0.js' import type { Focus } from '../scheduler/scheduler-v0.js' /** The hot layer's hard ceiling (#27: hot context assembles under 2k tokens). */ export const HOT_CONTEXT_BUDGET_TOKENS = 2000 /** * Tokenizer-free estimate (~4 chars/token). Deliberately a slight over-estimate so * a real tokenizer never exceeds what this predicts — the budget stays safe. */ export function estimateTokens(text: string): number { return Math.ceil(text.length / 4) } /** Directives that still bind: accepted or amended, most-recent-first. */ export function activeDirectives(all: DirectiveEntry[]): DirectiveEntry[] { return all .filter((d) => d.status === 'accepted' || d.status === 'amended') .slice() .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0)) } export interface HotContextInputs { /** The project charter markdown (hot-memory seed). */ charter: string /** The directive ledger (any status — filtered to active here). */ directives: DirectiveEntry[] /** The current Now/Next/Later focus, or null when nothing is scheduled. */ focus: Focus | null } function focusBlock(focus: Focus | null): string { if (!focus) return '' const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: ·`) return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n') } function directivesBlock(directives: DirectiveEntry[]): string[] { // one compact line each; the verbatim quote is the payload, kind is the tag return directives.map((d) => `- [${d.kind}] ${d.quote}`) } /** Truncate to a token budget on a whitespace boundary, with an ellipsis marker. */ function clampToTokens(text: string, budgetTokens: number): string { if (estimateTokens(text) <= budgetTokens) return text const maxChars = Math.max(0, budgetTokens * 4 - 1) const cut = text.slice(0, maxChars) const lastBreak = cut.lastIndexOf('\n') return `${(lastBreak > maxChars * 0.6 ? cut.slice(0, lastBreak) : cut).trimEnd()}\n…` } /** * Assemble the HOT context under `budget` tokens. Priority when space is tight: * the focus snapshot (tiny, always kept) → the most recent active directives * (each while they fit) → the charter fills whatever budget remains (truncated). * Never inlines ticket bodies — only charter text, directive quotes, and focus * titles, all authored/short. Returns a single prompt-ready block. */ export function assembleHotContext(inputs: HotContextInputs, budget = HOT_CONTEXT_BUDGET_TOKENS): string { const focus = focusBlock(inputs.focus) const focusTokens = focus ? estimateTokens(focus) : 0 // fit the most recent active directives into ~⅔ of what's left after focus const active = activeDirectives(inputs.directives) const directiveCap = Math.max(0, Math.floor((budget - focusTokens) * (2 / 3))) const keptDirectives: string[] = [] let directiveTokens = 0 for (const line of directivesBlock(active)) { const t = estimateTokens(line) + 1 if (directiveTokens + t > directiveCap) break keptDirectives.push(line) directiveTokens += t } const directives = keptDirectives.length ? ['## Active directives', ...keptDirectives].join('\n') : '' // Measure the fixed tail (directives + focus, with their joiner) exactly, then // give the charter the true remainder — reserving for the "## Charter" header, // the block joiner, and the truncation ellipsis so the total never exceeds budget. const tail = [directives, focus].filter(Boolean).join('\n\n') const tailTokens = tail ? estimateTokens(tail) : 0 const reserve = estimateTokens(`## Charter\n${tail ? '\n\n' : ''}\n…`) const charterBudget = Math.max(0, budget - tailTokens - reserve) const charterBody = inputs.charter.trim() ? clampToTokens(inputs.charter.trim(), charterBudget) : '' const charter = charterBody ? `## Charter\n${charterBody}` : '' return [charter, tail].filter(Boolean).join('\n\n') }