D4: token budget — rolling transcript summary keeps long sessions bounded

Director sessions can run for hours; the context would grow without bound. Now a
fixed live window of recent turns is sent each turn, and older turns are folded
into a rolling per-session summary.

- planContext (pure): summary covers [0, summarizedThrough); the live window is
  the rest. Once the window exceeds windowSize + SUMMARIZE_BATCH, the oldest turns
  fold down to the last windowSize — never leaving a gap between summary and window.
- maybeSummarize (after each turn): folds the batch into AiSession.summary via a
  separate complete() call when a key is set, else a deterministic compaction
  (one compact line per turn, capped) — bounded with or without an LLM.
- The summary already rides in the system prompt (it never displaces the grounded
  roster), so the closed-set anti-hallucination anchor is preserved as history scrolls.

Pure windowing + deterministic-summary covered by unit tests. 395 tests green;
lint clean; build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 03:17:45 +02:00
parent 2f0700f8d3
commit 35e0dc4fc1
5 changed files with 164 additions and 4 deletions
+1
View File
@@ -6,3 +6,4 @@ export { buildDirectorPrompt, buildDirectorMessages, cueFor } from './prompt';
export { fallbackDirectorTurn } from './fallback';
export { runDirectorTurn, sanitizeTurn, type DirectorTurnResult } from './engine';
export { resolveCombatant, resolveActor, resolveSpellByName, resolveResourceByName, inRoster } from './resolve';
export { planContext, deterministicSummary, buildSummaryPrompt, SUMMARIZE_BATCH, type ContextPlan } from './summarize';
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { planContext, deterministicSummary, SUMMARIZE_BATCH } from './summarize';
import type { DirectorTranscriptEntry } from '@/lib/schemas';
const entries = (n: number): DirectorTranscriptEntry[] =>
Array.from({ length: n }, (_, i) => ({ id: `e${i}`, role: 'narration' as const, text: `turn ${i}.`, ts: 't' }));
describe('planContext — token budget windowing', () => {
it('keeps everything in the window until it exceeds windowSize + batch', () => {
const windowSize = 4;
const t = entries(windowSize + SUMMARIZE_BATCH); // exactly at the threshold → no fold
const plan = planContext(t, 0, windowSize);
expect(plan.foldEntries).toHaveLength(0);
expect(plan.window).toHaveLength(windowSize + SUMMARIZE_BATCH);
expect(plan.newSummarizedThrough).toBe(0);
});
it('folds the oldest turns down to the last windowSize once over budget', () => {
const windowSize = 4;
const t = entries(windowSize + SUMMARIZE_BATCH + 1); // one over → fold
const plan = planContext(t, 0, windowSize);
expect(plan.window).toHaveLength(windowSize);
expect(plan.foldEntries).toHaveLength(SUMMARIZE_BATCH + 1);
expect(plan.newSummarizedThrough).toBe(SUMMARIZE_BATCH + 1);
// no gap: summary covers [0, newSummarizedThrough), window is the remainder
expect(plan.window[0]!.id).toBe(`e${plan.newSummarizedThrough}`);
});
it('accounts for already-summarized entries', () => {
const windowSize = 4;
const t = entries(45);
const plan = planContext(t, 30, windowSize); // 15 unsummarized > 4+8 → fold down to last 4
expect(plan.foldEntries).toHaveLength(11);
expect(plan.newSummarizedThrough).toBe(41);
expect(plan.window).toHaveLength(4);
});
it('does not fold when the unsummarized count is within budget', () => {
const plan = planContext(entries(40), 30, 4); // 10 unsummarized ≤ 4+8 → no fold
expect(plan.foldEntries).toHaveLength(0);
expect(plan.window).toHaveLength(10);
});
});
describe('deterministicSummary', () => {
it('merges the prior summary with one compact line per folded entry', () => {
const folded: DirectorTranscriptEntry[] = [
{ id: '1', role: 'player-input', text: 'I search the chest. It is heavy.', ts: 't' },
{ id: '2', role: 'roll-result', text: 'Investigation: 17 — Success', ts: 't' },
];
const out = deterministicSummary('The party entered the crypt.', folded);
expect(out).toContain('The party entered the crypt.');
expect(out).toContain('Player: I search the chest.');
expect(out).toContain('Roll — Investigation: 17');
});
it('caps the summary length', () => {
const big = deterministicSummary('x'.repeat(5000), entries(50));
expect(big.length).toBeLessThanOrEqual(3000);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { DirectorTranscriptEntry } from '@/lib/schemas';
/** How many turns past the live window must accumulate before we fold them into
* the rolling summary (so we don't fire a summarization call every single turn). */
export const SUMMARIZE_BATCH = 8;
export interface ContextPlan {
/** the live transcript turns to send verbatim this turn */
window: DirectorTranscriptEntry[];
/** older turns to fold into the summary now (empty most turns) */
foldEntries: DirectorTranscriptEntry[];
/** advanced count of entries represented by the summary after folding */
newSummarizedThrough: number;
}
/**
* Decide the token-budgeted context. `summary` covers entries [0, summarizedThrough);
* the live window is everything after that. When the window grows past
* windowSize + SUMMARIZE_BATCH, the oldest entries (down to the last `windowSize`)
* are marked to fold into the summary — so there is never a gap between what the
* summary covers and what the window shows.
*/
export function planContext(
transcript: DirectorTranscriptEntry[],
summarizedThrough: number,
windowSize: number,
): ContextPlan {
const unsummarized = transcript.slice(summarizedThrough);
if (unsummarized.length <= windowSize + SUMMARIZE_BATCH) {
return { window: unsummarized, foldEntries: [], newSummarizedThrough: summarizedThrough };
}
const foldCount = unsummarized.length - windowSize; // keep the most recent windowSize
return {
window: unsummarized.slice(foldCount),
foldEntries: unsummarized.slice(0, foldCount),
newSummarizedThrough: summarizedThrough + foldCount,
};
}
const firstSentence = (text: string): string => (text.split(/(?<=[.!?])\s/)[0] ?? text).slice(0, 160);
/** Offline-safe summary: merge the prior summary with one compact line per folded
* entry, capped so it can't grow unbounded. */
export function deterministicSummary(prior: string, entries: DirectorTranscriptEntry[]): string {
const lines = entries.map((e) => {
const who = e.speaker ? `${e.speaker}: ` : e.role === 'roll-result' ? 'Roll — ' : e.role === 'player-input' ? 'Player: ' : '';
return `${who}${firstSentence(e.text)}`;
});
return [prior, ...lines].filter(Boolean).join(' ').slice(-3000);
}
/** Prompt to compress folded turns into durable canon (continuing the prior summary). */
export function buildSummaryPrompt(
systemConstraint: string,
prior: string,
entries: DirectorTranscriptEntry[],
): { system: string; user: string } {
const log = entries.map((e) => `${e.speaker ? `${e.speaker}: ` : ''}${e.text}`).join('\n');
return {
system:
`${systemConstraint}\n` +
'You compress a tabletop RPG session into durable canon. Summarize the events below into at most 180 words of plain prose: who is present, where they are, the current goal, key outcomes, and open threads. Keep names exact and invent nothing. Merge with the prior summary into a single continuous summary.',
user: `Prior summary:\n${prior || '(none)'}\n\nNew events:\n${log}\n\nReturn only the updated summary text — no preamble.`,
};
}