compaction: the usage-anchored trigger + visible session state (round 15)

The relational-databases incident, diagnosed and fixed:

TRIGGER — pi's estimator law, adopted exactly: anchor on the last
assistant message's provider-reported usage (totalTokens || the field
sum) and chars/4-estimate ONLY what was appended after it. The anchor
scan skips aborted/zero-usage assistants and STOPS at the newest
compaction marker (pre-cut usage described context the next request
will not carry — pi's distrust rule). The old estimator
(JSON.stringify of every message /4) tripled the true context
(measured: 84,412 'tokens' estimated vs 38,799 provider-reported on
the incident record) — scaffolding, escaped LaTeX, thinking blocks
and app-side details all counted. estimateMessageTokens now counts
wire text only (text + thinking + toolCall name/args; content text
for user/toolResult/custom). The unanchored fallback runs the plain
heuristic over the CUT-HONORED projection plus the additive prompt
term; the anchored path adds nothing (usage already billed prompt
and tools).

OBSERVABILITY — compaction is a visible session-wide state (owner
spec): the transcript-bottom placeholder register gains COMPACTING
CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin,
mutually exclusive by construction — the gate precedes beginTurn),
and the sidebar row shows 'Compacting context · <ago>' with the
spinner (sessionMeta outranks busy; compacting-changed joins
turn-started/turn-settled as refresh triggers). Liveness rides the
API like turn-busy: ChatListItem.compacting + compactionInFlight on
both GET /api/chats and GET /api/chats/[id] (initiallyCompacting
covers the navigate-back-mid-compaction mount — the event already
fired), exported through the server barrel.

Also: the relational-databases record rolled back to pre-compaction
(marker, post-marker turns, tree reset; backup in /tmp) for a clean
retest. The summarizer-usage 'gap' was a misread — steps[] was
populated all along.
This commit is contained in:
amosblomqvist
2026-10-06 00:38:15 +03:00
parent 6dcad55175
commit a2db80b72d
11 changed files with 247 additions and 21 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
import { deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -18,6 +18,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
...chat,
messages: chat.messages,
turnInFlight: isTurnRunning(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
});
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { chainsForSelection, formatChainLine } from "@/src/core";
import { newGraphContextMessage, type GraphContextPick } from "@/src/core";
import { createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server";
import { compactionInFlight, createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server";
import type { ChatListItem } from "@/src/core";
/** The session picker's list: record-derived summaries (phase, pending
@@ -14,6 +14,7 @@ export async function GET() {
const chats: ChatListItem[] = listChats().map((c) => ({
...c,
busy: isTurnRunning(c.id),
compacting: compactionInFlight(c.id),
}));
return Response.json({ chats });
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { Chat } from "@/components/chat";
import { slotsConfigured } from "@/src/core";
import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
export const dynamic = "force-dynamic";
@@ -34,6 +34,7 @@ export default async function ChatPage({
// A teacher turn that outlived its client: the remounted view must
// attach (busy shimmer + poll), never resurrect the stale record.
initiallyInFlight={isTurnRunning(chat.id)}
initiallyCompacting={compactionInFlight(chat.id)}
/>
);
}
+18 -1
View File
@@ -183,6 +183,7 @@ export function Chat({
initialMessages,
initialPrompt,
initiallyInFlight,
initiallyCompacting = false,
initialConfigured = false,
kind = "teach",
}: {
@@ -197,6 +198,11 @@ export function Chat({
/** A teacher turn is running server-side but attached to no client
* (navigated away mid-turn, returned while in flight). */
initiallyInFlight?: boolean;
/** A compaction holds the chat's model gate at mount (navigated away
* mid-compaction — the registry is live server state, the
* compaction-changed event already fired). The transcript spinner
* and send gates key on it. */
initiallyCompacting?: boolean;
/** slotsConfigured(server record) — the persistent setup CTA keys on
* it (BYOK, owner 2026-09-22). The server gate stays the truth; this
* only decides whether the pill rides the composer. */
@@ -623,7 +629,7 @@ export function Chat({
// flips it; the send paths, canSend, and the docks gate on it (the
// server's registry mutex is the structural gate; this is the
// affordance). The summary dialog state rides with it.
const [compacting, setCompacting] = useState(false);
const [compacting, setCompacting] = useState(initiallyCompacting);
const [compactionSummary, setCompactionSummary] = useState<CompactionDividerData | null>(null);
const canSend =
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
@@ -1425,6 +1431,17 @@ export function Chat({
provider-connect gap. turnInFlight: the same state
entered by external discovery (wake) / the poll
fallback. Never collapsible itself. */}
{/* Compaction (owner round 15): the same register as the
thinking placeholder — a visible session-wide state while
the summarizer holds the model gate. Mutually exclusive with
the THINKING placeholder by construction (the gate sits
BEFORE beginTurn; the settle kick runs after endTurn). */}
{compacting && (
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
<BrailleSpinner label="Compacting context" />
<Shimmer duration={1}>COMPACTING CONTEXT…</Shimmer>
</span>
)}
{(status === "submitted" || turnInFlight) && (
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
<BrailleSpinner label="Thinking" />
+3 -1
View File
@@ -243,6 +243,7 @@ export function timeAgo(iso: string, now = new Date()): string {
* still rides the API payload, so this is a one-line flip back if it's
* ever wanted. */
function sessionMeta(chat: ChatListItem): string {
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
@@ -284,7 +285,7 @@ export function SessionRow({
kbdSelected && !active && "bg-accent/60 ring-1 ring-border",
)}
>
<SessionStatus busy={chat.busy} />
<SessionStatus busy={chat.busy || chat.compacting} />
<span className="min-w-0 flex-1">
{/* Titles are the learner's/teacher's WORDS — Source Sans 3, the
same voice the words were typed in (every input is lh-prose);
@@ -486,6 +487,7 @@ function SessionsSidebar() {
useServerEvent("chats-changed", scheduleEventRefresh);
useServerEvent("turn-started", scheduleEventRefresh);
useServerEvent("turn-settled", scheduleEventRefresh);
useServerEvent("compaction-changed", scheduleEventRefresh);
useServerResync(() => void refreshRef.current());
useEffect(
() => () => {
+115 -10
View File
@@ -16,8 +16,11 @@
*
* 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).
* tail ~20k recent tokens (pi's defaults, owner-approved). Token
* estimation follows pi's law (2026-10-05 incident fix): anchor on the
* provider-reported usage of the last assistant message, chars/4 only
* for messages appended after that anchor; the heuristic alone runs
* only when no usage exists yet.
*
* Summary content (owner's three requirements + continuity): the
* learner's understanding and its EVOLUTION (quiz outcomes, confidence,
@@ -56,17 +59,119 @@ export const DEFAULT_COMPACTION_THRESHOLD_TOKENS = 150_000;
* 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) ───────────
// ─── token estimation (pi's law: usage-anchored, chars/4 only for the tail) ─
/** 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);
/** Context tokens from a provider-reported usage block — the most honest
* measure of "what the last request actually carried." Mirrors pi's
* calculateContextTokens. */
export function calculateContextTokens(usage: {
totalTokens?: number;
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
}): number {
return (
usage.totalTokens ||
(usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
);
}
/** Estimate a whole message list. */
/** Estimate a message's context tokens from its WIRE-relevant text only
* (pi's estimator): text + thinking + toolCall name/args for assistants,
* content text for user/toolResult/custom. NEVER JSON.stringify of the
* whole message — record scaffolding (ids, usage, responseId, app-side
* details, escaped quotes/backslashes) inflates ~3x (measured on the
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
export function estimateMessageTokens(message: LhMessage | AgentMessage): number {
const content: unknown = (message as { content?: unknown }).content;
let chars = 0;
if ((message as { role?: string }).role === "assistant") {
for (const block of (Array.isArray(content) ? content : []) as Array<{
type: string;
text?: string;
thinking?: string;
name?: string;
arguments?: unknown;
}>) {
if (block.type === "text") chars += block.text?.length ?? 0;
else if (block.type === "thinking") chars += block.thinking?.length ?? 0;
else if (block.type === "toolCall") chars += (block.name?.length ?? 0) + JSON.stringify(block.arguments ?? {}).length;
}
} else {
chars += textCharsOfContent(content);
}
return Math.ceil(chars / 4);
}
function textCharsOfContent(content: unknown): number {
if (typeof content === "string") return content.length;
if (!Array.isArray(content)) return 0;
let chars = 0;
for (const block of content as Array<string | { type?: string; text?: string }>) {
if (typeof block === "string") chars += block.length;
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
}
return chars;
}
/** The newest assistant message carrying VALID provider-reported usage
* (pi's rule: skip aborted messages and all-zero usage — they carry no
* truth about the wire). The scan STOPS at the newest compaction
* marker: usage captured before a compaction described the pre-cut
* context — the next request carries the summary + kept tail instead,
* so pre-marker usage is not a valid anchor (pi's "don't trust usage
* captured before a later compaction" law). Index into messages, or
* null. */
export function lastAssistantUsageIndex(
messages: readonly (LhMessage | AgentMessage)[],
): number | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
if ((m as { role?: string }).role !== "assistant") continue;
const a = m as AssistantMessage & { stopReason?: string };
if (a.stopReason === "aborted") continue;
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
return i;
}
return null;
}
export interface ContextTokenEstimate {
/** The estimate the trigger should compare against the threshold. */
tokens: number;
/** Provider-reported tokens from the usage anchor (0 when unanchored). */
usageTokens: number;
/** chars/4-estimated tokens after the anchor (or everything, unanchored). */
trailingTokens: number;
/** Index of the usage anchor, or null (diagnostics). */
lastUsageIndex: number | null;
}
/** THE trigger estimator (pi's estimateContextTokens): anchor on the last
* assistant usage the provider reported — prompt, tools, tokenizer
* density and all — and chars/4-estimate ONLY what was appended after
* it. The heuristic never re-estimates history the provider already
* counted (the relational-databases bug: full-record JSON.stringify/4
* tripled the true context and fired a 30k session at an 80k threshold). */
export function estimateContextTokens(
messages: readonly (LhMessage | AgentMessage)[],
): ContextTokenEstimate {
const anchor = lastAssistantUsageIndex(messages);
if (anchor === null) {
let trailing = 0;
for (const m of messages) trailing += estimateMessageTokens(m);
return { tokens: trailing, usageTokens: 0, trailingTokens: trailing, lastUsageIndex: null };
}
const anchored = messages[anchor]! as AssistantMessage;
const usageTokens = calculateContextTokens(anchored.usage as Parameters<typeof calculateContextTokens>[0]);
let trailing = 0;
for (let i = anchor + 1; i < messages.length; i++) trailing += estimateMessageTokens(messages[i]!);
return { tokens: usageTokens + trailing, usageTokens, trailingTokens: trailing, lastUsageIndex: anchor };
}
/** Estimate a whole message list (the plain heuristic — cut-walk use). */
export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]): number {
let total = 0;
for (const m of messages) total += estimateMessageTokens(m);
+5
View File
@@ -263,6 +263,11 @@ export interface ChatSummary {
* split at exactly the record/liveness seam. */
export interface ChatListItem extends ChatSummary {
busy: boolean;
/** Live compaction liveness (turn-liveness's twin): true while a
* background context compaction holds the chat's model gate. The
* route attaches it from the compaction registry — never
* record-derived (the marker persists, the RUN is ephemeral). */
compacting: boolean;
}
// ─── Artifact renderer registry (client extension point) ────────────────────
+13 -6
View File
@@ -34,6 +34,7 @@ import {
buildCompactionPrompt,
compactionCutIndex,
COMPACTION_RESERVE_TOKENS,
estimateContextTokens,
estimateTokens,
newCompactionMarker,
planTreeToText,
@@ -142,13 +143,19 @@ export function effectiveThresholdTokens(settingTokens: number | undefined, cont
// ─── the estimate over the ACTIVE projection ────────────────────────────────
/** The projected context estimate: what the next LLM call would carry —
* toLlmMessages output (cut honored) + the system prompt. The record
* path carries no leading system message (settleWith filters it), so
* the chrome rides as the additive term instead: prompt text here,
* tool declarations absorbed by the 16k reserve (the declarations are
* a few k and stable per session). chars/4. */
/** 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. */
export function projectedContextTokens(messages: readonly LhMessage[], systemPromptTokens: number): number {
const est = estimateContextTokens(messages);
if (est.lastUsageIndex !== null) return est.tokens;
return estimateTokens(toLlmMessages(messages as readonly AgentMessage[]) as unknown as readonly LhMessage[]) + systemPromptTokens;
}
+1
View File
@@ -38,6 +38,7 @@ export * from "./settings-body.ts";
export * from "./turn-liveness.ts";
export * from "./turn-runs.ts";
export * from "./dismissal-park.ts";
export * from "./compaction.ts";
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";
+85
View File
@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
activeCompactionOf,
calculateContextTokens,
estimateContextTokens,
estimateMessageTokens,
buildCompactionPrompt,
compactionCutIndex,
estimateTokens,
@@ -59,6 +62,88 @@ const resultFor = (callId: string, text: string): LhMessage & ToolResultMessage
const big = (id: string, kb = 8): LhMessage => newUserMessage(`${id}: ${"x".repeat(kb * 1024)}`);
// ─── the anchored estimator (pi's law — 2026-10-05 incident fix) ─────────────
describe("estimateContextTokens (usage-anchored, pi's law)", () => {
const usageAssistant = (id: string, total: number, trailing = false): LhMessage =>
({
id,
role: "assistant",
content: [{ type: "text", text: "ok" }],
usage: { input: total - 10, output: 10, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: trailing ? "aborted" : "toolUse",
timestamp: 1,
}) as never;
it("anchors on the last assistant's provider-reported usage and chars/4-estimates ONLY the tail", () => {
const msgs = [
newUserMessage(`${"x".repeat(400_000)}`), // 100k chars of DROPPED-span text
usageAssistant("a1", 30_000),
newUserMessage("y".repeat(4_000)), // 1k estimated tokens AFTER the anchor
] as LhMessage[];
// ^ newUserMessage signature: (text, ts?) — second arg timestamp
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex !== null, true);
assert.equal(est.usageTokens, 30_000);
assert.equal(est.trailingTokens, 1_000);
assert.equal(est.tokens, 31_000); // the 100k chars before the anchor are NOT re-estimated
});
it("skips aborted and all-zero-usage assistants when hunting the anchor", () => {
const msgs = [
usageAssistant("a1", 30_000),
usageAssistant("a2", 99_999, true), // aborted after the real one
{ ...usageAssistant("a3", 0), stopReason: "toolUse" }, // zero usage
] as LhMessage[];
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex, 0);
assert.equal(est.tokens, 30_000 + estimateMessageTokens(msgs[1]!) + estimateMessageTokens(msgs[2]!));
});
it("refuses anchors older than the newest compaction marker (pre-cut usage is not truth)", () => {
const marker = newCompactionMarker("summary text", {
firstKeptMessageId: "m1",
tokensBefore: 500_000,
at: "2026-10-05T00:00:00.000Z",
});
const msgs = [usageAssistant("a1", 500_000), marker as LhMessage, newUserMessage("kept tail")] as LhMessage[];
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex, null, "the marker invalidates every anchor behind it");
});
it("unanchored (no usage at all): the plain heuristic over everything", () => {
const msgs = [big("m1", 4), big("m2", 4)] as LhMessage[]; // 2 × 4,116ish chars
const est = estimateContextTokens(msgs);
assert.equal(est.lastUsageIndex, null);
assert.equal(est.tokens, estimateTokens(msgs));
});
});
describe("estimateMessageTokens (wire text only — the 3x-inflation fix)", () => {
it("assistant: text + thinking + toolCall name/args — scaffolding never counted", () => {
const m = {
id: "a1",
role: "assistant",
content: [
{ type: "text", text: "x".repeat(400) },
{ type: "thinking", thinking: "y".repeat(800), thinkingSignature: "sig" },
{ type: "toolCall", id: "c1", name: "quiz", arguments: { q: "z".repeat(200) } },
],
usage: { input: 9_999_999, output: 1, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 10_000_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
responseId: "r".repeat(300),
timestamp: 1,
} as never as LhMessage;
const expected = Math.ceil((400 + 800 + ("quiz".length + JSON.stringify({ q: "z".repeat(200) }).length)) / 4);
assert.equal(estimateMessageTokens(m), expected); // usage/responseId/signature inflate nothing
});
it("calculateContextTokens: totalTokens wins, else the field sum", () => {
assert.equal(calculateContextTokens({ totalTokens: 42, input: 1, output: 2 }), 42);
assert.equal(calculateContextTokens({ input: 10, output: 5, cacheRead: 3, cacheWrite: 2 }), 20);
assert.equal(calculateContextTokens({}), 0);
});
});
// ─── the cut walk ────────────────────────────────────────────────────────────
describe("compactionCutIndex", () => {
+1
View File
@@ -29,6 +29,7 @@ function item(overrides: Partial<ChatListItem>): ChatListItem {
pendingQuestion: false,
pendingProblem: false,
busy: false,
compacting: false,
...overrides,
};
}