Voice: rewrote REGINALD_SYSTEM and CAPTURE_SYSTEM (core) to an elevated,
dry butler register, and re-voiced his visible lines — chat greetings, the
approve/dismiss/error replies, the standup closer ('The kettle is on. Yours,
Reginald.'), the onboarding welcome, and the capture prose. Both system prompts
now also instruct him never to use an em dash.
Em-dashes: swept every user-facing string in the renderer free of em-dashes
(punctuation only, comments left untouched) via a per-file pass, plus the core
tool descriptions and the memory focus-slot placeholder ('· ' not '— '). Bare
'—' value placeholders became middots ('·'). No em-dash now renders anywhere
in the app or in Reginald's own output.
core 169 tests green (updated the memory placeholder assertion) · core + desktop
tsc clean · verified visually (posh greeting + standup closer render).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
4.7 KiB
TypeScript
103 lines
4.7 KiB
TypeScript
/**
|
|
* 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')
|
|
}
|