diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 47d543a..9ae3fda 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -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), }); } diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index e908857..a498a79 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -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 }); } diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 8d012e0..9f35d80 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -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)} /> ); } diff --git a/components/chat.tsx b/components/chat.tsx index b392856..3530ab7 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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(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 && ( + + + COMPACTING CONTEXT… + + )} {(status === "submitted" || turnInFlight) && ( diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 3bee27c..0cabe1a 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -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", )} > - + {/* 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( () => () => { diff --git a/src/core/compaction.ts b/src/core/compaction.ts index b6e9663..85bc682 100644 --- a/src/core/compaction.ts +++ b/src/core/compaction.ts @@ -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) { + 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[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[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); diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 34d6e2d..f0bd487 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -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) ──────────────────── diff --git a/src/server/compaction.ts b/src/server/compaction.ts index 7fbee10..8cb775e 100644 --- a/src/server/compaction.ts +++ b/src/server/compaction.ts @@ -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; } diff --git a/src/server/index.ts b/src/server/index.ts index 9979cb8..c614f91 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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"; diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts index 56957e9..d673710 100644 --- a/tests/compaction.test.ts +++ b/tests/compaction.test.ts @@ -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", () => { diff --git a/tests/render-sidebar.test.tsx b/tests/render-sidebar.test.tsx index 25ca313..ae306a9 100644 --- a/tests/render-sidebar.test.tsx +++ b/tests/render-sidebar.test.tsx @@ -29,6 +29,7 @@ function item(overrides: Partial): ChatListItem { pendingQuestion: false, pendingProblem: false, busy: false, + compacting: false, ...overrides, }; }