compaction: the pure core — cut walk, marker, and the model-facing projection (round 14 slice 1)

- core/compaction.ts: compactionCutIndex (backwards ~20k-token walk, NEVER
  separates a tool result from its call — the cut extends to the call's
  assistant message; a pending pose always survives), estimateTokens
  (chars/4, pi's method), newCompactionMarker (header + summary; details
  carry firstKeptMessageId/tokensBefore/model/at), serializeForSummary
  (labeled text, tool results truncated ~2k), planTreeToText (verbatim
  tree with transcript-derived statuses), buildCompactionPrompt (the four
  ruled sections: learner understanding + evolution, teaching arc, plan
  tree verbatim, current moment)
- toLlmMessages honors the cut: the ACTIVE (newest) marker renders ONCE
  as the head; everything before firstKeptMessageId drops; corrupt cut
  degrades honestly (everything renders, never throw)
- constants: KEEP_RECENT 20k, RESERVE 16k, DEFAULT_THRESHOLD 150k
- 16 test pins
This commit is contained in:
amosblomqvist
2026-10-05 22:06:14 +03:00
parent 0c9f82e027
commit 7e9597d969
5 changed files with 577 additions and 4 deletions
+290
View File
@@ -0,0 +1,290 @@
/**
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
* mirroring pi's design (docs aside, this is OUR implementation — pi's
* compaction lives in the coding-agent package we don't depend on).
*
* THE CONTRACT: full history stays persisted in the record — extraction
* and the user's transcript keep reading everything. A compaction marker
* (LhMarkerMessage, customType "compaction") carries a summary plus the
* cut (firstKeptMessageId); toLlmMessages — the ONE model-facing seam —
* projects [summary] + [messages from the cut onward] on every request.
* The live agent never holds dropped messages because every run is built
* fresh from the record: question/problem poses terminate their runs, so
* the "user is thinking" pause is always a BETWEEN-RUNS window —
* compaction kicks at turn settle (running during the pause) and every
* turn start awaits the gate. No mid-run surgery exists.
*
* Trigger threshold (owner ruling): effective = min(settings threshold,
* model contextWindow − 16k reserve). Defaults: threshold 150k, kept
* tail ~20k recent tokens (pi's defaults, owner-approved), token
* estimates chars/4 (pi's method).
*
* Summary content (owner's three requirements + continuity): the
* learner's understanding and its EVOLUTION (quiz outcomes, confidence,
* misconceptions — a detailed picture of what the user knows), the
* teaching arc / session progression, and the plan dependency tree
* VERBATIM (from record.tree — the plan_init tool result may fall before
* the cut, so the marker re-injects it). Plus the current moment (any
* pending question/problem and the immediate next step) so the post-cut
* model can continue mid-thought.
*/
import type { PlanTree } from "./contracts/plan.ts";
import { derivePlanStatusesLh, derivedNodeStatus } from "./contracts/plan.ts";
import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
import { newMessageId } from "./native-messages.ts";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
// ─── constants (the tunables, owner-approved) ────────────────────────────────
/** Kept-recent tail budget (pi's default, owner-approved 2026-10-05):
* ~20k tokens of verbatim recent transcript always ride after the
* summary — recency is cheap insurance against summary loss. */
export const KEEP_RECENT_TOKENS = 20_000;
/** The reserve subtracted from the model's contextWindow when the window
* (not the user's threshold) is the binding constraint (pi's default). */
export const COMPACTION_RESERVE_TOKENS = 16_384;
/** Factory default for the settings threshold (owner ruling: 150k). */
export const DEFAULT_COMPACTION_THRESHOLD_TOKENS = 150_000;
/** Tool-result truncation when serializing the span for the summarizer
* (pi's mirror: long job/research results don't need verbatim inclusion
* in the SUMMARY INPUT — the kept tail carries them whole). */
const SUMMARY_TOOL_RESULT_TRUNCATE = 2_000;
// ─── token estimation (pi's method: chars/4, uniform over shapes) ───────────
/** Estimate a message's context tokens. JSON-serializes the message minus
* its record id — uniform across user/assistant/toolResult/markers, no
* shape forking. An ESTIMATE (the trigger check, not billing). */
export function estimateMessageTokens(message: LhMessage | AgentMessage): number {
const { id: _id, ...rest } = message as LhMessage;
return Math.ceil(JSON.stringify(rest).length / 4);
}
/** Estimate a whole message list. */
export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]): number {
let total = 0;
for (const m of messages) total += estimateMessageTokens(m);
return total;
}
// ─── the cut walk ────────────────────────────────────────────────────────────
/**
* Find the compaction cut index: walk BACKWARDS from the end accumulating
* estimates until ~keepRecentTokens are kept; the cut lands there.
*
* HARD INVARIANT — a tool result is never separated from its call: if the
* walk stops on a toolResult, the KEPT region extends to the assistant
* message carrying its call (keeping the result means keeping the call).
* This is the provider protocol's shape law: dropping a call while
* keeping its result strands an orphan the provider rejects. A PENDING
* question/problem call (no result yet — the pose→answer window) sits at
* the transcript end and is always kept: the walk never crosses it.
*
* Returns 0 when everything must be summarized (the whole history is the
* span — valid), or messages.length when there is nothing meaningful to
* cut (kept budget covers everything — caller checks before compacting).
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
let kept = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (kept >= keepRecentTokens) {
// The cut candidate: extend past orphaned tool results to the
// assistant message carrying their calls.
let cut = i + 1;
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
if (callIndex === -1) break; // no call found (defensive): stop extending
cut = callIndex;
}
return cut;
}
kept += estimateMessageTokens(messages[i]!);
}
return 0; // the whole history fits inside the kept budget — summarize all
}
/** The newest assistant message at or before `before` whose content
* carries the tool call `toolCallId` — the call a result answers. */
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
for (let i = before - 1; i >= 0; i--) {
const m = messages[i]!;
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
}
return -1;
}
// ─── the marker ──────────────────────────────────────────────────────────────
/** The compaction marker's details (UI payload — never model-visible;
* the model sees content, the header + summary). */
export interface CompactionDetails {
/** Record id of the first KEPT message: the projection renders the
* summary, then everything from this message onward. */
firstKeptMessageId: string;
/** The estimated context tokens that triggered this compaction
* (provenance for the divider's tooltip/dialog). */
tokensBefore: number;
/** provider:id of the model that wrote the summary (the session's
* own model — owner ruling; provenance, never a routing input). */
model?: string;
/** ISO timestamp of the compaction moment. */
at: string;
}
/** Build the compaction marker. Content is the FULL model-facing text
* (header + summary — single-sourced header); the marker rides the
* record at the compaction MOMENT (near the transcript end), while the
* cut it names sits deep in history: the UI renders both places. */
export function newCompactionMarker(summary: string, details: CompactionDetails): LhMarkerMessage {
return {
id: newMessageId(),
role: "custom",
customType: "compaction",
content: `${COMPACTED_HISTORY_HEADER}\n\n${summary.trim()}`,
display: true,
details,
timestamp: Date.now(),
};
}
/** The ACTIVE compaction marker: the newest one in the record (chained
* compactions — the last one rules; older markers fall inside the
* dropped span and never render). */
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if ((m as { role?: string }).role !== "custom") continue;
const marker = m as LhMarkerMessage;
if (marker.customType === "compaction") {
const details = marker.details as Partial<CompactionDetails>;
if (typeof details.firstKeptMessageId === "string") {
return marker as LhMarkerMessage & { details: CompactionDetails };
}
}
}
return null;
}
/** The index of the first KEPT message, or -1 when the record's active
* marker names an id that no longer exists (corrupt/foreign) — callers
* fall back to rendering everything (honest degradation, never throw). */
export function keptIndexFor(messages: readonly LhMessage[], marker: LhMarkerMessage & { details: CompactionDetails }): number {
const id = marker.details.firstKeptMessageId;
for (let i = 0; i < messages.length; i++) {
if (messages[i]!.id === id) return i;
}
return -1;
}
// ─── the summarizer input (pi's mirror: labeled text, truncated results) ─────
/** Serialize a message span as labeled text for the summarizer. Tool
* results truncate at ~2k chars (the kept tail carries them verbatim for
* the model; the SUMMARY only needs their gist). */
export function serializeForSummary(messages: readonly LhMessage[]): string {
const lines: string[] = [];
for (const m of messages) {
const role = (m as { role?: string }).role;
if (role === "user") {
lines.push(`[LEARNER] ${(m as { content: string }).content}`);
} else if (role === "assistant") {
const assistant = m as AssistantMessage;
const blocks: string[] = [];
for (const b of assistant.content) {
if (b.type === "text") blocks.push(b.text);
else if (b.type === "toolCall") blocks.push(`(tool call ${b.name}: ${JSON.stringify(b.arguments)})`);
// thinking blocks: the model's private scratch — its
// conclusions land in text; skip the noise
}
if (blocks.length > 0) lines.push(`[TUTOR] ${blocks.join("\n")}`);
} else if (role === "toolResult") {
const result = m as ToolResultMessage;
const text = result.content
.map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
.join("\n");
lines.push(
`[RESULT for ${result.toolName}${result.isError ? " (error)" : ""}] ${
text.length > SUMMARY_TOOL_RESULT_TRUNCATE ? `${text.slice(0, SUMMARY_TOOL_RESULT_TRUNCATE)}…[truncated]` : text
}`,
);
} else if (role === "custom") {
const marker = m as LhMarkerMessage;
if (marker.customType === "plan-block") continue; // UI-only
lines.push(`[SYSTEM NOTE] ${marker.content}`);
}
}
return lines.join("\n\n");
}
/** Render the plan dependency tree verbatim (owner requirement: the tree
* persists through compaction). Structure from record.tree, statuses
* derived from the transcript (the record's own law: the tree carries
* structure only). Empty tree → absent section (nothing persisted). */
export function planTreeToText(tree: PlanTree | undefined, messages: readonly LhMessage[]): string | null {
if (!tree || tree.nodes.length === 0) return null;
const statuses = derivePlanStatusesLh(tree, messages);
const byId = new Map(tree.nodes.map((n) => [n.id, n]));
const lines: string[] = [];
for (const node of tree.nodes) {
const status = derivedNodeStatus(statuses, node.id);
const summary = node.summary ? ` — ${node.summary}` : "";
lines.push(`- [${status}] ${node.title} (${node.id})${summary}`);
}
for (const edge of tree.edges) {
const from = byId.get(edge.from);
const to = byId.get(edge.to);
if (from && to) lines.push(`- ${from.title} → ${to.title} (${from.id} → ${to.id})`);
}
return lines.join("\n");
}
/**
* The summarizer prompt. The summary's REQUIRED SHAPE (owner rulings):
* the learner's understanding AND its evolution, the teaching arc, the
* plan tree verbatim, and the current moment — in that order of stress.
* The summarizer is the session's own model (owner ruling), writing for
* its own future self: the summary replaces everything before the cut.
*/
export function buildCompactionPrompt(input: {
/** The span to summarize, pre-serialized (serializeForSummary). */
spanText: string;
/** The plan tree rendered (planTreeToText) or null. */
planTreeText: string | null;
/** The session kind (wording: tutor vs practice agent). */
kind: "teach" | "practice" | "probe";
}): string {
const planSection =
input.planTreeText ?? "No plan tree exists in this session (nothing was persisted).";
return `You are compacting the earlier part of a tutoring session — writing the summary your future self will read INSTEAD of that transcript. Everything before the cut is replaced by what you write; everything after stays verbatim. The learner's transcript below labels the learner as [LEARNER], the tutoring agent as [TUTOR], tool traffic as [RESULT …]/[SYSTEM NOTE].
Write the summary with EXACTLY these sections, in this order:
## The learner's understanding
The most detailed picture you can give of what this learner knows and how that EVOLVED across the session: what they answered and how well (quiz outcomes, correct reasoning, mistakes), their stated or displayed confidence, misconceptions that surfaced, which were corrected and which still stand. This section carries the most weight — future teaching decisions hang on it.
## The teaching arc
How the session progressed: what was covered and in what order, which explanations landed and which didn't, how the learner responded, where the momentum went. Include probe-phase findings if present.
## The plan dependency tree
Reproduce the session's current plan tree verbatim from the structure below — do not drop nodes, edges, or statuses.
${planSection}
## The current moment
What is happening RIGHT NOW at the cut: any question or problem awaiting the learner's answer, any commitment the agent has made that a later turn must honor, and the immediate next step.
Rules: no praise, no filler, no speculation beyond the transcript. Every claim about the learner must be traceable to what they actually said or answered. Keep every identifier (proposition ids, node ids, file paths) exact.
TRANSCRIPT TO SUMMARIZE:
${input.spanText}`;
}