Rip the compressHistory opt-in and run Variant C history-image collapse on
every request that has a closed-prefix run. There is now ONE codepath: no
toggle, no env switch, no per-request opt-in. The reason the option was
defaulted-off was a -250% live measurement on 2026-05-19 (commit c3506e3 era);
that wasn't a "marginal upside" problem, it was a math bug. This commit
fixes the bug and turns the feature on.
Root cause of the -250% loss
----------------------------
`isCompressionProfitable()` has two paths: a row-aware path (string input,
matches `renderTextToPngs` image budgeting exactly) and a looser chars-only
path (number input, assumes dense lines). History.ts was calling it with
`text.length` (number) → loose chars-only estimate. History text is
newline-heavy (`--- role ---` headers, JSON arg blocks, [tool_use] /
[tool_result] labels), so the chars-only estimate under-counted images by
5-10× and let net-losers through the gate.
Secondary contributor: tool_use args were serialised with
`JSON.stringify(input, null, 2)` — pretty-printed, one short row per JSON
field. At cols=100 each field claimed a full row instead of wrapping at
~100 chars, inflating image count for tool-heavy histories.
Changes
-------
- src/core/history.ts
- `ProfitableFn` signature: `(text: string, cols: number)` (was `textLen: number`).
Documented the asymmetry so callers can't regress this.
- `blocksToText` tool_use: compact `JSON.stringify(input)`, no indent.
- Pass `text` (not `text.length`) into the profitability check.
- src/core/transform.ts
- Delete `compressHistory`, `historyKeepTail`, `historyMinPrefix` from
TransformOptions and DEFAULTS.
- History collapse is now unconditional when `messages[]` is non-empty.
- Wrap the gate in a closure that pins `numCols=1` + per-request `cpt`,
so the gate's row-aware decision is byte-identical to the single-col
renderer's actual image count.
- src/worker.ts: drop `COMPRESS_HISTORY`, `HISTORY_KEEP_TAIL`,
`HISTORY_MIN_PREFIX` env vars from `Env` + transform options.
- src/node.ts: stale comment refresh — no toggles, period.
- tests/history.test.ts:
- Rename describe "transformRequest + compressHistory" →
"transformRequest history compression (always-on)".
- Remove every `compressHistory: true, historyKeepTail: X, historyMinPrefix: Y`
from transformRequest calls — options no longer exist.
- Bump fixtures for default `keepTail=4` / `minCollapsePrefix=10`
(14-turn body, 3500 chars/turn) so the now-honest row-aware gate
accepts. Renamed "8-closed + 2-live" → "10-closed + 4-live" to match
the new defaults.
- Bump straddle-test per-turn body 2500→3500 chars: tool block labels
add ~65 chars of header overhead that pushed the tighter fixture
under the row-aware boundary.
- Rename "JSON-pretty args" test → "COMPACT JSON args"; assert no
pretty indentation.
Verification
------------
- npm test → 263/263 green
- npm run typecheck → clean
- npm run build → dist/node.js builds clean
pixelpipe
A token-saving proxy for Claude Code that renders the system prompt + tool definitions as images, so Claude OCRs them instead of paying for them as text. 65-73% input-token savings on Opus 4.7, 100% reasoning quality preserved, identical fixed text every turn for a clean prompt-cache.
Runs on Node 18+ and Cloudflare Workers from the same source.
How it works
┌─ original ────────────────────┐
│ ~68K input tok │
Claude Code ──► pixelpipe ──► │ (system + tools as text) │ ──► Anthropic
│ └───────────────────────────────┘
└──────► ┌─ via proxy ───────────────────┐
│ ~3.5K input tok │
│ (system + tools as PNG + │
│ prompt-cache breakpoint) │
└───────────────────────────────┘
↓ Anthropic vision OCR
100% reasoning quality retained
The proxy intercepts POST /v1/messages, pulls the system prompt + tool
documentation out of the JSON body, renders it into one or more grayscale
PNGs using a build-time-generated GNU Unifont glyph atlas (covers ~35k
BMP codepoints by default — Latin, Cyrillic, Greek, CJK, Hiragana,
Katakana, Hangul, Hebrew, Arabic, math symbols, box drawing, decorative
symbols), and substitutes those PNGs back in as image content blocks
with an ephemeral cache_control breakpoint.
Token math (Opus 4.7, real Claude Code workflow):
| metric | original | via proxy | savings |
|---|---|---|---|
| Cold input tokens | ~68K | ~3.5K | 95% |
| Cache-warm input tokens | ~7.5K | ~3.5K | 53% |
| Per-call median (mixed) | - | - | 65-73% |
| Per-image OCR quality vs txt | - | - | ~99.5% |
Quick start (Node)
npm install
npm run build # produces dist/node.js
node bin/cli.js # listens on 127.0.0.1:47821 by default
After editing code, restart in one step:
pnpm run restart # graceful SIGTERM → rebuild → start
pnpm run restart -- --no-build # skip rebuild (dist/ is fresh)
PORT=47822 pnpm run restart # override listen port via env
pnpm run restart does, in order:
- Lists every running pixelpipe PID (via
pgrep) and SIGTERMs them all. Orphans from prior crashed sessions are cleaned up too. - Waits up to 5s for graceful exit (the SIGTERM handler flushes the JSONL tracker). Escalates to SIGKILL only if anything's still alive.
- Runs
pnpm run build. Build failures abort the restart — the script refuses to start a stale binary. Pass--no-buildto skip when you knowdist/is fresh. - Checks the target port is free. If it isn't, names the holding process and refuses to start (cheaper than a crashed Node stacktrace).
execsnode bin/cli.jsin the foreground so Ctrl-C reaches Node. The proxy takes no behavioral flags — env vars only (see Configuration).
Point Claude Code at it:
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 \
claude --exclude-dynamic-system-prompt-sections
That's it. Use Claude Code normally.
The --exclude-dynamic-system-prompt-sections flag suppresses the small
per-turn variable section so the rendered image stays byte-identical
across turns — that's what makes the prompt cache actually hit.
Quick start (Cloudflare Workers)
npx wrangler dev # local dev on :8787
npx wrangler deploy # ship to *.workers.dev
Then in Claude Code:
ANTHROPIC_BASE_URL=https://pixelpipe.<your-account>.workers.dev \
claude --exclude-dynamic-system-prompt-sections
You can attach a custom hostname and route in wrangler.toml.
Configuration
The proxy runs with a single codepath. Every compression mode is on, every break-even threshold is at its measured-best value, and tuning parameters are not user-adjustable. The only configurable surface is where to listen, what to proxy, and where to log — env-var only, no CLI flags.
| env var | default | meaning |
|---|---|---|
PORT |
47821 |
Node only — listen port |
ANTHROPIC_UPSTREAM |
https://api.anthropic.com |
upstream API base |
PIXELPIPE_LOG |
~/.pixelpipe/events.jsonl |
persistent event log |
In Workers, set the optional upstream API key with:
npx wrangler secret put ANTHROPIC_API_KEY
If unset, the proxy forwards whatever x-api-key the client sent.
Architecture
src/
├── core/ 100% runtime-agnostic (Web Standard APIs only)
│ ├── atlas.ts (generated) sparse Unicode atlas, base64-inlined
│ ├── png.ts minimal grayscale PNG encoder
│ ├── render.ts text → PNG bytes
│ ├── transform.ts request body rewriter
│ ├── proxy.ts the fetch handler
│ └── types.ts Anthropic API types
├── node.ts node:http adapter + CLI
└── worker.ts export default { fetch }
scripts/
├── gen-atlas.ts build-time: OTF → atlas.ts (uses @napi-rs/canvas)
└── build.mjs esbuild bundler for Node target
assets/
├── Unifont-16.0.04.otf primary font (~35k BMP codepoints w/ full-bmp profile)
├── UNIFONT_LICENSE.txt OFL + GPL-with-font-exception
└── JetBrainsMono-Regular.ttf legacy / ASCII-only fallback (kept on disk)
The atlas is generated at build time from Unifont-16.0.04.otf,
base64-inlined into a .ts file with sparse codepoint + offset tables
(binary-packed), and shipped with the bundle. At runtime there are zero
external files to read and zero non-Web-Standard imports — that's the
only way this works in Workers without per-request asset fetches.
Regenerate the atlas (after swapping fonts, sizes, or codepoint profile):
pnpm run build:atlas # default: full-bmp (~35k cp, all BMP Unifont covers)
ATLAS_PROFILE=practical pnpm run build:atlas # drops Hangul (~24k cp; for Workers free-tier)
FONT_PX=12 pnpm run build:atlas # nondefault size; verify cells
Limitations
- The bundled GNU Unifont at 10px (cell 5×11 px Latin, 10×11 CJK) is
Anthropic-OCR-clean for ~35k BMP codepoints by default (
full-bmpprofile): Latin, Cyrillic, Greek, CJK Unified Ideographs, Hiragana, Katakana, Hangul, Hebrew, Arabic, math symbols, box-drawing, arrows, Dingbats, Letterlike Symbols, Enclosed Alphanumerics, etc. Drops for codepoints outside the profile (e.g. emoji 😀 — supplementary plane) get counted inevents.jsonl#dropped_chars(with the top-20 broken out asdropped_codepoints_top) so you can spot patterns. For Workers free-tier deployments under the 1 MB compressed-bundle cap, switch toATLAS_PROFILE=practical pnpm run build:atlas(~24k cp; drops Hangul). Right-to-left scripts render left-to-right in source order (no bidi shaping); Devanagari / Thai / similar complex shaping is also unsupported. - Compression sets a 5-minute prompt-cache TTL. Adding
cache_control: ephemeralcauses warm-cache rotation, not eviction. - A 5KB break-even point: if input is
< MIN_COMPRESS_CHARSchars we skip compression entirely (overhead would exceed savings). - Per-machine font: regenerate the atlas if you swap fonts. The
generated
src/core/atlas.tsis checked in so consumers don't need@napi-rs/canvasto install. - Workers CPU limit: this is fine for free-tier (10ms CPU) on small prompts; large prompts (>30K chars) may need the paid tier.
Development
npm install
npm run dev:node # tsx watch on src/node.ts
npm run dev:worker # wrangler dev
npm run test # vitest
npm run test:watch
npm run typecheck # tsc --noEmit
pnpm run build:atlas # regenerate src/core/atlas.ts from OTF
npm run build # build dist/node.js
npm run deploy:worker # wrangler deploy
License
MIT.