From 2dfd0e278b7b57224649268f027c52dfee1616cb Mon Sep 17 00:00:00 2001 From: Leo Martens Date: Thu, 30 Jul 2026 19:21:20 +0200 Subject: [PATCH 1/7] fix(cache): report history-image hash from the history message, not messages[0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `historyImageSha8` hashed the image blocks on `messages[0]`, described in its own docstring as "the synthetic history message". It is not: whenever a slab anchor exists, `collapseHistory` returns `[...head, syntheticUser, ...tail]` and transform.ts passes `protectedPrefix = slabAnchorIdx + 1`, so on every Claude Code request `messages[0]` is the protected slab message. The field that exists to prove history-image byte-identity was therefore reporting SLAB stability under the history name — a drifting collapse boundary still showed a rock-steady `history_image_sha8` in events.jsonl, which is precisely the signal #11 attribution relies on. Locate the synthetic message by its banner, the same way `cachePrefixDigest` does. Also split the pinned-prefix digest per layer. `cache_prefix_sha8` proves THAT the cacheable prefix moved but never WHICH layer moved, and the layers fail for different reasons and need different fixes: tools drift when the client loads a deferred tool, system drifts when volatile text (env, git status) rides inside the pinned span, and the imaged head drifts when a collapse boundary or marker placement moves. Emit `cache_prefix_tools_sha8` / `cache_prefix_system_sha8` / `cache_prefix_head_sha8` alongside the aggregate so one session of telemetry names the culprit instead of narrowing it to "somewhere in the prefix". Motivation: a production session showed 530/530 requests with a unique `cache_prefix_sha8` at a constant `cache_prefix_bytes`, 0 cache_read across 87M cache_create tokens — with the aggregate hash alone the cause could not be attributed, and `history_image_sha8` looked stable because it was measuring the wrong message. (cherry picked from commit fa7314339840518393a478568e7f6da59765e7ba) --- src/core/tracker.ts | 10 +++ src/core/transform.ts | 66 +++++++++++--- tests/cache-bust-attribution.test.ts | 127 +++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 tests/cache-bust-attribution.test.ts diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 4c59b7754..68cc771a1 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -98,6 +98,13 @@ export interface TrackEvent { cache_prefix_sha8?: string; /** Approx chars in that pinned prefix (growth vs pure-invalidation split). */ cache_prefix_bytes?: number; + /** Per-layer digests of the same pinned prefix. Whichever one moves between + * two turns of a session IS the cache-bust cause: tools (client loaded a + * deferred tool), system (volatile text inside the pinned span), head + * (collapse boundary / marker placement moved). */ + cache_prefix_tools_sha8?: string; + cache_prefix_system_sha8?: string; + cache_prefix_head_sha8?: string; // From TransformInfo.env: cwd?: string; @@ -278,6 +285,9 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { } if (info.cachePrefixSha8) out.cache_prefix_sha8 = info.cachePrefixSha8; if (info.cachePrefixBytes !== undefined) out.cache_prefix_bytes = info.cachePrefixBytes; + if (info.cachePrefixToolsSha8) out.cache_prefix_tools_sha8 = info.cachePrefixToolsSha8; + if (info.cachePrefixSystemSha8) out.cache_prefix_system_sha8 = info.cachePrefixSystemSha8; + if (info.cachePrefixHeadSha8) out.cache_prefix_head_sha8 = info.cachePrefixHeadSha8; if (info.unknownStaticTags && info.unknownStaticTags.length > 0) out.unknown_static_tags = info.unknownStaticTags; if (info.churningStaticTags && info.churningStaticTags.length > 0) diff --git a/src/core/transform.ts b/src/core/transform.ts index 6d78b0bf5..d7e973447 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -684,6 +684,13 @@ export interface TransformInfo { /** Approx size (chars) of that cached prefix — pairs with cachePrefixSha8 so a * bust reads as growth (size up) vs pure invalidation (size unchanged). */ cachePrefixBytes?: number; + /** Per-layer digests of that same pinned prefix, in wire order: tool + * definitions, system blocks, and the imaged head (messages up to and + * including the history/slab boundary). Exactly one of these moving names + * the cache-bust culprit; the aggregate cachePrefixSha8 alone cannot. */ + cachePrefixToolsSha8?: string; + cachePrefixSystemSha8?: string; + cachePrefixHeadSha8?: string; /** Why the history collapse didn't run (or did). Diagnostic only. */ historyReason?: | 'no_history' @@ -1016,13 +1023,27 @@ async function recordRecoverable( }); } -/** Hash the concatenated base64 of every image block on `messages[0]` (the synthetic - * history message). Stable across the quantized collapse window → proves Anthropic - * can cache_read the history prefix. Returns undefined if no images on messages[0]. */ +/** Hash the concatenated base64 of every image block on the synthetic history + * message. Stable across the quantized collapse window → proves Anthropic can + * cache_read the history prefix. Returns undefined if there is no such message. + * + * The synthetic message is NOT `messages[0]` whenever a slab anchor exists: + * collapseHistory returns `[...head, syntheticUser, ...tail]` and transform.ts + * passes `protectedPrefix = slabAnchorIdx + 1`, so on a Claude Code request + * `messages[0]` is the protected slab message. Hashing it reported SLAB image + * stability under the `history_image_sha8` name — the one field meant to prove + * history-image byte-identity was blind to the history images, so a drifting + * collapse boundary still looked stable in telemetry (#11 attribution). Locate + * the message by its banner instead, exactly like cachePrefixDigest does. */ async function historyImageSha8( messages: Message[], ): Promise { - const synthetic = messages[0]; + const synthetic = messages.find( + (m) => + Array.isArray(m.content) && + (m.content[0] as TextBlock | undefined)?.type === 'text' && + (m.content[0] as TextBlock).text === HISTORY_SYNTHETIC_INTRO, + ); if (!synthetic || !Array.isArray(synthetic.content)) return undefined; let concat = ''; for (const blk of synthetic.content) { @@ -1110,7 +1131,10 @@ function relocateAnchorToHistoryImage(messages: Message[] | undefined, anchorOrd */ async function cachePrefixDigest( req: { tools?: unknown; system?: unknown; messages?: unknown }, -): Promise<{ sha8: string; bytes: number } | undefined> { +): Promise< + | { sha8: string; bytes: number; toolsSha8: string; systemSha8: string; headSha8: string } + | undefined +> { const msgs = Array.isArray(req.messages) ? (req.messages as Message[]) : []; // Boundary = latest message carrying pxpipe's imaged prefix: the history image // (banner) when collapse ran, else the slab message ('[End of rendered @@ -1127,19 +1151,34 @@ async function cachePrefixDigest( if (isHistory || hasSlab) boundary = i; } if (boundary < 0) return undefined; // not an imaged-prefix shape — nothing pinned - const parts: string[] = []; - if (Array.isArray(req.tools)) for (const t of req.tools) parts.push(JSON.stringify(t)); + // Component digests, in wire order. A whole-prefix hash proves THAT the cache + // busted but never WHICH layer moved, and the layers fail for different + // reasons: tools drift when the client loads a deferred tool, system drifts + // when volatile text (env/git status) rides inside the pinned span, and the + // imaged head drifts when a collapse boundary or marker placement moves. + // Hashing each separately turns "prefix changed" into a one-line diagnosis. + const toolParts: string[] = []; + if (Array.isArray(req.tools)) for (const t of req.tools) toolParts.push(JSON.stringify(t)); + const sysParts: string[] = []; const sys = req.system; - if (typeof sys === 'string') parts.push(sys); - else if (Array.isArray(sys)) for (const b of sys) parts.push(JSON.stringify(b)); + if (typeof sys === 'string') sysParts.push(sys); + else if (Array.isArray(sys)) for (const b of sys) sysParts.push(JSON.stringify(b)); + const headParts: string[] = []; for (let i = 0; i <= boundary; i++) { const content = msgs[i]?.content; - if (typeof content === 'string') parts.push(content); + if (typeof content === 'string') headParts.push(content); else if (Array.isArray(content)) - for (const b of content) parts.push(typeof b === 'string' ? b : JSON.stringify(b)); + for (const b of content) headParts.push(typeof b === 'string' ? b : JSON.stringify(b)); } + const parts = [...toolParts, ...sysParts, ...headParts]; const prefix = parts.join('\x00'); - return { sha8: await sha8(prefix), bytes: prefix.length }; + return { + sha8: await sha8(prefix), + bytes: prefix.length, + toolsSha8: await sha8(toolParts.join('\x00')), + systemSha8: await sha8(sysParts.join('\x00')), + headSha8: await sha8(headParts.join('\x00')), + }; } // Removed: extractClaudeMdSlab(). It scanned the static system text for @@ -2398,6 +2437,9 @@ export async function transformRequest( if (pfx) { info.cachePrefixSha8 = pfx.sha8; info.cachePrefixBytes = pfx.bytes; + info.cachePrefixToolsSha8 = pfx.toolsSha8; + info.cachePrefixSystemSha8 = pfx.systemSha8; + info.cachePrefixHeadSha8 = pfx.headSha8; } } // Top dropped codepoints, capped at 20 entries to bound JSONL row size. diff --git a/tests/cache-bust-attribution.test.ts b/tests/cache-bust-attribution.test.ts new file mode 100644 index 000000000..5e9db716c --- /dev/null +++ b/tests/cache-bust-attribution.test.ts @@ -0,0 +1,127 @@ +/** + * Cache-bust ATTRIBUTION telemetry (#11). + * + * When cache_read collapses in production, these fields are the only evidence + * available after the fact. Two contracts: + * + * 1. `history_image_sha8` must describe the HISTORY images. On a Claude Code + * request the slab anchor makes `messages[0]` the protected slab message, + * not the synthetic history message — hashing index 0 silently reported + * slab stability under the history name, so a drifting collapse boundary + * looked stable in telemetry. + * 2. The pinned prefix must be digested per LAYER (tools / system / imaged + * head), because "the prefix changed" does not say which layer moved, and + * the three fail for different reasons and need different fixes. + * + * Run just this file: pnpm vitest run tests/cache-bust-attribution.test.ts + */ +import { describe, expect, it } from 'vitest'; +import { transformRequest } from '../src/core/transform.js'; +import { HISTORY_SYNTHETIC_INTRO } from '../src/core/history.js'; +import type { Message } from '../src/core/types.js'; + +const big = (n: number) => 'x'.repeat(n); +const enc = (obj: unknown) => new TextEncoder().encode(JSON.stringify(obj)); +const dec = (b: Uint8Array): any => JSON.parse(new TextDecoder().decode(b)); + +/** N closed plain turns — long enough that the collapse gate accepts. */ +function convo(n: number, chars = 3500): Message[] { + const out: Message[] = []; + for (let i = 0; i < n; i++) { + const body = `turn ${i}: ` + big(chars); + out.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: body }); + } + return out; +} + +/** A Claude-Code-shaped request: big marked system slab + a long conversation. */ +function ccBody(opts: { turns?: number; tools?: unknown[]; sysSuffix?: string } = {}) { + return enc({ + model: 'claude-3-5-sonnet', + system: [ + { + type: 'text', + text: big(80_000) + (opts.sysSuffix ?? ''), + cache_control: { type: 'ephemeral' }, + }, + ], + ...(opts.tools ? { tools: opts.tools } : {}), + messages: convo(opts.turns ?? 15), + }); +} + +/** Concatenated base64 of the image blocks on the synthetic history message. */ +function historyImageData(out: Uint8Array): string { + const body = dec(out); + const synthetic = (body.messages ?? []).find( + (m: any) => Array.isArray(m.content) && m.content[0]?.type === 'text' && m.content[0].text === HISTORY_SYNTHETIC_INTRO, + ); + if (!synthetic) return ''; + return synthetic.content + .filter((b: any) => b?.type === 'image') + .map((b: any) => b.source.data) + .join(''); +} + +/** Concatenated base64 of the image blocks on the FIRST message (the slab). */ +function slabImageData(out: Uint8Array): string { + const first = dec(out).messages?.[0]; + if (!first || !Array.isArray(first.content)) return ''; + return first.content + .filter((b: any) => b?.type === 'image') + .map((b: any) => b.source.data) + .join(''); +} + +describe('cache-bust attribution telemetry', () => { + it('history_image_sha8 tracks the history images, not the slab message at index 0', async () => { + const { body: out, info } = await transformRequest(ccBody()); + // Precondition: this really is the Claude Code shape — a slab message ahead + // of the synthetic history message, both carrying images. + const slab = slabImageData(out); + const history = historyImageData(out); + expect(info.collapsedTurns).toBeGreaterThan(0); + expect(slab.length).toBeGreaterThan(0); + expect(history.length).toBeGreaterThan(0); + expect(history).not.toBe(slab); + + // The reported hash must be a function of the HISTORY images. Prove it by + // changing only the history (more collapsed turns) and requiring the hash + // to move, while the slab bytes stay identical. + const { body: out2, info: info2 } = await transformRequest(ccBody({ turns: 65 })); + expect(slabImageData(out2)).toBe(slab); // slab unchanged … + expect(historyImageData(out2)).not.toBe(history); // … history changed … + expect(info2.historyImageSha).not.toBe(info.historyImageSha); // … so must the hash + }); + + it('digests the pinned prefix per layer (tools / system / head)', async () => { + const { info } = await transformRequest(ccBody()); + expect(info.cachePrefixSha8).toBeDefined(); + expect(info.cachePrefixToolsSha8).toBeDefined(); + expect(info.cachePrefixSystemSha8).toBeDefined(); + expect(info.cachePrefixHeadSha8).toBeDefined(); + }); + + it('moves ONLY the tools digest when the client adds a tool', async () => { + const toolsA = [{ name: 'Read', description: 'Read a file. ' + big(400), input_schema: { type: 'object' } }]; + const toolsB = [ + ...toolsA, + { name: 'Grep', description: 'Search. ' + big(400), input_schema: { type: 'object' } }, + ]; + const a = await transformRequest(ccBody({ tools: toolsA })); + const b = await transformRequest(ccBody({ tools: toolsB })); + expect(b.info.cachePrefixToolsSha8).not.toBe(a.info.cachePrefixToolsSha8); + expect(b.info.cachePrefixSystemSha8).toBe(a.info.cachePrefixSystemSha8); + expect(b.info.cachePrefixSha8).not.toBe(a.info.cachePrefixSha8); + }); + + it('moves the system digest when volatile text rides inside the pinned span', async () => { + // `# Environment` churns every turn (cwd, git status, model id). Whatever + // survives as system TEXT is inside the pinned prefix and must show up as a + // system-layer bust — that is the signal a live capture needs. + const a = await transformRequest(ccBody({ sysSuffix: '\n# Environment\ngit status: clean\n' })); + const b = await transformRequest(ccBody({ sysSuffix: '\n# Environment\ngit status: 3 files changed\n' })); + expect(b.info.cachePrefixSystemSha8).not.toBe(a.info.cachePrefixSystemSha8); + expect(b.info.cachePrefixToolsSha8).toBe(a.info.cachePrefixToolsSha8); + }); +}); From fec6f65779263b85aa7fc8a76e203233867cfaa4 Mon Sep 17 00:00:00 2001 From: Leo Martens Date: Thu, 30 Jul 2026 19:23:45 +0200 Subject: [PATCH 2/7] fix(cache): digest the MARKED span, not the boundary message, for bust attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cachePrefixDigest` hashed every block up to and including the message that carries pxpipe's imaged prefix. That is not the span Anthropic caches: caching ends at the LAST cache_control marker, and after a history collapse the two differ by construction. The synthetic message's newest freeze chunk re-renders on every turn BY DESIGN (append-only rendering: only completed chunks are frozen) and it sits AFTER the pinned marker — so the boundary-scoped digest changes every single turn even when the cached span is byte-stable, and `cache_prefix_sha8` reads "pxpipe busted its own cache" on healthy traffic. Emit `cache_prefix_marked_sha8` / `_marked_bytes` / `_marker_pos` alongside: the digest through the breakpoint, its size, and where the breakpoint sits as `m.b`. The marked digest is the one that must hold turn over turn; the marker position makes a roaming breakpoint — a bust cause in its own right, previously invisible — a one-field diagnosis. Tests pin the contract that decides whether cache_read happens at all: two consecutive turns of one session keep the marked span byte-identical while the live tail grows. (cherry picked from commit d7b865542b8b5aeda1bc8634254e946c91c58cad) --- src/core/tracker.ts | 10 +++++ src/core/transform.ts | 60 +++++++++++++++++++++++++++- tests/cache-bust-attribution.test.ts | 21 ++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 68cc771a1..8f44ef87d 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -105,6 +105,12 @@ export interface TrackEvent { cache_prefix_tools_sha8?: string; cache_prefix_system_sha8?: string; cache_prefix_head_sha8?: string; + /** The span Anthropic really caches (through the last cache_control marker), + * its size, and the marker's position. Unstable marked digest ⇒ pxpipe-side + * bust; stable digest with cache_read 0 ⇒ look upstream, not at the rewrite. */ + cache_prefix_marked_sha8?: string; + cache_prefix_marked_bytes?: number; + cache_prefix_marker_pos?: string; // From TransformInfo.env: cwd?: string; @@ -288,6 +294,10 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { if (info.cachePrefixToolsSha8) out.cache_prefix_tools_sha8 = info.cachePrefixToolsSha8; if (info.cachePrefixSystemSha8) out.cache_prefix_system_sha8 = info.cachePrefixSystemSha8; if (info.cachePrefixHeadSha8) out.cache_prefix_head_sha8 = info.cachePrefixHeadSha8; + if (info.cachePrefixMarkedSha8) out.cache_prefix_marked_sha8 = info.cachePrefixMarkedSha8; + if (info.cachePrefixMarkedBytes !== undefined) + out.cache_prefix_marked_bytes = info.cachePrefixMarkedBytes; + if (info.cachePrefixMarkerPos) out.cache_prefix_marker_pos = info.cachePrefixMarkerPos; if (info.unknownStaticTags && info.unknownStaticTags.length > 0) out.unknown_static_tags = info.unknownStaticTags; if (info.churningStaticTags && info.churningStaticTags.length > 0) diff --git a/src/core/transform.ts b/src/core/transform.ts index d7e973447..6d77114a9 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -691,6 +691,16 @@ export interface TransformInfo { cachePrefixToolsSha8?: string; cachePrefixSystemSha8?: string; cachePrefixHeadSha8?: string; + /** Digest of the span Anthropic actually caches: everything up to and + * including the LAST cache_control marker. After a collapse this is a strict + * subset of the boundary-scoped prefix — the newest freeze chunk re-renders + * every turn by design and sits after the marker — so THIS is the digest that + * must stay stable turn over turn, and the boundary one is context. */ + cachePrefixMarkedSha8?: string; + cachePrefixMarkedBytes?: number; + /** Where that last marker sits, as `m.b`. A marker that + * roams between turns re-cuts the cached span and busts it on its own. */ + cachePrefixMarkerPos?: string; /** Why the history collapse didn't run (or did). Diagnostic only. */ historyReason?: | 'no_history' @@ -1132,7 +1142,16 @@ function relocateAnchorToHistoryImage(messages: Message[] | undefined, anchorOrd async function cachePrefixDigest( req: { tools?: unknown; system?: unknown; messages?: unknown }, ): Promise< - | { sha8: string; bytes: number; toolsSha8: string; systemSha8: string; headSha8: string } + | { + sha8: string; + bytes: number; + toolsSha8: string; + systemSha8: string; + headSha8: string; + markedSha8: string; + markedBytes: number; + markerPos: string; + } | undefined > { const msgs = Array.isArray(req.messages) ? (req.messages as Message[]) : []; @@ -1170,6 +1189,39 @@ async function cachePrefixDigest( else if (Array.isArray(content)) for (const b of content) headParts.push(typeof b === 'string' ? b : JSON.stringify(b)); } + // The span Anthropic actually caches ends at the LAST cache_control marker — + // not at the message boundary above. Those differ by construction after a + // collapse: the synthetic message's newest freeze chunk re-renders every turn + // BY DESIGN and sits AFTER the pinned marker, so the boundary-scoped digest + // reports a bust on every single turn even when the cached span is perfectly + // stable. Digest the marker-scoped span too, and record where the marker sits: + // a moving marker is itself a bust cause, and telemetry could not see it. + let markPos = ''; + const markedParts: string[] = [...toolParts, ...sysParts]; + const markedUpTo: string[] = []; + outer: for (let i = msgs.length - 1; i >= 0; i--) { + const content = msgs[i]?.content; + if (!Array.isArray(content)) continue; + for (let k = content.length - 1; k >= 0; k--) { + const b = content[k] as { cache_control?: unknown } | undefined; + if (b && typeof b === 'object' && b.cache_control !== undefined) { + markPos = `m${i}.b${k}`; + for (let mi = 0; mi <= i; mi++) { + const c = msgs[mi]?.content; + if (typeof c === 'string') markedUpTo.push(c); + else if (Array.isArray(c)) + for (let bk = 0; bk < c.length; bk++) { + if (mi === i && bk > k) break; + const blk = c[bk]; + markedUpTo.push(typeof blk === 'string' ? blk : JSON.stringify(blk)); + } + } + break outer; + } + } + } + markedParts.push(...markedUpTo); + const marked = markedParts.join('\x00'); const parts = [...toolParts, ...sysParts, ...headParts]; const prefix = parts.join('\x00'); return { @@ -1178,6 +1230,9 @@ async function cachePrefixDigest( toolsSha8: await sha8(toolParts.join('\x00')), systemSha8: await sha8(sysParts.join('\x00')), headSha8: await sha8(headParts.join('\x00')), + markedSha8: await sha8(marked), + markedBytes: marked.length, + markerPos: markPos, }; } @@ -2440,6 +2495,9 @@ export async function transformRequest( info.cachePrefixToolsSha8 = pfx.toolsSha8; info.cachePrefixSystemSha8 = pfx.systemSha8; info.cachePrefixHeadSha8 = pfx.headSha8; + info.cachePrefixMarkedSha8 = pfx.markedSha8; + info.cachePrefixMarkedBytes = pfx.markedBytes; + if (pfx.markerPos) info.cachePrefixMarkerPos = pfx.markerPos; } } // Top dropped codepoints, capped at 20 entries to bound JSONL row size. diff --git a/tests/cache-bust-attribution.test.ts b/tests/cache-bust-attribution.test.ts index 5e9db716c..b6bc42d4f 100644 --- a/tests/cache-bust-attribution.test.ts +++ b/tests/cache-bust-attribution.test.ts @@ -115,6 +115,27 @@ describe('cache-bust attribution telemetry', () => { expect(b.info.cachePrefixSha8).not.toBe(a.info.cachePrefixSha8); }); + it('digests the MARKED span (what Anthropic caches) and records the marker position', async () => { + const { info } = await transformRequest(ccBody()); + expect(info.cachePrefixMarkedSha8).toBeDefined(); + expect(info.cachePrefixMarkerPos).toMatch(/^m\d+\.b\d+$/); + // The marked span ends at the breakpoint, so it is a strict subset of the + // boundary-scoped prefix (which runs to the end of the history message). + expect(info.cachePrefixMarkedBytes!).toBeLessThanOrEqual(info.cachePrefixBytes!); + }); + + it('keeps the marked span byte-identical while the live tail grows', async () => { + // The contract that decides whether cache_read happens: two consecutive + // turns of one session must send the SAME bytes up to the breakpoint. The + // newest freeze chunk re-renders by design, so a boundary-scoped digest may + // legitimately move — the marked one may not. + const a = await transformRequest(ccBody({ turns: 15 })); + const b = await transformRequest(ccBody({ turns: 17 })); + expect(b.info.collapsedTurns).toBe(a.info.collapsedTurns); // same collapse window + expect(b.info.cachePrefixMarkerPos).toBe(a.info.cachePrefixMarkerPos); + expect(b.info.cachePrefixMarkedSha8).toBe(a.info.cachePrefixMarkedSha8); + }); + it('moves the system digest when volatile text rides inside the pinned span', async () => { // `# Environment` churns every turn (cwd, git status, model id). Whatever // survives as system TEXT is inside the pinned prefix and must show up as a From ff8b95f4eb4fc2ffb5d123a4e501d24fdff504a4 Mon Sep 17 00:00:00 2001 From: Leo Martens Date: Thu, 30 Jul 2026 21:31:17 +0200 Subject: [PATCH 3/7] fix(dashboard): divide the by-events cache-hit rate by a field that exists renderStatsTableFragment read s.eventsWithBaseline, but stats.ts emits eventsWithUsage; the missing field made the comparison false for every session, so the "cache hit (by events)" row silently rendered "-" no matter what the cache did. The type carried the stale name too, so tsc had no chance to catch it. Test pins the value to its own row (the table renders as one line, so a bare toContain passes on a neighbouring number) and fails with '-' against the old expression. (cherry picked from commit c6c5db6c2916b89cc5ef8114adbdbe02f5acd383) --- src/dashboard/fragments.ts | 2 +- src/dashboard/types.ts | 4 +++- tests/dashboard-api.test.ts | 38 ++++++++++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/dashboard/fragments.ts b/src/dashboard/fragments.ts index f875e9067..2d8fe88ac 100644 --- a/src/dashboard/fragments.ts +++ b/src/dashboard/fragments.ts @@ -766,7 +766,7 @@ export function renderStatsTableFragment(p: FullStatsPayload): string { const totalIn = (s.inputTokensTotal || 0) + (s.cacheCreateTokensTotal || 0) + (s.cacheReadTokensTotal || 0); const hitRateTok = totalIn > 0 ? ((s.cacheReadTokensTotal / totalIn) * 100).toFixed(1) + '%' : '-'; const hitRateEv = - s.eventsWithBaseline > 0 ? ((s.cacheHitEvents / s.eventsWithBaseline) * 100).toFixed(1) + '%' : '-'; + s.eventsWithUsage > 0 ? ((s.cacheHitEvents / s.eventsWithUsage) * 100).toFixed(1) + '%' : '-'; const charRatio = s.origCharsTotal > 0 ? ((s.imageBytesTotal / s.origCharsTotal) * 100).toFixed(3) + 'x' : '-'; diff --git a/src/dashboard/types.ts b/src/dashboard/types.ts index f2090c0d6..7a44053b5 100644 --- a/src/dashboard/types.ts +++ b/src/dashboard/types.ts @@ -132,7 +132,9 @@ export interface FullStatsSummary { cacheReadTokensTotal: number; outputTokensTotal: number; cacheHitEvents: number; - eventsWithBaseline: number; + // Denominator for the event-based cache-hit rate: events that carried usage + // data at all. Emitted by stats.ts as `eventsWithUsage`. + eventsWithUsage: number; origCharsTotal: number; imageBytesTotal: number; pinCharsTotal?: number; diff --git a/tests/dashboard-api.test.ts b/tests/dashboard-api.test.ts index 74756b880..4ec2fa9b2 100644 --- a/tests/dashboard-api.test.ts +++ b/tests/dashboard-api.test.ts @@ -15,7 +15,11 @@ import { getAllowedModelBases, setAllowedModelBases } from '../src/core/applicab import type { SessionsPaths } from '../src/sessions.js'; import type { TrackEvent } from '../src/core/tracker.js'; import type { StatsPayload, RecentPayload } from '../src/dashboard/types.js'; -import { renderHeaderFragment, renderPage } from '../src/dashboard/fragments.js'; +import { + renderHeaderFragment, + renderPage, + renderStatsTableFragment, +} from '../src/dashboard/fragments.js'; function makeTmp(): SessionsPaths { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pxpipe-dashapi-')); @@ -1004,3 +1008,35 @@ describe('server-observed warmth: text follows actual cache_read', () => { expect(row.session_saved_so_far_delta).toBe(9900); }); }); + +/** + * Regression: the dashboard's "cache hit rate (by events)" row read a summary + * field (`eventsWithBaseline`) that stats.ts never emitted — it emits + * `eventsWithUsage`. The field existed in the payload *type*, so tsc stayed + * quiet, and the row silently rendered "-" forever. This test walks the real + * path (fold -> summaryToJson -> renderStatsTableFragment) so any future + * rename on either side fails here instead of blanking the dashboard. + */ +describe('stats table: event-based cache hit rate', () => { + it('renders a real percentage from the summary stats.ts actually emits', async () => { + const { newSummary, fold, summaryToJson } = await import('../src/stats.js'); + let s = newSummary(); + // 3 events with usage, 2 of them cache hits -> 66.7% + s = fold(s, { input_tokens: 100, cache_read_tokens: 900 } as TrackEvent); + s = fold(s, { input_tokens: 100, cache_read_tokens: 900 } as TrackEvent); + s = fold(s, { input_tokens: 100, cache_read_tokens: 0 } as TrackEvent); + // an event carrying no usage at all must not dilute the denominator + s = fold(s, { cwd: '/tmp' } as TrackEvent); + + const summary = summaryToJson(s); + expect(summary.eventsWithUsage).toBe(3); + expect(summary.cacheHitEvents).toBe(2); + + const html = renderStatsTableFragment({ summary } as never); + // Pin the value to its own row: the table is a single line, so a bare + // "contains" would also pass on some other row's number. + const evRow = /cache hit \(by events\)<\/td>([^<]*)<\/td>/.exec(html); + expect(evRow, 'the "cache hit (by events)" row must exist').not.toBeNull(); + expect(evRow![1]).toBe('66.7%'); // not '-', which is what the dead field produced + }); +}); From 3ce2b987eac7174f974fc737f89566689b6cf67c Mon Sep 17 00:00:00 2001 From: Leo Martens Date: Sat, 1 Aug 2026 20:47:43 +0200 Subject: [PATCH 4/7] fix(images): price the client's own images against the provider cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 100-image limit is a property of the wire, not of pxpipe: it counts the images the client sent (pasted screenshots, pictures a tool returned) together with every image we add. We priced only our own, so a request that arrived already full got imaged further and came back 400/500 — two sessions became unresumable. Every imaging path now spends from one shared headroom: imageHeadroom() = cap - margin - ours - theirs and the caller's images are counted once, before any rewrite, at both nesting levels (top-level and inside tool_result). The gate is quantitative, not boolean. A first cut asked "is there ANY room left?" and then emitted a whole 15-page slab into it: 94 client images + a 400k slab put 109 images on the wire. Now: - the slab must fit whole or not at all — imaging half of it would re-key the cache prefix on every turn whose client-image count moved; - each tool_result pages against the LIVE headroom, not a fixed 10; - after rendering we verify the real page count, because paging is budgeted at denseGeo.cols while rendering happens at o.cols; - the history collapse is skipped entirely at zero headroom — its budget floors at 1, so it would otherwise emit exactly one image and still fail the request. Degrading means "do not emit this turn", never "un-emit": each turn is re-derived from the client's own text transcript, so our images are never stranded there. Telemetry: native_images and image_budget_skips reach the event log, and passthrough_reasons loses an allow-list that swallowed exactly the two reasons worth diagnosing (kept_sharp, image_budget). Measured, 300 turns + 60k slab + 60k tool_result: clients= 0 | slab=3 toolres=3 hist=50 | wire= 56 | text 233k clients=50 | slab=3 toolres=3 hist=44 | wire=100 | text 206k clients=80 | slab=3 toolres=3 hist=11 | wire= 97 | text 809k clients=90 | slab=3 toolres=1 hist= 0 | wire= 94 | text 1053k Client images always win; we shrink to fit. What remains is a cliff, not a slope: the collapse is our only reducer and it is image-based, so at a full wire we drop from compressed straight to raw. (cherry picked from commit cdae5519b915e9b3a4ac40cdc6185d26de2b6f02) --- src/core/history.ts | 181 +++++++++++++++++++++++-- src/core/render.ts | 17 +++ src/core/session-state.ts | 146 ++++++++++++++++++++ src/core/tracker.ts | 14 +- src/core/transform.ts | 226 +++++++++++++++++++++++++++++-- src/dashboard.ts | 19 ++- src/dashboard/fragments.ts | 18 ++- tests/dashboard-api.test.ts | 39 +++++- tests/history-grid-e2e.test.ts | 161 ++++++++++++++++++++++ tests/history.test.ts | 34 ++++- tests/native-image-cap.test.ts | 178 ++++++++++++++++++++++++ tests/scripts/smoke-collapse.mjs | 101 ++++++++++++++ 12 files changed, 1099 insertions(+), 35 deletions(-) create mode 100644 src/core/session-state.ts create mode 100644 tests/history-grid-e2e.test.ts create mode 100644 tests/native-image-cap.test.ts create mode 100644 tests/scripts/smoke-collapse.mjs diff --git a/src/core/history.ts b/src/core/history.ts index 8e76935e1..7257ae376 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -13,7 +13,7 @@ import type { CacheControl, ContentBlock, ImageBlock, Message, TextBlock, ToolUseBlock, ToolResultBlock } from './types.js'; import type { RenderedImage } from './render.js'; -import { DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_CONTENT_COLS, DENSE_RENDER_STYLE, MAX_HEIGHT_PX, neutralizeSentinel, reflow, renderTextToPngsWithCharLimit, roleSlotSegment, SLOT_MARK_ASSISTANT, SLOT_MARK_USER, type RenderStyle } from './render.js'; +import { DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_CONTENT_COLS, maxCharsPerImage, DENSE_RENDER_STYLE, MAX_HEIGHT_PX, neutralizeSentinel, reflow, renderTextToPngsWithCharLimit, roleSlotSegment, SLOT_MARK_ASSISTANT, SLOT_MARK_USER, type RenderStyle } from './render.js'; import { factSheetText } from './factsheet.js'; import { bytesToBase64 } from './png.js'; @@ -76,8 +76,41 @@ export interface HistoryCollapseOptions { style: RenderStyle; /** Model-profile page-height cap. */ maxHeightPx: number; + /** Chars one rendered page holds. Only an ESTIMATOR input (the renderer paginates + * on its own); it decides how many pages a candidate chunk grid will produce. + * Default {@link DENSE_CONTENT_CHARS_PER_IMAGE}. */ + pageChars: number; + /** Hard cap on image blocks this collapse may emit. Anthropic rejects requests + * with more than 100 images (opaque 500), and a 10-message freeze grid emits + * ≈1 page per chunk regardless of how little text the chunk holds — a 3000-turn + * session hit 317 history images at 43% page fill (#161). When the grid would + * exceed the budget the freeze step is DOUBLED (chunks merge, pages fill) until + * the estimate fits; if even a single chunk cannot fit, the collapse range is + * trimmed from the tail and the remainder stays live text. 0 = unlimited. */ + imageBudget: number; + /** Fill-optimal repack. When true the freeze step is raised until the grid costs + * at most one page more than a perfectly packed render — trading the append-only + * cache freeze for ~2× fewer image tokens. Only correct when the upstream prefix + * cache is dead anyway (cold session, see node.ts session store); on a warm + * session it would re-key every frozen chunk. Default false. */ + packFill: boolean; + /** Sticky lower bound for the freeze step, in messages. Once a session has been + * repacked at a coarser grid, every later turn must keep that grid or the + * re-render re-keys the whole history. Rounded UP to a power-of-two multiple of + * `freezeChunk` so chunk boundaries stay a subset of the base grid. Default 0. */ + minFreezeStep: number; } +/** Images Anthropic accepts per request. Exceeding it fails the WHOLE request with + * an opaque `500` (observed 2026-07-31 at 387 images), not a typed 400 — so the + * cap has to be enforced on our side, before the wire. */ +export const ANTHROPIC_MAX_IMAGES = 100; + +/** Default history-image budget: the hard cap minus headroom for the slab, tool-doc + * and tool_result images that share the same request. transform.ts narrows this + * further with the count it has already emitted for this very request. */ +export const ANTHROPIC_HISTORY_IMAGE_BUDGET = 80; + export const HISTORY_DEFAULTS: HistoryCollapseOptions = { keepTail: 4, minCollapsePrefix: 10, @@ -88,6 +121,12 @@ export const HISTORY_DEFAULTS: HistoryCollapseOptions = { reflow: true, style: DENSE_RENDER_STYLE, maxHeightPx: MAX_HEIGHT_PX, + // MUST agree with `cols` above: pageChars is what the budget arithmetic thinks + // one image holds, and DENSE_CONTENT_CHARS_PER_IMAGE is only true at 312 cols. + pageChars: maxCharsPerImage(100), + imageBudget: ANTHROPIC_HISTORY_IMAGE_BUDGET, + packFill: false, + minFreezeStep: 0, }; /** Per-request telemetry surfaced back to TransformInfo. */ @@ -120,7 +159,16 @@ export interface HistoryCollapseInfo { | 'prefix_too_short' | 'no_closed_prefix' | 'not_profitable' - | 'render_empty'; + | 'render_empty' + | 'over_budget'; + /** Freeze step actually used (messages per chunk). Larger than `o.freezeChunk` + * when the image budget or fill-repack forced chunks to merge. The caller pins + * it per session (`minFreezeStep`) so the coarser grid never falls back — a + * fallback would re-key every frozen chunk it already paid to cache. */ + freezeStep?: number; + /** True when the collapse range had to be shortened to stay inside the image + * budget; the dropped tail stays as live text. */ + budgetTrimmed?: boolean; /** Dropped codepoints from the history render, merged into the * transform-wide map by the caller. */ droppedChars: number; @@ -571,6 +619,15 @@ async function userTurnBlocks( ): Promise { const out: ContentBlock[] = []; let pending: string[] = []; + // Over-cap prompts are collected and rendered TOGETHER at the end of the chunk. + // One image per pasted document would put a floor of ≥1 image on every such turn + // that no grid coarsening can lift: a session with 175 pasted logs rendered 175 + // near-empty images and blew the 100-image cap (#161), which upstream answers with + // a 500 rather than a usable error. Batching packs them at ~28k chars/image and + // makes the count a function of BYTES, which the freeze grid can actually control. + // Within a chunk the order is fixed and the chunk is frozen once closed, so the + // grouped bytes are as stable as the transcript image next to them. + const imaged: { idx: number; typed: string }[] = []; const flush = () => { if (pending.length === 0) return; out.push({ @@ -588,19 +645,33 @@ async function userTurnBlocks( pending.push(`${typed}`); continue; } - // Over the cap: this one prompt becomes its own image, kept separate from the - // history transcript image so it stays independently readable and attributable. - flush(); + // Past the cap this is a pasted document, not an instruction: it is rendered + // verbatim below instead of bloating the text. + imaged.push({ idx: i, typed }); + } + flush(); + if (imaged.length > 0) { + // Every batched turn is NAMED (attribution is the point), but only the newest + // few carry a preview: 60 pasted docs × a 300-char preview is a wall of text + // that buys nothing the images below don't already say, verbatim. + const PREVIEW_LIMIT = 8; + const previews = imaged + .map((u, k) => + k >= imaged.length - PREVIEW_LIMIT + ? ` (${u.typed.length} chars) Preview: ${compactPreview(u.typed)}` + : ` (${u.typed.length} chars)`, + ) + .join('\n'); + out.push({ + type: 'text', + text: `[${imaged.length} user turn(s) from this session were too long to carry as text; they are rendered verbatim, in turn order, in the image(s) immediately below, separate from the history transcript. Each begins with its own tag. PRIOR context, not the current request.\n${previews}]`, + }); const imgs = await renderTextToPngsWithCharLimit( - `\n${typed}\n`, + imaged.map((u) => `\n${u.typed}\n`).join('\n\n'), DENSE_CONTENT_COLS, DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_RENDER_STYLE, ); - out.push({ - type: 'text', - text: `[ was too long to carry as text (${typed.length} chars); it is rendered verbatim in the image(s) immediately below, separate from the history transcript. PRIOR context, not the current request. Preview: ${compactPreview(typed)}]`, - }); for (const img of imgs) { out.push({ type: 'image', @@ -613,7 +684,6 @@ async function userTurnBlocks( onImage(img); } } - flush(); return out; } @@ -704,11 +774,60 @@ export async function collapseHistory( } // Need at least minCollapsePrefix turns in [protectedPrefix..boundary] — collapsing // 2-3 turns is net cost (cache-amortization math doesn't work at small scale). - const collapseLen = boundary + 1; + let collapseLen = boundary + 1; if (collapseLen - protectedPrefix < o.minCollapsePrefix) { info.reason = 'prefix_too_short'; return { messages, info }; } + + // ---- Image budget --------------------------------------------------------- + // Anthropic rejects the WHOLE request past ANTHROPIC_MAX_IMAGES with an opaque + // 500, so the budget is a hard constraint we must enforce before the wire. Price + // candidate grids off per-message serialized lengths: one pass here replaces + // re-serializing the transcript once per candidate step. + const budget = o.imageBudget > 0 ? o.imageBudget : Infinity; + const pageChars = Math.max(1, o.pageChars); + const msgLen: number[] = []; + // Over-cap user prompts are imaged too (userTurnBlocks), batched per chunk. They + // are NOT part of the transcript segments, so they must be priced separately or + // the estimate silently under-counts and the wire limit is what finds out. + const userImgLen: number[] = []; + for (let i = protectedPrefix; i < collapseLen; i++) { + const seg = messagesToHistorySegments(messages, i + 1, i).text; + msgLen.push(seg.length === 0 ? 0 : seg.length + 2); // +2 = the "\n\n" joiner + const m = messages[i]!; + const typed = m.role === 'user' ? typedUserText(m.content) : ''; + userImgLen.push(typed && typed.length > USER_TEXT_MAX_CHARS ? typed.length + 20 : 0); + } + const sumOf = (arr: number[], from: number, to: number): number => { + let n = 0; + for (let i = from; i < to; i++) n += arr[i]!; + return n; + }; + const sumLen = (from: number, to: number): number => sumOf(msgLen, from, to); + const perfectPages = + Math.ceil(sumLen(0, msgLen.length) / pageChars) + + Math.ceil(sumOf(userImgLen, 0, userImgLen.length) / pageChars); + if (perfectPages > budget) { + // Even a perfectly packed render of the full range overflows. Keep the OLDEST + // messages collapsed (the frozen prefix must stay anchored at protectedPrefix + // or every cached chunk re-keys) and leave the tail as live text. + let acc = 0; + let k = 0; + while (k < msgLen.length && acc + msgLen[k]! + userImgLen[k]! <= budget * pageChars) { + acc += msgLen[k]! + userImgLen[k]!; + k++; + } + const trimmedLen = findClosedPrefixBoundary(messages, protectedPrefix + k) + 1; + if (trimmedLen - protectedPrefix < o.minCollapsePrefix) { + info.reason = 'over_budget'; + return { messages, info }; + } + collapseLen = trimmedLen; + msgLen.length = collapseLen - protectedPrefix; + info.budgetTrimmed = true; + } + // Exclude slab messages (protectedPrefix) from serialization. const text = messagesToHistoryText(messages, collapseLen, protectedPrefix); if (!text || text.length === 0) { @@ -738,7 +857,43 @@ export async function collapseHistory( // cacheable image boundary instead of being silently flattened (count conserved, // never added). Each chunk is reflowed and rendered on its own, which is what // makes the bytes a pure function of the chunk's messages. - const step = o.freezeChunk > 0 ? o.freezeChunk : collapseLen - protectedPrefix; + // + // The grid step is ADAPTIVE. A fixed 10-message step emits ≥1 page per chunk no + // matter how little text the chunk holds: a long session of short turns rendered + // 317 pages at 43% fill and 500'd the request (#161). Doubling the step merges + // neighbouring chunks — pages fill up, count drops ~2× per doubling — while + // keeping chunk boundaries a SUBSET of the base grid, so a chunk frozen at the + // coarse step spans whole base chunks and stays byte-identical as long as the + // step never shrinks again (the caller pins it via minFreezeStep). + const baseStep = o.freezeChunk > 0 ? o.freezeChunk : collapseLen - protectedPrefix; + const rangeLen = collapseLen - protectedPrefix; + const pagesFor = (s: number): number => { + let pages = 0; + for (let a = 0; a < rangeLen; a += s) { + const b = Math.min(a + s, rangeLen); + const chars = sumLen(a, b); + if (chars > 0) pages += Math.ceil(chars / pageChars); + // Over-cap user prompts in this chunk are batched into their own image(s). + const uchars = sumOf(userImgLen, a, b); + if (uchars > 0) pages += Math.ceil(uchars / pageChars); + } + return pages; + }; + // Caller cache_control marks force extra splits below; charge one page each so a + // marked request can't slip past the budget the estimate just cleared. + let markSplits = 0; + for (let i = protectedPrefix; i < collapseLen; i++) { + if (messageCacheControl(messages[i]!) !== undefined) markSplits++; + } + let step = baseStep; + // Sticky floor first: a session already repacked coarse must STAY coarse. + while (step < o.minFreezeStep && step < rangeLen) step *= 2; + // packFill trades the append-only freeze for ~2× fewer image tokens and is only + // set when the upstream cache is dead anyway (cold session / after a 500). + const packedPages = Math.max(1, Math.ceil(sumLen(0, rangeLen) / pageChars)); + const goal = Math.min(budget, o.packFill ? packedPages + 1 : Infinity); + while (step < rangeLen && pagesFor(step) + markSplits > goal) step *= 2; + info.freezeStep = step; const ends = new Set(); for (let e = protectedPrefix + step; e < collapseLen; e += step) ends.add(e); const markerByEnd = new Map(); diff --git a/src/core/render.ts b/src/core/render.ts index 924a0ead9..90fb21316 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -257,6 +257,23 @@ export const DEFAULT_CELL_H_BONUS = 0; export const CELL_W = ATLAS_CELL_W + DEFAULT_CELL_W_BONUS; export const CELL_H = ATLAS_CELL_H + DEFAULT_CELL_H_BONUS; +/** Visual rows per image: `floor((MAX_HEIGHT_PX − 2·PAD_Y) / CELL_H)`. Derived + * from the cell geometry above so break-even math auto-tracks it. */ +export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H)); + +/** Real char capacity of one page AT A GIVEN COLUMN WIDTH. + * + * Lives here, next to the geometry it is derived from, because every caller that + * budgets images must price pages at the width it actually renders at. The + * DENSE_CONTENT_CHARS_PER_IMAGE constant is only correct for DENSE_CONTENT_COLS + * (312×90); using it while rendering at, say, COLS=100 overstates capacity 3.1×, + * so an image budget clears a plan that then emits 3× the images — the request + * blows the API's per-request limit and comes back 500. Always pass the cols the + * renderer will actually use. */ +export function maxCharsPerImage(cols: number): number { + return Math.min(Math.max(1, cols) * LINES_PER_IMAGE, READABLE_CHARS_PER_IMAGE); +} + export interface RenderedImage { png: Uint8Array; width: number; diff --git a/src/core/session-state.ts b/src/core/session-state.ts new file mode 100644 index 000000000..ddb33aa16 --- /dev/null +++ b/src/core/session-state.ts @@ -0,0 +1,146 @@ +/** + * Per-session cache-liveness state for the history collapse. + * + * ## Why this exists + * + * The history grid is append-only: chunk N's pixels are a pure function of its + * message range, so old chunks stay byte-identical as the conversation grows and + * ride Anthropic's prompt cache as `cache_read` forever. That freeze is worth a + * lot — but only while a cache actually exists. Two situations end it: + * + * 1. **Idle gap.** Anthropic's ephemeral prefix cache lives {@link CACHE_TTL_SEC} + * seconds past the last hit. Resume a session the next morning and every + * block is `cache_create` again no matter what we send. + * 2. **A rejected request.** An oversized request (opaque `500`, see + * {@link ANTHROPIC_MAX_IMAGES}) never populated a cache entry at all. + * + * In both cases the append-only freeze protects nothing, and the grid is free to + * be re-cut for *density* instead: {@link HistoryCollapseOptions.packFill} raises + * the freeze step until the pages are nearly full, which roughly halves image + * tokens on long sessions of short turns (#161: 317 images at 43% fill). + * + * ## Why the step is sticky + * + * Once a session has been repacked coarse, every later turn must keep at least + * that step. Falling back to the fine grid would re-cut the same messages into + * different chunks — every chunk's bytes change, and the whole history re-keys as + * `cache_create`. {@link recordFreezeStep} pins the floor; the collapse only ever + * doubles it. + * + * ## Failure mode we deliberately accept + * + * State is in-memory and per proxy process. After a restart a live session looks + * *unknown*, and unknown is treated as WARM (no repack) — the conservative + * choice: at worst we keep paying the old image count, we never nuke a live cache + * on a guess. The state re-arms itself on the first idle gap after the restart. + */ + +import { CACHE_TTL_SEC } from './baseline.js'; + +/** Sessions tracked before the oldest is evicted. One small record each. */ +const SESSIONS_MAX = 512; + +/** + * Grace added to the provider TTL before we call a cache dead. Our clock is the + * request-arrival time, the provider's is its own; a request that lands one + * second inside the window can still miss. Only gaps clearly past the TTL flip + * the session cold, so a borderline case keeps the (cheap, correct) warm path. + */ +const COLD_GRACE_MS = 30_000; + +interface SessionRecord { + /** Wall-clock ms of the last request we saw for this session. */ + lastSeenMs: number; + /** Coarsest freeze step this session has been rendered at, in messages. */ + freezeStep: number; + /** Set when a request for this session failed in a way that leaves no cache. */ + cacheDead: boolean; +} + +const sessions = new Map(); + +function touch(key: string): SessionRecord { + const existing = sessions.get(key); + if (existing) { + sessions.delete(key); // refresh LRU position + sessions.set(key, existing); + return existing; + } + const fresh: SessionRecord = { lastSeenMs: 0, freezeStep: 0, cacheDead: false }; + sessions.set(key, fresh); + while (sessions.size > SESSIONS_MAX) { + const oldest = sessions.keys().next().value; + if (oldest === undefined) break; + sessions.delete(oldest); + } + return fresh; +} + +export interface HistorySessionState { + /** The upstream prefix cache is provably gone — re-cutting the grid is free. */ + cold: boolean; + /** Floor for the freeze step, in messages. 0 = no constraint. */ + minFreezeStep: number; +} + +/** Neutral answer for callers without a session identity (no fingerprint yet). */ +const UNKNOWN_STATE: HistorySessionState = { cold: false, minFreezeStep: 0 }; + +/** + * Record a request for `sessionKey` and report what the history collapse may + * assume about the upstream cache. Call once per transformed request, BEFORE the + * collapse runs; it advances the session's last-seen clock. + * + * A session we have never seen counts as warm (see module docs) — unknown must + * never authorize a repack. + */ +export function noteHistoryRequest( + sessionKey: string | undefined, + nowMs: number = Date.now(), +): HistorySessionState { + if (!sessionKey) return UNKNOWN_STATE; + const rec = touch(sessionKey); + const known = rec.lastSeenMs > 0; + const idleMs = nowMs - rec.lastSeenMs; + const expired = known && idleMs > CACHE_TTL_SEC * 1000 + COLD_GRACE_MS; + const cold = rec.cacheDead || expired; + rec.lastSeenMs = nowMs; + rec.cacheDead = false; // consumed: this request gets the repack + return { cold, minFreezeStep: rec.freezeStep }; +} + +/** + * Pin the grid this session was last rendered at. Monotonic: the floor only ever + * rises, because a later, finer render would re-key every chunk it re-cuts. + */ +export function recordFreezeStep( + sessionKey: string | undefined, + step: number | undefined, +): void { + if (!sessionKey || !step || !Number.isFinite(step) || step <= 0) return; + const rec = touch(sessionKey); + if (step > rec.freezeStep) rec.freezeStep = step; +} + +/** + * Mark this session's upstream cache as gone: the last request was rejected, so + * nothing was cached and the next one may re-cut the grid for density. Call on + * the failure paths that leave no cache entry (oversized request → opaque 500). + */ +export function markCacheDead(sessionKey: string | undefined): void { + if (!sessionKey) return; + touch(sessionKey).cacheDead = true; +} + +/** Test seam: drop all session state. */ +export function resetSessionState(): void { + sessions.clear(); +} + +/** Test/telemetry seam: inspect a session without mutating its clock. */ +export function peekSessionState( + sessionKey: string, +): { lastSeenMs: number; freezeStep: number; cacheDead: boolean } | undefined { + const rec = sessions.get(sessionKey); + return rec ? { ...rec } : undefined; +} diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 8f44ef87d..07e9ddb54 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -27,6 +27,14 @@ export interface TrackEvent { * Compare with image_count: textTokens(n/4) vs imageTokens(n×2500). */ compressed_chars?: number; image_count?: number; + /** Images the CLIENT already put on the wire (pasted screenshots, tool-returned + * pictures). They spend from the same provider cap as ours, so this is the + * number that explains an otherwise-surprising image_budget passthrough. */ + native_images?: number; + /** Imaging paths that degraded to text because the wire cap was full. Nonzero + * here plus a fat request means the client's own images crowded us out — the + * request stayed valid, but the token win was skipped. */ + image_budget_skips?: number; image_bytes?: number; /** Total pixel area across all rendered images; pairs with cache_create_tokens for px/token regression. */ image_pixels?: number; @@ -222,6 +230,10 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { out.compressed_chars = info.compressedChars; } if (info.imageCount !== undefined) out.image_count = info.imageCount; + // Only when nonzero: a wire without client images is the common case and + // should not pay a key per event. + if ((info.nativeImages ?? 0) > 0) out.native_images = info.nativeImages; + if ((info.imageBudgetSkips ?? 0) > 0) out.image_budget_skips = info.imageBudgetSkips; if (info.imageBytes !== undefined) out.image_bytes = info.imageBytes; if (info.imagePixels !== undefined && info.imagePixels > 0) { out.image_pixels = info.imagePixels; @@ -275,7 +287,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { } if (info.passthroughReasons) { const pr = info.passthroughReasons; - if ((pr.below_threshold ?? 0) > 0 || (pr.not_profitable ?? 0) > 0) { + if (Object.values(pr).some((n) => (n ?? 0) > 0)) { out.passthrough_reasons = pr; } } diff --git a/src/core/transform.ts b/src/core/transform.ts index 6d77114a9..f4b150a63 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -34,6 +34,8 @@ import { renderTextToPngsWithCharLimit, renderCellHeight, renderCellWidth, + LINES_PER_IMAGE, + maxCharsPerImage, type RenderStyle, } from './render.js'; import { @@ -43,7 +45,13 @@ import { import { factSheetText } from './factsheet.js'; import { stripSchemaDescriptions, schemaHasStructure } from './schema-strip.js'; import { bytesToBase64 } from './png.js'; -import { collapseHistory, HISTORY_SYNTHETIC_INTRO } from './history.js'; +import { + collapseHistory, + HISTORY_SYNTHETIC_INTRO, + ANTHROPIC_MAX_IMAGES, + ANTHROPIC_HISTORY_IMAGE_BUDGET, +} from './history.js'; +import { noteHistoryRequest, recordFreezeStep } from './session-state.js'; import type { GptHistoryOptions } from './openai-history.js'; import { CACHE_CREATE_RATE, CACHE_READ_RATE } from './baseline.js'; import { visionTokens, type VisionPricing } from './vision-cost.js'; @@ -303,10 +311,17 @@ function imageTokensCost( /** Gate geometry for dense tool-result, reminder, and history pages. */ function denseGateGeometry(o?: Required): GateGeometry { const profile = o?.model ? resolveGptProfile(o.model) : undefined; + const cols = o?.cols ?? profile?.stripCols ?? DENSE_CONTENT_COLS; return { - cols: o?.cols ?? profile?.stripCols ?? DENSE_CONTENT_COLS, + cols, maxHeightPx: profile?.maxHeightPx ?? MAX_HEIGHT_PX, - maxChars: DENSE_CONTENT_CHARS_PER_IMAGE, + // Price a page at the width we actually render at, NOT at the 312-col constant. + // These are the same number in the default Anthropic geometry, but a narrower + // COLS (env override, GPT strip profile) holds proportionally fewer chars: the + // old constant overstated capacity up to 3.1× at COLS=100, so the history image + // budget cleared a plan that then emitted 3× the images and the oversized + // request came back 500. Capacity must track cols or the budget is fiction. + maxChars: maxCharsPerImage(cols), style: profile?.style ?? DENSE_RENDER_STYLE, // No model on the request (Anthropic slab path): price at the Claude // profile, which is what is actually serving it. @@ -314,13 +329,10 @@ function denseGateGeometry(o?: Required): GateGeometry { }; } -/** Visual rows per image: `floor((MAX_HEIGHT_PX − 2·PAD_Y) / CELL_H)`. Derived - * from render.ts constants so break-even math auto-tracks cell geometry changes. */ -export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H)); - -export function maxCharsPerImage(cols: number): number { - return Math.min(cols * LINES_PER_IMAGE, READABLE_CHARS_PER_IMAGE); -} +/** Re-exported from render.ts, which owns the cell geometry these derive from. + * Kept exported here because the eval harnesses and gpt paths import them from + * transform. Single implementation, so the page-capacity math can't fork. */ +export { LINES_PER_IMAGE, maxCharsPerImage }; /** Lossless pre-render whitespace compactor (each `\n` costs ≥1 visual row): * 1. Strip trailing whitespace per line (preserves leading indent). @@ -479,7 +491,7 @@ export function isCompressionProfitableAmortized( /** Increment a passthrough-reason counter on `info`. Lazily allocates `passthroughReasons`. */ function bumpPassthrough( info: TransformInfo, - reason: 'below_threshold' | 'not_profitable' | 'kept_sharp', + reason: 'below_threshold' | 'not_profitable' | 'kept_sharp' | 'image_budget', ): void { if (!info.passthroughReasons) info.passthroughReasons = {}; info.passthroughReasons[reason] = (info.passthroughReasons[reason] ?? 0) + 1; @@ -635,6 +647,14 @@ export interface TransformInfo { * render may repeat its section source. Dashboard-only; not persisted. */ imageSourceTexts?: Array; toolResultImgs?: number; + /** Image blocks the CLIENT already sent (screenshots, pasted images, prior + * tool_result images). They count against the provider's hard image cap just + * like ours do, so every pxpipe imaging path must price them in — a request + * whose own images already fill the cap must not get a single one from us. + * Counted once, before any rewrite. See {@link imageHeadroom}. */ + nativeImages?: number; + /** Imaging steps skipped because the cap was exhausted (telemetry for tuning). */ + imageBudgetSkips?: number; /** Chars of tool docs moved to the system-text Tool Reference (not imaged). */ toolDocsChars?: number; /** Codepoints missing from the atlas (rendered as blank cells). Telemetry for atlas tuning. */ @@ -642,7 +662,7 @@ export interface TransformInfo { /** Top dropped codepoints by frequency (`U+HHHH` → count), at most 20 entries. */ droppedCodepointsTop?: Record; /** Why blocks passed through without compression. Only present when count > 0. */ - passthroughReasons?: { below_threshold?: number; not_profitable?: number; kept_sharp?: number }; + passthroughReasons?: { below_threshold?: number; not_profitable?: number; kept_sharp?: number; image_budget?: number }; /** Slab gate diagnostics — imageTokens, textTokens, burn terms, and verdict. * Lets hosts measure flap-prevention efficacy and tune amortization horizon. */ gateEval?: { @@ -674,6 +694,15 @@ export interface TransformInfo { * proves Anthropic's prompt cache can `cache_read` (0.1×) instead of `cache_create`. * A changing hash means cache-key drift is back. Only set when collapse produced images. */ historyImageSha?: string; + /** Freeze-grid step the history collapse actually used, in messages. Rises when the + * adaptive packer merges chunks to fit the image budget; must never fall within a + * session (a finer re-cut re-keys every chunk). */ + historyFreezeStep?: number; + /** The collapse re-cut the grid for page fill instead of cache freeze — only set when + * the session's upstream cache was provably dead (idle past TTL, or after a reject). */ + historyPackFill?: boolean; + /** The image budget could not hold the whole closed prefix; the tail stayed live text. */ + historyBudgetTrimmed?: boolean; /** sha8 of the ACTUAL cacheable prefix sent this turn (tools + system + * message blocks through the imaged history/slab boundary; the live tail is * excluded). Read-only measurement. A change turn-over-turn within a session @@ -711,6 +740,7 @@ export interface TransformInfo { | 'not_profitable' | 'too_many_images' | 'render_empty' + | 'over_budget' | 'collapsed'; /** Token count of the pre-compression body from /v1/messages/count_tokens (free). * Absent when probe failed — event excluded from savings rollup. */ @@ -1718,6 +1748,71 @@ function applyPins(req: MessagesRequest, info: TransformInfo, pins: Pin[]): void * Called from both the main path AND early-exit paths (below_min_chars, * not_profitable) — history collapse must run even when the slab skips. * Tolerant to missing/short message arrays (collapseHistory short-circuits). */ +/** Slack between our page estimate and the provider's hard image cap. The renderer + * paginates on its own, so a candidate grid can come out one page heavier than the + * estimator predicted; the request must still land under {@link ANTHROPIC_MAX_IMAGES}. */ +const HISTORY_IMAGE_SAFETY_MARGIN = 5; + +/** + * Image blocks this request may still add before the provider's hard cap. + * + * The cap counts EVERY image on the wire: the client's own (`nativeImages`) and + * ours (`imageCount`). Pricing only ours is how a request with 103 client images + * still got imaged further and came back 400 — the cap is a wire property, not a + * pxpipe property. Never negative; callers treat 0 as "keep it as text". + */ +export function imageHeadroom(info: TransformInfo): number { + return Math.max( + 0, + ANTHROPIC_MAX_IMAGES - + HISTORY_IMAGE_SAFETY_MARGIN - + info.imageCount - + (info.nativeImages ?? 0), + ); +} + +/** Count image blocks already present in the caller's messages. Runs BEFORE any + * rewrite, so it sees the client's images only — ours do not exist yet. */ +export function countNativeImages(messages: readonly Message[] | undefined): number { + let n = 0; + for (const m of messages ?? []) { + if (!Array.isArray(m.content)) continue; + for (const blk of m.content) { + const t = (blk as { type?: string } | null)?.type; + if (t === 'image') n++; + else if (t === 'tool_result') { + const inner = (blk as ToolResultBlock).content; + if (Array.isArray(inner)) { + for (const ib of inner) if ((ib as { type?: string } | null)?.type === 'image') n++; + } + } + } + } + return n; +} + +/** + * Per-request history-grid tuning: how many images the collapse may still spend, + * and whether it is allowed to re-cut the grid for density. + * + * `info.imageCount` already holds every image this request emitted before the + * collapse (slab, tool docs, tool_results), so the remaining headroom is the hard + * cap minus those, minus a margin for estimator drift — and never more than the + * standing cost guard {@link ANTHROPIC_HISTORY_IMAGE_BUDGET}. + * + * `packFill`/`minFreezeStep` come from the session store: repack only when the + * upstream cache is provably dead, and never below a grid this session already + * froze at. See {@link noteHistoryRequest}. + */ +function historyGridTuning( + info: TransformInfo, +): { imageBudget: number; packFill: boolean; minFreezeStep: number } { + const headroom = imageHeadroom(info); + const imageBudget = Math.max(1, Math.min(ANTHROPIC_HISTORY_IMAGE_BUDGET, headroom)); + const session = noteHistoryRequest(info.firstUserSha8); + return { imageBudget, packFill: session.cold, minFreezeStep: session.minFreezeStep }; +} + async function runHistoryCollapseAndFinalize( req: MessagesRequest, info: TransformInfo, @@ -1727,7 +1822,15 @@ async function runHistoryCollapseAndFinalize( pins: Pin[], ): Promise<{ body: Uint8Array; info: TransformInfo; collapsed: boolean }> { let collapsedFlag = false; - if (Array.isArray(req.messages) && req.messages.length > 0) { + // Same wire cap as every other imaging path: the collapse buys tokens with + // image blocks, and a request whose client images already fill the cap has + // none left to spend. `historyGridTuning` floors the budget at 1, so without + // this guard a full wire would still emit exactly one image — and 400. + if (imageHeadroom(info) <= 0) { + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + info.historyReason ??= 'too_many_images'; + } else if (Array.isArray(req.messages) && req.messages.length > 0) { const historyCpt = opts.charsPerToken !== undefined ? o.charsPerToken : HISTORY_CHARS_PER_TOKEN; @@ -1755,6 +1858,7 @@ async function runHistoryCollapseAndFinalize( // message carrying them is protected from collapse the same way the // non-collapse path already keeps as text below. const protectedPrefix = firstMessageHasSystemReminder(req.messages) ? 1 : 0; + const tuning = historyGridTuning(info); const { messages: newMessages, info: histInfo } = await collapseHistory( req.messages, historyProfitable, @@ -1764,8 +1868,16 @@ async function runHistoryCollapseAndFinalize( reflow: o.reflow, style: historyGeometry.style, maxHeightPx: historyGeometry.maxHeightPx, + pageChars: historyGeometry.maxChars, + imageBudget: tuning.imageBudget, + packFill: tuning.packFill, + minFreezeStep: tuning.minFreezeStep, }, ); + recordFreezeStep(info.firstUserSha8, histInfo.freezeStep); + if (histInfo.freezeStep !== undefined) info.historyFreezeStep = histInfo.freezeStep; + if (histInfo.budgetTrimmed) info.historyBudgetTrimmed = true; + if (tuning.packFill) info.historyPackFill = true; if (histInfo.collapsedTurns > 0) { req.messages = newMessages; info.collapsedTurns = histInfo.collapsedTurns; @@ -1858,6 +1970,11 @@ export async function transformRequest( return { body, info }; } + // Price the caller's OWN images before we rewrite anything: they occupy the + // same wire cap we are about to spend from. Counted once, here, because every + // later path (slab, tool_results, history) rewrites content in place. + info.nativeImages = countNativeImages(req.messages); + // 0. User-pinned instructions. Fold the transcript's pin commands, then remove // them from the outbound copy — the client's own transcript is untouched, so // the next request still carries every command and re-derives the same state. @@ -2059,6 +2176,18 @@ export async function transformRequest( return { body: pinsRewrote ? finalized.body : body, info }; } + // The wire cap guards even the slab. Imaging it is our biggest single win, but + // a request whose client images already fill the provider's cap has no image + // left to spend: emitting one anyway turns a large-but-valid request into a + // hard 400. Degrade to plain text and say why. + if (imageHeadroom(info) <= 0) { + info.reason = 'image_budget'; + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + const finalized = await runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints, pins); + return { body: pinsRewrote || finalized.collapsed ? finalized.body : body, info }; + } + // Break-even check guards even the slab (rare edge: tiny tool docs + tiny slab < 10k chars). const denseGeo = denseGateGeometry(o); // Use slab cpt (2.0) unless host pinned charsPerToken explicitly. @@ -2144,6 +2273,23 @@ export async function transformRequest( denseGeo.style, denseGeo.maxHeightPx, ); + // Quantitative cap, not just "is there room": the slab is many pages, and a + // boolean "headroom > 0" check happily emitted 15 of them into a single slot + // (measured: 94 client images + 400k slab -> 109 on the wire -> 400). All or + // nothing, because the slab is part of the cache prefix — imaging half of it + // would re-key that prefix on every turn whose client-image count moved. + if (images.length > imageHeadroom(info)) { + info.reason = `image_budget (slab needs ${images.length}, headroom ${imageHeadroom(info)})`; + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + const finalized = await runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints, pins); + if (finalized.collapsed) { + info.compressed = true; + return { body: finalized.body, info }; + } + return { body: pinsRewrote ? finalized.body : body, info }; + } + const imageBlocks: ImageBlock[] = []; for (let i = 0; i < images.length; i++) { const img = images[i]!; @@ -2248,6 +2394,15 @@ export async function transformRequest( rewritten.push(blk); continue; } + // Hard wire cap: the client's own images plus ours must stay + // under the provider's limit, else the WHOLE request is rejected. + // Out of headroom → keep the text sharp; a big body beats a 400. + if (imageHeadroom(info) <= 0) { + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + rewritten.push(blk); + continue; + } const inner = compactSlabWhitespace(innerRaw); // classifyContent sees pre-reflow `inner` so shape bucketing reflects real structure. const innerR = maybeReflow(inner, o.reflow); @@ -2259,13 +2414,17 @@ export async function transformRequest( rewritten.push(blk); } else { // Paging: truncate before render if it would blow the image cap. + // The per-result cap is the SMALLER of the configured max and what + // is actually left on the wire — the headroom shrinks as earlier + // results spend it, so each result sees the live number. + const resultImageCap = Math.min(o.maxImagesPerToolResult, imageHeadroom(info)); const linesPerImage = Math.max( 1, Math.floor((denseGeo.maxHeightPx - 2 * PAD_Y) / renderCellHeight(denseGeo.style)), ); const paged = truncateForBudget( innerR, - o.maxImagesPerToolResult, + resultImageCap, denseGeo.cols, denseGeo.maxChars, linesPerImage, @@ -2282,6 +2441,16 @@ export async function transformRequest( denseGeo.style, denseGeo.maxHeightPx, ); + // Paging is budgeted at denseGeo.cols but rendering happens at + // o.cols; when they differ the real page count can exceed the plan. + // Verify against the actual render and drop OUR images rather than + // ship a request the provider rejects outright. + if (imgs.length > imageHeadroom(info)) { + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + rewritten.push(blk); + continue; + } (info.imagePngs ??= []).push(...rawPngs); (info.imageDims ??= []).push(...rawDims); for (const img of imgs) info.imageBytes += approxBlockBytes(img); @@ -2327,6 +2496,13 @@ export async function transformRequest( newInner.push(ib as TextBlock | ImageBlock); continue; } + // Hard wire cap — see the string-content path above. + if (imageHeadroom(info) <= 0) { + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + newInner.push(ib as TextBlock | ImageBlock); + continue; + } // Lossless whitespace compaction before gate + render. const innerText = compactSlabWhitespace(innerTextRaw); // R3: gate/page/render on reflowed text; classify pre-reflow. @@ -2345,9 +2521,10 @@ export async function transformRequest( 1, Math.floor((denseGeo.maxHeightPx - 2 * PAD_Y) / renderCellHeight(denseGeo.style)), ); + const resultImageCap = Math.min(o.maxImagesPerToolResult, imageHeadroom(info)); const paged = truncateForBudget( innerTextR, - o.maxImagesPerToolResult, + resultImageCap, denseGeo.cols, denseGeo.maxChars, linesPerImage, @@ -2364,6 +2541,16 @@ export async function transformRequest( denseGeo.style, denseGeo.maxHeightPx, ); + // Paging is budgeted at denseGeo.cols but rendering happens at + // o.cols; when they differ the real page count can exceed the plan. + // Verify against the actual render and drop OUR images rather than + // ship a request the provider rejects outright. + if (imgs.length > imageHeadroom(info)) { + bumpPassthrough(info, 'image_budget'); + info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; + newInner.push(ib as TextBlock | ImageBlock); + continue; + } (info.imagePngs ??= []).push(...rawPngs); (info.imageDims ??= []).push(...rawDims); const srcCacheControl = demoteRelocatedCacheControl((ib as { cache_control?: unknown }).cache_control); @@ -2433,6 +2620,7 @@ export async function transformRequest( ); }; const slabAnchorIdx = (req.messages ?? []).findIndex((m) => m.role === 'user'); + const tuning = historyGridTuning(info); const { messages: newMessages, info: histInfo } = await collapseHistory( req.messages, historyProfitable, @@ -2442,8 +2630,16 @@ export async function transformRequest( reflow: o.reflow, style: historyGeometry.style, maxHeightPx: historyGeometry.maxHeightPx, + pageChars: historyGeometry.maxChars, + imageBudget: tuning.imageBudget, + packFill: tuning.packFill, + minFreezeStep: tuning.minFreezeStep, }, ); + recordFreezeStep(info.firstUserSha8, histInfo.freezeStep); + if (histInfo.freezeStep !== undefined) info.historyFreezeStep = histInfo.freezeStep; + if (histInfo.budgetTrimmed) info.historyBudgetTrimmed = true; + if (tuning.packFill) info.historyPackFill = true; if (histInfo.collapsedTurns > 0) { req.messages = newMessages; info.collapsedTurns = histInfo.collapsedTurns; diff --git a/src/dashboard.ts b/src/dashboard.ts index f6a21e7c5..993564fc3 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -29,6 +29,7 @@ */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as readline from 'node:readline'; import type { ProxyEvent } from './core/proxy.js'; import type { TrackEvent } from './core/tracker.js'; @@ -1497,7 +1498,7 @@ export class DashboardState { } serveHtml(port: number): Response { - return htmlResponse(renderPage(port)); + return htmlResponse(renderPage(port, dashboardHostLabel())); } /** GET /fragments/ — server-rendered htmx fragments. Each one reuses @@ -1716,6 +1717,22 @@ export function dashboardPath(pathname: string): DashboardRoute | null { return null; } +/** Name of the machine serving this dashboard, shown in the title and topbar. + * PXPIPE_DASH_LABEL overrides it for hosts whose system hostname says nothing + * useful (containers, "localhost"); an explicitly empty label opts out and + * renders the unlabelled page. */ +export function dashboardHostLabel(): string { + const override = process.env.PXPIPE_DASH_LABEL; + if (override !== undefined) return override.trim(); + try { + const h = os.hostname().trim(); + // Keep it short: FQDNs push the chip past the wordmark for no added meaning. + return h.split('.')[0] || ''; + } catch { + return ''; + } +} + function htmlResponse(body: string): Response { return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' }, diff --git a/src/dashboard/fragments.ts b/src/dashboard/fragments.ts index 2d8fe88ac..33c6be9b1 100644 --- a/src/dashboard/fragments.ts +++ b/src/dashboard/fragments.ts @@ -864,6 +864,10 @@ const CSS = ` background: radial-gradient(circle at 35% 30%, #ffd0a8, var(--flame) 55%, var(--flame-strong)); box-shadow: 0 0 0 4px var(--flame-tint); flex: none; } .wordmark { font-size: 22px; font-weight: 800; color: var(--ink); letter-spacing: -0.02em; } + .wordmark-row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } + /* Which machine is this? Two dashboards from two hosts look identical otherwise. */ + .hostchip { font-size: 11.5px; font-weight: 600; color: var(--muted); padding: 1px 7px; + border: 1px solid var(--line); border-radius: 999px; white-space: nowrap; } .tagline { font-size: 12.5px; color: var(--muted); margin-top: 1px; max-width: 460px; } .controls { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } @@ -1188,14 +1192,19 @@ const THEME_JS = ` })(); `; -export function renderPage(port: number): string { +/** `hostLabel` names the machine this proxy runs on. The dashboard is otherwise + * byte-identical across hosts, so a tab opened against a remote host through + * the tailnet front is indistinguishable from the local one - which is how a + * session gets read on the wrong box. Empty label = render as before. */ +export function renderPage(port: number, hostLabel = ''): string { + const host = escapeHtml(hostLabel.trim()); // hx-trigger="load, every Ns": paint on load then poll (2s live, 5s aggregates). return ` -pxpipe — live dashboard +${host ? `${host} · pxpipe dashboard` : 'pxpipe — live dashboard'}