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
@@ -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);
}