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}`;
}
+10
View File
@@ -197,6 +197,16 @@ export const THINKING_LABEL = "Your thinking";
export const CONCLUDE_ACK_TEXT =
"Problem concluded — the composer is free; pose the next problem or wrap up.";
/** The compaction marker's model-facing header (owner round 14,
* 2026-10-05). The marker's content is this header + the summary — the
* single text the provider sees in place of the dropped span. Wording
* law: it must tell the model (a) a cut happened and is routine, (b) the
* summary replaces everything before it, (c) what follows is verbatim
* recent transcript — so the model neither re-asks covered ground nor
* distrusts the seam. */
export const COMPACTED_HISTORY_HEADER =
"[SESSION HISTORY COMPACTED] The transcript before this point was summarized to fit the context window; the summary below replaces it. Everything after this message is the verbatim recent transcript.";
export interface PlanBlockData {
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
+1
View File
@@ -40,6 +40,7 @@ export * from "./math-delims.ts";
// and the input-events contract — all
// pure logic, browser-safe.
export * from "./native-messages.ts";
export * from "./compaction.ts";
export * from "./input-events.ts";
// Wave D (owner-approved 2026-09-26): the client view model — the grouping
// canon (one continuous assistant document, step separators) ported from
+32 -4
View File
@@ -42,6 +42,7 @@ import {
THINKING_LABEL,
} from "./contracts/chat.ts";
import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts";
import { activeCompactionOf, keptIndexFor } from "./compaction.ts";
/** The hard-invariant wording for an aborted turn's unanswered calls —
* carried over verbatim from the pre-native era so the model's view
@@ -70,7 +71,8 @@ export interface LhMarkerMessage {
| "subagent-abort"
| "plan-block"
| "practice-thinking"
| "problem-dismissed";
| "problem-dismissed"
| "compaction";
/** The model-facing text ("" for plan blocks — UI-only). */
content: string;
display: boolean;
@@ -468,8 +470,29 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set<string> {
* through untouched rather than killing the loop.
*/
export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
const out: Message[] = [];
for (let i = 0; i < messages.length; i++) {
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
// (the newest — chained compactions rule last) names the first kept
// message; everything before it is replaced by the marker's content
// (header + summary) rendered ONCE, up top. The marker itself is then
// skipped in place — it must never render twice. A cut naming a
// missing id degrades honestly (everything renders — never throw).
const lhMessages = messages as readonly LhMessage[];
const active = activeCompactionOf(lhMessages);
let start = 0;
let head: Message | null = null;
if (active) {
const kept = keptIndexFor(lhMessages, active);
if (kept > 0) {
start = kept;
head = {
role: "user",
content: [{ type: "text", text: active.content }],
timestamp: active.timestamp,
};
}
}
const out: Message[] = head ? [head] : [];
for (let i = start; i < messages.length; i++) {
const m = messages[i]!;
const role = (m as { role?: string }).role;
if (role === "custom") {
@@ -477,10 +500,15 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// UI-only markers: plan blocks, and problem dismissals (the
// synthesized conclude pair carries the model's face).
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
// The ACTIVE compaction marker already rendered as the head —
// never twice. (Stale markers before the cut are unreachable; a
// marker whose cut could not resolve has no head and renders as
// its user text — honest degradation.)
if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
out.push({
role: "user",
content: [{ type: "text", text: marker.content }],
timestamp: marker.timestamp,
timestamp: marker.timestamp,
});
continue;
}