From b65ee4c8ada6865c215436462570c3f0f2de260e Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 15:37:57 -0400 Subject: [PATCH] Memory layers: budgeted hot context (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/agent/memory-v0.test.ts | 71 +++++++++++++++ packages/core/src/agent/memory-v0.ts | 102 ++++++++++++++++++++++ packages/core/src/index.ts | 2 + 3 files changed, 175 insertions(+) create mode 100644 packages/core/src/agent/memory-v0.test.ts create mode 100644 packages/core/src/agent/memory-v0.ts diff --git a/packages/core/src/agent/memory-v0.test.ts b/packages/core/src/agent/memory-v0.test.ts new file mode 100644 index 0000000..0100110 --- /dev/null +++ b/packages/core/src/agent/memory-v0.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import type { DirectiveEntry } from '../directives/record-directive-v0.js' +import type { Focus, ScheduledItem } from '../scheduler/scheduler-v0.js' +import { + activeDirectives, + assembleHotContext, + estimateTokens, + HOT_CONTEXT_BUDGET_TOKENS, +} from './memory-v0.js' + +const item = (number: number, title: string): ScheduledItem => + ({ number, title, labels: [], order: 0, startDay: 0, endDay: 1, durationDays: 1, blockedBy: [], blocks: [], critical: false, rationale: '' }) + +const focus: Focus = { now: item(7, 'Fix lifecycle inference'), next: item(8, 'Webhook listener'), later: null } + +const directive = (over: Partial): DirectiveEntry => ({ + id: 'd1', + ts: '2026-02-10T09:00:00Z', + status: 'accepted', + kind: 'note', + quote: 'pilots come first', + ...over, +}) + +describe('memory-v0 (#27)', () => { + it('estimateTokens is a slight over-estimate (~4 chars/token)', () => { + expect(estimateTokens('')).toBe(0) + expect(estimateTokens('abcd')).toBe(1) + expect(estimateTokens('a'.repeat(4001))).toBe(1001) + }) + + it('activeDirectives keeps accepted/amended, most-recent-first', () => { + const ds = [ + directive({ id: 'a', ts: '2026-02-01T00:00:00Z', status: 'accepted', quote: 'old' }), + directive({ id: 'b', ts: '2026-02-11T00:00:00Z', status: 'amended', quote: 'new' }), + directive({ id: 'c', ts: '2026-02-12T00:00:00Z', status: 'withdrawn', quote: 'gone' }), + directive({ id: 'd', ts: '2026-02-09T00:00:00Z', status: 'proposed', quote: 'maybe' }), + ] + expect(activeDirectives(ds).map((d) => d.quote)).toEqual(['new', 'old']) + }) + + it('assembles hot context under the 2k budget even with a huge charter', () => { + const huge = 'Charter line that goes on and on. '.repeat(2000) // ~34k tokens + const ds = Array.from({ length: 50 }, (_, i) => + directive({ id: `d${i}`, ts: `2026-02-${String((i % 27) + 1).padStart(2, '0')}T00:00:00Z`, quote: `directive number ${i}` }), + ) + const out = assembleHotContext({ charter: huge, directives: ds, focus }) + + expect(estimateTokens(out)).toBeLessThanOrEqual(HOT_CONTEXT_BUDGET_TOKENS) + // focus (tiny) is always kept; the charter is the part that gets truncated + expect(out).toContain('## Focus') + expect(out).toContain('#7 Fix lifecycle inference') + expect(out).toContain('…') // charter was clamped + // at least some recent directives survived + expect(out).toContain('## Active directives') + }) + + it('never inlines ticket bodies — only numbers + titles appear for focus', () => { + // The assembler takes no issue bodies by construction; focus shows #number title only. + const out = assembleHotContext({ charter: 'Ship the beta.', directives: [directive({})], focus }) + expect(out).toContain('Now: #7 Fix lifecycle inference') + expect(out).toContain('[note] pilots come first') + expect(out).not.toMatch(/body|description|comment/i) + }) + + it('degrades to just focus when there is no charter or directives', () => { + const out = assembleHotContext({ charter: '', directives: [], focus }) + expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: —'].join('\n')) + }) +}) diff --git a/packages/core/src/agent/memory-v0.ts b/packages/core/src/agent/memory-v0.ts new file mode 100644 index 0000000..61d7306 --- /dev/null +++ b/packages/core/src/agent/memory-v0.ts @@ -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') +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e7fb83..8ab8294 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,8 @@ export { } from './agent/agent-tools.js' export { buildProjectView } from './agent/query-project.js' export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js' +export { activeDirectives, assembleHotContext, estimateTokens, HOT_CONTEXT_BUDGET_TOKENS } from './agent/memory-v0.js' +export type { HotContextInputs } from './agent/memory-v0.js' export { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js' export type { CaptureProposal, ProposedIssue } from './agent/capture-work.js'