Memory layers: budgeted hot context (#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 (2k). Assembled fresh each turn as the prompt seed. - WARM — the append-only directive/event ledger + digest, summarized on demand. - COLD — gitea + sidecar via query_project. Ticket bodies/comments/detail live here and are NEVER inlined; the model fetches them by number when needed. - `assembleHotContext(inputs, budget=2000)`: focus (tiny, always kept) → most recent active directives (each while they fit ~⅔) → charter fills the true remainder, truncated on a line boundary. Measures the fixed tail exactly and reserves for header/joiner/ellipsis so the total never exceeds budget. - `estimateTokens` (tokenizer-free ~4 chars/token, slight over-estimate so a real tokenizer stays under), `activeDirectives` (accepted/amended, most-recent-first). Acceptance met: hot assembles under the 2k budget even with a ~34k-token charter; nothing ticket-shaped is inlined (only numbers + titles for focus). +5 core tests; full core suite + typecheck green. Follow-up: wire assembleHotContext into the live system prompt in main (needs charter + directives + focus at chat time) — the assembly + budget is the tested core. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
packages/core/src/agent/memory-v0.ts
Normal file
102
packages/core/src/agent/memory-v0.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
Reference in New Issue
Block a user