2026-10-05 22:17:33 +03:00
/**
* THE COMPACTION SERVICE (owner round 14, 2026-10-05): the trigger
* plumbing around core/compaction's pure cuts and markers.
*
* TOPOLOGY (why this is simple): question and problem poses TERMINATE
* their runs, so the "user is thinking" pause is always a BETWEEN-RUNS
* window. Compaction therefore never touches a live run:
*
* - SETTLE KICK — a turn settles (onFinish, after persist) and the
* projected context is over threshold: the summary starts NOW, in
* the background, while the learner reads/answers — usually done
* long before the next input arrives.
* - TURN-START GATE — every turn (client POST or wake) awaits any
* in-flight compaction for its chat BEFORE appends or beginTurn;
* if the record is STILL over threshold (cold sessions, kicks that
* lost their engine), compaction runs synchronously inside the
* request. Structurally gated: nothing model-facing for the chat
* interleaves with a running compaction (owner ruling — minimal
* surface for anything to go wrong).
* - OVERFLOW RECOVERY — a run that dies on context length needs no
* special path: the next turn's gate compacts and the fresh run
* starts small.
*
* The summary is written by the session's OWN conversation model (owner
* ruling) via the zero-tool one-shot sub-agent seam; its usage lands in
* the chat's append-only turns log (the money channel) keyed by the
* marker's id.
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core" ;
import type { Usage } from "@earendil-works/pi-ai" ;
import {
activeCompactionOf ,
buildCompactionPrompt ,
compactionCutIndex ,
COMPACTION_RESERVE_TOKENS ,
2026-10-06 00:38:15 +03:00
estimateContextTokens ,
2026-10-05 22:17:33 +03:00
estimateTokens ,
newCompactionMarker ,
planTreeToText ,
serializeForSummary ,
toLlmMessages ,
type CompactionDetails ,
type LhMessage ,
type LhMarkerMessage ,
} from "../core/index.ts" ;
import { emit } from "../core/events.ts" ;
import type { ChatStore } from "../core/ports.ts" ;
import type { PiEngine } from "../agent/models.ts" ;
import { runSubagent } from "../agent/subagents/pi-run.ts" ;
/** The summarizer's system prompt — server-owned operational framing,
* not model-facing doctrine (buildCompactionPrompt carries the shape). */
const SUMMARIZER_SYSTEM_PROMPT =
"You are the session-compaction summarizer for a tutoring agent. You receive the transcript span to summarize and write exactly the summary the instructions demand — nothing else, no preamble, no meta-commentary." ;
/** The one-shot summary call: zero tools, one turn, text out. The engine
* is the chat's own conversation engine (owner ruling: the session's
* model writes its own summary). Injectable for tests. */
export type Summarizer = ( prompt : string , engine : PiEngine ) => Promise < { summary : string ; usage : Usage } > ;
/** The production summarizer over the sub-agent seam. */
async function subagentSummarizer ( prompt : string , engine : PiEngine ) : Promise < { summary : string ; usage : Usage } > {
let usage : Usage = {
input : 0 ,
output : 0 ,
cacheRead : 0 ,
cacheWrite : 0 ,
totalTokens : 0 ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 },
};
const { text } = await runSubagent ([], {
// The engine override is always present (the chat's own conversation
// engine — owner ruling), so the role label never resolves an
// engine; "researcher" is the least-wrong existing ModelRole label
// for a one-shot text job (the cast documents the dead path).
role : "researcher" as never ,
prompt ,
engine ,
systemPrompt : SUMMARIZER_SYSTEM_PROMPT ,
maxTurns : 1 ,
onUsage : ( u ) => {
usage = u ;
},
});
return { summary : text , usage };
}
/** Everything the service needs from its host (chat-handler wires the
* production versions; tests inject). */
export interface CompactionDeps {
2026-10-24 12:17:08 +03:00
/** Eligibility only for NEW runs; structural waits still apply. */
canStart ?: () => boolean ;
2026-10-05 22:17:33 +03:00
chatStore : ChatStore ;
/** The chat's conversation engine — also the summarizer's. */
engine : PiEngine ;
/** provider:id of the engine (usage + marker provenance). */
modelId : string ;
/** The session kind (summarizer wording: tutor vs practice). */
kind : "teach" | "practice" | "probe" ;
/** The effective trigger threshold, RESOLVED by the caller:
* min(settings threshold, contextWindow − reserve) — the owner's
* effective-trigger ruling. The catalog window lookup is the
* caller's (chat-handler has the model id at hand). */
thresholdTokens : number ;
/** The composed system prompt's token estimate (chars/4) — the
* projection the provider actually receives starts with it. */
systemPromptTokens : number ;
/** Injectable summarizer (tests); default = the sub-agent one-shot. */
summarize? : Summarizer ;
}
// ─── the registry (module-global on globalThis, jobs.ts's pattern) ─────────
interface CompactionState {
inFlight : Map < string , Promise < void >>;
}
const g = globalThis as typeof globalThis & { __lhCompaction? : CompactionState };
function state () : CompactionState {
return ( g . __lhCompaction ??= { inFlight : new Map () });
}
/** Is a compaction running for this chat? (The client status event's
* server-side twin — tests and the SSE emitter read it.) */
export function compactionInFlight ( chatId : string ) : boolean {
return state (). inFlight . has ( chatId );
}
/** Await any in-flight compaction for the chat — the structural gate
* every model interaction for the chat passes through. */
export async function awaitCompaction ( chatId : string ) : Promise < void > {
const running = state (). inFlight . get ( chatId );
if ( running ) await running ;
}
// ─── the effective threshold (owner ruling: min(setting, window − reserve)) ─
/** Resolve the effective trigger. Unknown window → the setting alone
* (honest: no fake window — the catalog-index precedent). */
export function effectiveThresholdTokens ( settingTokens : number | undefined , contextWindowTokens : number | undefined ) : number {
const setting = settingTokens ?? 150 _000 ;
if ( contextWindowTokens === undefined ) return setting ;
return Math . min ( setting , Math . max ( contextWindowTokens - COMPACTION_RESERVE_TOKENS , 1 ));
}
// ─── the estimate over the ACTIVE projection ────────────────────────────────
2026-10-06 00:38:15 +03:00
/** The projected context estimate: what the next LLM call would carry.
* THE TRIGGER LAW (pi's, verified 2026-10-05): anchor on the last
* assistant message's provider-reported usage — it already counted
* the system prompt, tool declarations, and the provider's own
* tokenizer — and chars/4-estimate ONLY messages appended after it.
* estimateContextTokens refuses anchors that predate a compaction
* marker (that usage described context the next request will NOT
* carry), so the fallback path runs the plain heuristic over the
* CUT-HONORED projection (never the dropped span) plus the additive
* prompt term the anchor would have covered. */
2026-10-05 22:17:33 +03:00
export function projectedContextTokens ( messages : readonly LhMessage [], systemPromptTokens : number ) : number {
2026-10-06 00:38:15 +03:00
const est = estimateContextTokens ( messages );
if ( est . lastUsageIndex !== null ) return est . tokens ;
2026-10-05 22:17:33 +03:00
return estimateTokens ( toLlmMessages ( messages as readonly AgentMessage []) as unknown as readonly LhMessage []) + systemPromptTokens ;
}
// ─── the compaction run ─────────────────────────────────────────────────────
/** Run one compaction for the chat (the registry mutex is HELD by the
* caller — ensureCompacted). Reads the record fresh, cuts, summarizes,
* appends the marker, records usage, notifies. Never throws upward
* past the registry cleanup. */
async function compact ( deps : CompactionDeps , chatId : string ) : Promise < void > {
const record = deps . chatStore . getChat ( chatId );
if ( ! record ) return ;
const messages = record . messages ;
const tokensBefore = projectedContextTokens ( messages , deps . systemPromptTokens );
if ( tokensBefore <= deps . thresholdTokens ) return ; // settled beneath while we waited
const cut = compactionCutIndex ( messages );
2026-10-24 19:05:02 +03:00
if ( cut <= 0 || cut >= messages . length ) return ; // no history can be dropped
2026-10-05 22:17:33 +03:00
// The previous compaction's kept boundary (chained compactions
// summarize the old summary + what rode after it).
const previous = activeCompactionOf ( messages );
const prevKept = previous ? messages . findIndex (( m ) => m . id === previous . details . firstKeptMessageId ) : - 1 ;
// ANTI-CHURN GUARD: when the cut cannot advance past the previous
// kept boundary, the span is empty (or repeats it) — the kept tail
// alone carries the projection and re-summarizing the same span
// cannot shrink it. Reachable only when the threshold sits below
// kept-tail + summary (a hand-edited record below the settings
// floor); skipping keeps the loop honest instead of churning.
if ( previous && prevKept !== - 1 && cut <= prevKept ) {
console . info (
`[chat ${ chatId } ] compaction skipped — the kept tail alone exceeds the threshold (raise it or shrink the tail)` ,
);
return ;
}
// The span: from the PREVIOUS kept boundary through the cut.
let spanStart = 0 ;
let priorSummary : string | null = null ;
if ( previous && prevKept > 0 ) {
spanStart = prevKept ;
priorSummary = previous . content ;
}
const spanText = serializeForSummary ( messages . slice ( spanStart , cut ));
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded] \ n ${ priorSummary } \ n \ n ${ spanText } ` : spanText ;
const prompt = buildCompactionPrompt ({
spanText : fullSpan ,
planTreeText : planTreeToText ( record . tree , messages ),
kind : deps.kind ,
});
emit ( "compaction-changed" , { chatId , running : true });
try {
const summarize = deps . summarize ?? subagentSummarizer ;
const { summary , usage } = await summarize ( prompt , deps . engine );
const trimmed = summary . trim ();
if ( ! trimmed ) return ; // an empty summary is a refusal artifact — leave the record honest
const firstKept = messages [ cut ] ! ;
const details : CompactionDetails = {
firstKeptMessageId : firstKept.id ,
tokensBefore ,
model : deps.modelId ,
at : new Date (). toISOString (),
};
const marker = newCompactionMarker ( trimmed , details );
deps . chatStore . saveMessages ( chatId , [... messages , marker ]);
// The money channel: the summary is a real model call on the
// session's own engine — its usage keys to the marker (provenance:
// the transcript shows the compaction divider at the marker).
deps . chatStore . appendTurnUsage ( chatId , {
id : crypto.randomUUID (),
messageId : marker.id ,
at : details.at ,
model : deps.modelId ,
steps : [
{
inputTokens : usage.input ,
outputTokens : usage.output ,
cacheReadTokens : usage.cacheRead ,
cacheWriteTokens : usage.cacheWrite ,
2026-10-24 19:05:02 +03:00
cacheWrite1hTokens : usage.cacheWrite1h ?? 0 ,
2026-10-05 22:17:33 +03:00
reasoningTokens : usage.reasoning ?? 0 ,
},
],
});
// Notify-then-fetch: the record gained the marker (the client's
// dividers appear) and the status event ends the send gate.
emit ( "record-changed" , { chatId });
} catch ( err ) {
// A failed compaction leaves the record untouched — the next
// turn-start gate retries (the same self-healing law as the
// extraction trigger).
console . error ( `[chat ${ chatId } ] compaction failed:` , err );
} finally {
emit ( "compaction-changed" , { chatId , running : false });
}
}
/**
* The gate + trigger in one: await any in-flight compaction, then — if
* the record is STILL over threshold — run one synchronously (the
* caller's request waits; the owner's structural-gate ruling). Idempotent
* under concurrency through the registry mutex.
*/
export async function ensureCompacted ( deps : CompactionDeps , chatId : string ) : Promise < void > {
// Skipped under tests, the extraction-trigger precedent: scripted
// chats are fixtures — the service itself is tested directly with an
// injected summarizer.
if ( process . env . NODE_ENV === "test" && ! deps . summarize ) return ;
await awaitCompaction ( chatId );
2026-10-24 12:17:08 +03:00
if ( deps . canStart ? .() === false ) return ;
2026-10-05 22:17:33 +03:00
const record = deps . chatStore . getChat ( chatId );
if ( ! record ) return ;
if ( projectedContextTokens ( record . messages , deps . systemPromptTokens ) <= deps . thresholdTokens ) return ;
if ( state (). inFlight . has ( chatId )) return ; // a concurrent caller started it while we awaited
const run = compact ( deps , chatId );
state (). inFlight . set ( chatId , run );
try {
await run ;
} finally {
state (). inFlight . delete ( chatId );
}
}
/**
* The settle kick (fire-and-forget): the turn settled, the learner is
* about to read/answer — if the projection is over threshold, the
* summary starts NOW and finishes inside the pause. Wired at
* chat-handler's onFinish tail; engineless or over-threshold-nothing
* are silent no-ops. The next turn's ensureCompacted gate picks up
* whatever this leaves running.
*/
export function kickCompaction ( buildDeps : () => CompactionDeps | null , chatId : string ) : void {
if ( state (). inFlight . has ( chatId )) return ;
const deps = buildDeps ();
2026-10-24 12:17:08 +03:00
if ( ! deps || deps . canStart ? .() === false ) return ;
2026-10-05 22:17:33 +03:00
const record = deps . chatStore . getChat ( chatId );
if ( ! record ) return ;
if ( projectedContextTokens ( record . messages , deps . systemPromptTokens ) <= deps . thresholdTokens ) return ;
const run = compact ( deps , chatId );
state (). inFlight . set ( chatId , run );
void run . finally (() => state (). inFlight . delete ( chatId ));
}