Captures why imaging conversation history stays cache-safe as a session grows: the quantized collapseChunk boundary, the one-time cache-create burn + amortization gate, the protectedPrefix slab anchor, and the relocate-don't-add cache_control rule. Grounded in src/core/history.ts and transform.ts; prose mirror of the verified code.
11 KiB
How imaged history stays cache-safe as a conversation grows
This doc captures the mental model behind pxpipe's history-image compression: why turning past conversation turns into images doesn't break Anthropic's prompt cache, even though the conversation keeps growing and "new" content is constantly becoming "old."
It's the conceptual companion to CACHING_AND_SAVINGS.md,
which carries the pricing math. Where the two disagree, the code wins:
src/core/history.ts—collapseHistory, the quantized boundary,blocksToTextsrc/core/transform.ts— the splice, the cache-mark relocation, the gatessrc/core/baseline.ts—CACHE_CREATE_RATE = 1.25,CACHE_READ_RATE = 0.1
0. The question this answers
If we convert old turns to images, doesn't the prefix change — and break the cache? And since new content always eventually becomes old, doesn't the boundary move every turn, re-imaging and re-keying the cache continuously?
Short version: we do image history, it is cache-safe, and "old" is defined by a quantized boundary so the re-key is a rare one-time event, not a per-turn churn. The cache breakpoint ("mark") is the seam that makes the cost one-time.
1. Yes — we image history, always-on
collapseHistory (history.ts) is always-on, unconditional (transform.ts:
"Variant C history-image compression. ALWAYS-ON, unconditional"). It walks
messages[], finds the largest tool-closed prefix run of past turns,
serializes them to text with blocksToText — which includes assistant
responses, tool_use args, and tool_result content — and renders that text
into PNG blocks inside one prepended synthetic user message.
What stays as text is the live tail: the last keepTail turns plus anything
inside an open tool sequence (and the most-recent assistant thinking signature,
which must round-trip bit-perfect).
So past assistant responses absolutely become images. The rest of this doc is about why that's safe.
2. The aging problem is real
Every turn, the live tail grows and the oldest tail turn becomes eligible to be "old." So the text↔image boundary wants to move forward over time. The danger:
If "old" meant "everything except the last
keepTailturns" — a per-turn moving window — the boundary would advance by one message every turn. The collapsed set would change every turn → the rendered PNG bytes would change every turn → new cache key every turn →cache_create(1.25×) on the whole history every single turn.
That is not hypothetical. It's the 2026-05-19 regression (bug #28), which the pricing doc cites as going to −250% "savings." A moving boundary is a cache shredder.
3. The fix: "old" is quantized, not a moving window
The boundary is snapped onto a fixed grid of collapseChunk messages
(default 50). From history.ts:
const rawCutoff = messages.length - o.keepTail;
const cutoff = o.collapseChunk > 0
? Math.min(rawCutoff,
Math.max(minCollapsePrefix + protectedPrefix,
Math.floor(rawCutoff / o.collapseChunk) * o.collapseChunk))
: rawCutoff;
const boundary = findClosedPrefixBoundary(messages, cutoff);
Math.floor(rawCutoff / collapseChunk) * collapseChunk is the whole trick:
eligibility for imaging advances in discrete jumps, not continuously. Then
findClosedPrefixBoundary snaps it back to the nearest tool-closed point so the
image never splits an open tool_use/tool_result pair.
It's a staircase, not a ramp. With keepTail = 4, collapseChunk = 50:
messages.length |
rawCutoff |
quantized cutoff |
what's imaged |
|---|---|---|---|
| 54 | 50 | 50 | msgs[0..50) |
| 80 | 76 | 50 | msgs[0..50) ← same |
| 103 | 99 | 50 | msgs[0..50) ← same |
| 104 | 100 | 100 | msgs[0..100) ← jumps |
For ~50 turns the collapsed set is a fixed set of messages → byte-identical
PNG → the prefix reads warm (cache_read, 0.1×) the whole time. New content
piles up in the text tail; it only crosses into "imaged" when the conversation
passes the next multiple of 50.
historyImageSha8 (transform.ts) logs the image hash per request precisely so
this is verifiable from events.jsonl: while the boundary holds, consecutive
collapsed turns MUST report an identical history_image_sha8. A hash that
moves turn-over-turn is the signature of the bug returning.
4. The one-time burn, and the gates that control it
A chunk crossing changes the imaged region's bytes once → that turn pays a fresh
cache_create. This is the one-time burn. It is:
-
One-time per stable window, not per turn — ~one create per
collapseChunkturns, amortized over the warm reads in between. -
Gated both ways so it only happens when it pays back:
Symmetric burn term (
isCompressionProfitable):burnImageSide = priorWarmTokens × (CACHE_CREATE_RATE − CACHE_READ_RATE) // ≈ 1.15× the warm prefix given up compress iff imageTokens + burnImageSide < textTokens + burnTextSideHistory amortization gate (
isCompressionProfitableAmortized):accept iff I × (CC + CR×(N−1)) < T × CR × N CC = 1.25, CR = 0.10where
N = historyAmortizationHorizon("assume this prefix gets reusedNmore times"). DefaultN = 1, which is deliberately conservative — atN = 1collapse almost never wins, so a host raisesNonce it has observed that its cache lives long enough to amortize the create. AtN ≈ 10collapse wins once the image is below ~0.7× the text. -
Cold-start free. On turn 1 / a fresh conversation,
priorWarmTokensandpriorWarmImageTokensdefault to0, zeroing the burn term entirely — there's no warm cache to lose, so imaging from the start breaks nothing.
5. The unifying principle: the cache mark is the seam
Everything above collapses into one rule:
Byte-stable content goes before the cache mark; per-turn-volatile content goes after it. "One-time" is defined relative to that mark.
pxpipe never adds a breakpoint — Task #21: it relocates the caller's
existing cache_control marker onto the last static image produced from the
content that marker covered (transform.ts; doc: "the marker rides the last
image"). So the breakpoint lands exactly at the stable↔volatile seam:
[ intro / static slab image(s) ] ← stable
[ last static image ] ← cache_control ◄── the mark (relocated, not added)
─────────────── cache breakpoint ───────────────
[ end-marker + dynamic <env> + billing line ] ← per-turn, changes every turn
[ history image, current user content ] ← after the mark
Two conditions make the burn one-time, and the layout arranges both:
- Everything up to the mark is byte-identical across turns — guaranteed by the quantized boundary (§3).
- Everything volatile sits after the mark — the billing line, dynamic
<env>, and current user message are spliced after the breakpoint, so they never pollute the prefix cache key.
When both hold, the prefix up to the mark reads warm every turn, and the prefix re-keys only when the bytes at/before the mark genuinely change — i.e. the initial text→image flip and each chunk crossing. That's the one-time create; everything in between is a warm read.
Why the slab is protected
The leading slab-bearing user message is shielded from collapse via
protectedPrefix (transform.ts: slabAnchorIdx + 1). If history collapse swept
it in, blocksToText would reduce the system-prompt/tool-docs images to
[image] placeholders and the slab's cache_control anchor would vanish — so
every grid-crossing would invalidate the whole prefix. Keeping it out of the
collapse range pins it at the front as the stable cache anchor and places the
history image after it.
6. This is just Claude Code's own model
Claude Code natively ships a big stable prefix (system prompt, tool docs,
<system-reminder>s, older history) terminated by a cache_control breakpoint,
followed by a thin per-turn tail. pxpipe preserves that exact shape. It only:
- Swaps the stable prefix's representation — verbose text → byte-stable image.
- Moves the mark with it (relocate, not add) so the seam stays in the same logical place and pxpipe spends none of the 4-breakpoint budget.
- Quantizes the history boundary so the imaged region's bytes change only at chunk crossings, keeping the native byte-stability Claude Code relied on.
The one-time-ness isn't luck — it's enforced by anchoring the mark to the last stable image and pushing all per-turn churn past it.
7. Defaults (source of truth: HISTORY_DEFAULTS in history.ts)
| Option | Default | Meaning |
|---|---|---|
keepTail |
4 | Most-recent turns always kept as text (live tail). |
minCollapsePrefix |
10 | Don't bother collapsing fewer than this many turns. |
collapseChunk |
50 | Grid the boundary snaps to → byte-stable image between steps. |
protectedPrefix |
0 | Leading messages never collapsed (set to slab anchor + 1). |
cols |
100 | Soft-wrap column hint (history renders dense single-col). |
Related (in TransformOptions):
| Option | Default | Meaning |
|---|---|---|
HISTORY_CHARS_PER_TOKEN |
2.0 | History cpt fit (Opus 4.7 / Fable 5 tokenizer). |
historyAmortizationHorizon |
1 | N in the amortization gate; raise once cache is proven long-lived. |
Knob intuition
- Larger
collapseChunk→ fewer flips/burns (cheaper caching), but more un-imaged text sitting in the tail (less compression). - Smaller
collapseChunk→ more aggressive compression, but more frequent burns. - Larger
historyAmortizationHorizon→ more willing to eat a create now for warm reads later; only safe when the cache actually lives that long.
8. One-paragraph summary
We image historical turns always-on, but "old" is a quantized boundary
(floor((len − keepTail) / collapseChunk) × collapseChunk, snapped to a
tool-closed point), so the imaged region's bytes stay identical for a whole
collapseChunk window and the prefix reads warm every turn. New content ages
into the image only at chunk crossings; each crossing costs a single one-time
cache_create, gated by a symmetric burn term and an amortization horizon so it
only fires when the warm reads pay it back. The whole thing works because the
caller's cache_control mark is relocated (never added) onto the last stable
image, putting every byte-stable thing before the seam and every per-turn-volatile
thing after it — which is exactly the prefix-cache shape Claude Code already
relies on.