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
+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;
}