Files
commitea/packages/core/src/agent/memory-v0.test.ts
Croissant Le Doux b65ee4c8ad 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>
2026-07-09 15:37:57 -04:00

72 lines
3.2 KiB
TypeScript

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>): 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'))
})
})