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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user