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,13 +6,43 @@ import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { useDirectorStore } from '@/stores/directorStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { buildDirectorScene, runDirectorTurn, type DirectorAction, type RollRequest } from '@/lib/assistant/director';
|
||||
import {
|
||||
buildDirectorScene,
|
||||
runDirectorTurn,
|
||||
planContext,
|
||||
deterministicSummary,
|
||||
buildSummaryPrompt,
|
||||
type DirectorAction,
|
||||
type RollRequest,
|
||||
} from '@/lib/assistant/director';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import type { Degree } from '@/lib/dice/check';
|
||||
import { useLatestAiSession } from './hooks';
|
||||
import { useDirectorAction, type ApplyResult } from './useDirectorAction';
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
|
||||
/**
|
||||
* Fold older transcript turns into the session's rolling summary when the live
|
||||
* window has grown past its budget. Uses the LLM when configured, else a
|
||||
* deterministic compaction — so the context stays bounded with or without a key.
|
||||
*/
|
||||
async function maybeSummarize(sessionId: string, systemConstraint: string, windowSize: number): Promise<void> {
|
||||
const s = await aiSessionsRepo.get(sessionId);
|
||||
if (!s) return;
|
||||
const plan = planContext(s.transcript, s.summarizedThrough, windowSize);
|
||||
if (plan.foldEntries.length === 0) return;
|
||||
|
||||
const cfg = getLlmConfig();
|
||||
let summary = deterministicSummary(s.summary, plan.foldEntries);
|
||||
if (cfg.enabled && cfg.apiKey.trim()) {
|
||||
const { system, user } = buildSummaryPrompt(systemConstraint, s.summary, plan.foldEntries);
|
||||
const res = await complete(cfg, { system, user, maxTokens: 512 });
|
||||
if (res.ok && 'text' in res && res.text.trim()) summary = res.text.trim();
|
||||
}
|
||||
await aiSessionsRepo.setSummary(sessionId, summary, plan.newSummarizedThrough);
|
||||
}
|
||||
|
||||
const DEGREE_LABEL: Record<Degree, string> = {
|
||||
'critical-success': 'Critical Success',
|
||||
success: 'Success',
|
||||
@@ -68,7 +98,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
}
|
||||
|
||||
const fresh = await aiSessionsRepo.get(sid);
|
||||
const transcriptWindow = (fresh?.transcript ?? []).slice(-windowSize);
|
||||
const plan = planContext(fresh?.transcript ?? [], fresh?.summarizedThrough ?? 0, windowSize);
|
||||
const scene = buildDirectorScene({
|
||||
campaign,
|
||||
characters,
|
||||
@@ -76,7 +106,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
persona,
|
||||
controlledCharacterId,
|
||||
narrationStyle,
|
||||
transcriptWindow,
|
||||
transcriptWindow: plan.window,
|
||||
summary: fresh?.summary,
|
||||
});
|
||||
|
||||
@@ -94,6 +124,9 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
}]);
|
||||
}
|
||||
setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
|
||||
|
||||
// Token budget: fold older turns into a rolling summary so the context stays bounded.
|
||||
await maybeSummarize(sid, scene.systemConstraint, windowSize);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user