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)}
/>
);
}