Claude Code re-injects the same long <system-reminder> blocks (CLAUDE.md
hints, task-tools reminders, the dynamic skills list, etc.) into the
FIRST user message on every turn. They're text, so they're not part of
the system+tools image we already cache — they hit the wire fresh every
turn. This rewrite renders any reminder block ≥ minReminderChars (1000
by default, matching legacy/python/proxy.py) to PNG images placed inside
the user message content array, just like the static slab.
Why: the system+tools image is the only cache_control breakpoint we're
allowed (Anthropic caps at 4 and Claude Code uses the others). Per-block
reminder images therefore carry NO cache_control — they're per-turn
content that the model OCRs without prefix-cache benefit. The win is
token cost, not cache: reminders are 65-73% cheaper as PNG than as text,
and a typical turn has 1-3 of them.
Layout inside the first user message after compression:
[intro text] ← static (helps OCR framing)
[image block(s)] ← static; LAST has cache_control
↑ cache breakpoint (the only one)
[End of rendered context.] ← static text closer for the image
[processed existing content] ← per-turn (incl. reminder images)
Wire-up:
- src/core/transform.ts: new TransformOptions.compressReminders (default
true) + minReminderChars (default 1000). Two new helpers,
textToImageBlocks() (wraps renderTextToPngs + makeImageBlock with
no cache_control) and approxBlockBytes() (b64 → byte-count for the
imageBytes telemetry, no second base64 round-trip). The placement
='user' branch now walks `existing` and rewrites long reminders.
- src/core/tracker.ts: TrackEvent.reminder_imgs flows from
TransformInfo.reminderImgs through toTrackEvent so `pixelpipe stats`
and the dashboard can attribute compression contributions.
- src/worker.ts: COMPRESS_REMINDERS + MIN_REMINDER_CHARS env vars.
- src/node.ts: matching CliOpts, parseCli cases (--no-reminders,
--min-reminder-chars), help text, env-var summary, and `rem+N` in
the per-request console line when reminder_imgs > 0.
Tests:
- "compresses long <system-reminder> blocks in the first user message"
confirms info.reminderImgs ≥ 1, the reminder text is gone from
user-content text blocks, the user's actual prompt survives, and the
new reminder image blocks carry NO cache_control.
- "leaves short <system-reminder> blocks alone (below minReminderChars)"
confirms info.reminderImgs is 0 and the reminder text passes through
as text.
Ports the live dashboard from legacy/python/proxy.py into TypeScript.
Node-only by design — the Workers host doesn't expose a dashboard (use
Workers Logs / Logpush for that).
New module src/dashboard.ts:
- DashboardState keeps a 50-entry ring buffer of recent requests, plus
running totals for the savings-vs-baseline computation (same formula
as the Python: input + cache_create*1.25 + cache_read*0.10). One
latest PNG is held in memory for the preview pane.
- Replay-on-startup: reads the tail of ~/.pixelpipe/events.jsonl so the
UI doesn't show an empty table after a process restart. Cumulative
totals are NOT replayed (file may have rotated; double-counting beats
starting from zero badly here? — no, starting fresh is the lesser
evil after rotation, hence "best-effort replay of the ring buffer
only").
- Four HTTP handlers: serveHtml (the inline dashboard page),
serveStats (JSON aggregate), serveRecent (JSON ring buffer),
servePng (the latest preview image). dashboardPath() classifies a
path so the Node server can intercept before the proxy handler.
- Inline HTML uses CSS object-position to crop the preview at the
image's native resolution — no server-side PNG decode needed.
Plumbing changes:
- src/core/transform.ts: capture info.firstImagePng / firstImageWidth /
firstImageHeight after rendering. NOT persisted to JSONL; toTrackEvent
drops them. Memory cost is bounded (only the latest image survives,
the dashboard replaces on every update).
- src/node.ts: import DashboardState, instantiate, seed via replay,
call dashboard.update(e) inside onRequest BEFORE tracker.emit (the
tracker strips firstImagePng). The HTTP handler intercepts the four
dashboard paths and answers from DashboardState; everything else
falls through to the proxy.
- src/node.ts / src/worker.ts: bump the CLI/env default for PLACEMENT
from 'system' to 'user' so the runtime path matches transform's
default (e0950ef changed only the library default). Without this, the
CLI's ?? 'system' fallback would silently override the new default
and re-introduce the 400 we just fixed.
Open the dashboard at http://127.0.0.1:<port>/ once the proxy is running.
Two protocol-correctness fixes that have to ship together.
1. **Default placement: 'system' → 'user'.** Anthropic's `system` field is
typed as `string | TextBlock[]` — image blocks there come back as
`400 system.N.type: Input should be 'text'`. Images must go into a
user-message content array.
2. **ttl='1h' on the image's cache_control.** Anthropic enforces the rule
"ttl='1h' must not come after ttl='5m'" in processing order
(tools → system → messages). Claude Code marks its own user-message
content with ttl='1h'; if we leave ttl unset it defaults to '5m', our
block lands first in processing order, and the request 400s the moment
Claude Code's own 1h breakpoint shows up later in the conversation.
The existing image-placement tests now look in messages[0].content instead
of out.system, and a new test pins the ttl='1h' invariant directly.
Reads ~/.pixelpipe/events.jsonl (or --file / PIXELPIPE_LOG) and prints
a one-screen summary of how the proxy is doing:
- request counts by status bucket (2xx/4xx/5xx)
- compression rate (compressed vs passthrough)
- latency p50/p95/p99 (duration + first-byte separately)
- aggregate Anthropic token usage (input/output/cache_create/cache_read)
- cache hit rate computed two ways (by tokens, by events)
- top skip reasons (so we see WHY a request wasn't compressed)
- top cwds with per-project compression ratio
- system_sha8 distribution → 'reuse rate' = how often the cached
payload is being re-sent. High reuse + low cache_read means
something is breaking cache_control.
- unknown_static_tags warning section if Claude Code shipped a new
dynamic tag that we're silently baking into the image.
--json mode dumps the raw Summary as JSON for downstream pipelines.
Streaming reader so a 100 MB log file doesn't OOM. Pure aggregator
(fold/newSummary/renderTextReport) is unit-tested; only the file IO
shell is untested.
Dispatched from src/node.ts BEFORE parseCli so its flags don't collide
with the proxy's flags. The CLI shim in bin/cli.js stays unchanged.
Tests: +6 (status bucketing, compressed/passthrough split, token agg,
cwd & sha histogram, unknown-tag collection, text report rendering).
Node host now appends one JSONL event per request to
~/.pixelpipe/events.jsonl (override with --events-file or PIXELPIPE_LOG).
- Size-based rotation at 100 MB (old file → events.jsonl.1).
- Sync writes — overhead is ~1ms, dwarfed by upstream latency.
- Failures degrade silently: a broken disk disables the tracker but
never breaks a request.
- Flushed and fsync'd on SIGINT/SIGTERM so the last batch isn't lost.
- Opt out via --no-track or PIXELPIPE_TRACK=0.
Worker host uses JsonLogTracker with console.log; Cloudflare ingests
this as Workers Logs, and Logpush can ship it to R2/S3 as JSONL —
same shape Node writes locally, so analysis tooling works on either.
Console log line on both hosts now also includes tokens/cache_read
inline (e.g. 'compressed 4799ch → 2img/12343B tokens=1234+56
cache_read=800') so cache effectiveness is visible at a glance even
without parsing the JSONL.
Unknown-tag canary fires a stderr warning when info.unknownStaticTags
is non-empty — surfaces Claude Code releases that introduce new
dynamic tags before they tank the cache hit rate.
Help text and HANDOFF will be updated in the next commit; for now the
new flag drops the need for --exclude-dynamic-system-prompt-sections.
Tests: +5 (toTrackEvent flattening, JsonLogTracker sink behavior, error
swallowing, minimal-event handling, noop).
Two cheap guardrails based on architectural review:
1. Determinism test: renders the same input twice and asserts byte
equality of the produced PNGs. Without this guarantee, identical
system prompts on consecutive turns would yield different image
bytes, silently destroying the cache hit rate that justifies the
whole proxy. The test currently passes — locking it in so a future
refactor can't regress it without noticing.
2. Unknown-tag canary: splitStaticDynamic now sniffs the *static* slab
for any <tag>...</tag>-shaped block whose name isn't in
DYNAMIC_BLOCK_TAGS. Surfaced via info.unknownStaticTags and emitted
on the tracker event as unknown_static_tags. If Claude Code ships a
new per-turn tag in a future release, it'll show up in logs within
hours instead of being silently baked into the cached image (where
it would tank cache hit rate with no error).
Also adds tracker.ts (Tracker interface, TrackEvent shape,
JsonLogTracker, noopTracker, toTrackEvent normalizer). Hosts will wire
these in the next commit.
ProxyEvent gains two new fields:
- usage: Anthropic's input/output/cache_creation/cache_read tokens.
- firstByteMs: ms to upstream response headers (durationMs now measures
end-to-end since the event fires after usage is extracted).
Implementation (teeForUsage):
- Tee the response body. Client gets one side immediately; we read the
other in the background.
- SSE: scan up to 64 KiB for 'event: message_start' + the data: payload.
Usage is always in the first event so this terminates within ms of
upstream first byte.
- JSON: buffer up to 4 MiB and parse.
- Error responses (status >= 400) and bodyless responses skip extraction.
- Drains the tee fully in the background to avoid backpressure.
Without this, the proxy had no way to validate its own value
proposition. Now we can compute cache hit rate, real input-token
savings, etc. directly from log events.
Tests: +3 (JSON usage, SSE message_start usage, error skips extraction).
Three short hashes carried in TransformInfo:
- systemSha8: sha256[0..8] of the actual image-bound text. Stable
across turns proves cache_control is doing its job. If it drifts
unexpectedly, that's a cache leak to investigate.
- claudeMdSha8: hash of just the CLAUDE.md slab (heuristic header
detection). Lets us bucket requests by project even when cwd is
absent or anonymized.
- firstUserSha8: hash of first user message (4 KiB cap). Rough
thread id since the wire protocol has none.
All async via Web Crypto so Node + Workers share one impl. Hashing is
in parallel via Promise.all; ~0.1ms overhead.
+2 tests (systemSha8 invariant under dynamic-block change,
firstUserSha8 format).
New EnvFields type and extractEnvFields() helper. Parses the <env>
block Claude Code injects, plus 'On branch' / 'Branch:' lines that
appear in <git_status> or context blocks. Read-only — telemetry signal
only, never mutates the request.
Per-project compression ratios, repeat-CWD detection, today's-date
drift checks all become possible from logs now.
+2 tests (full env block extraction, no-env-no-info).
Claude Code injects per-turn blocks (<env>, <context>, <git_status>,
<directoryStructure>, <system-reminder>) into the system prompt.
Previously those got rendered into the image alongside the static
slab (CLAUDE.md, agent defs, tool docs), which kills Anthropic's
prompt cache: image bytes drift turn-to-turn → 0% cache hits.
Now we split:
- static (cacheable) → image with cache_control: ephemeral
- dynamic (per-turn) → plain text block AFTER the image
- billing line → also AFTER the image (was BEFORE, also a
cache-killer — bug fix as a side effect)
Net effect: stable cache key on the big slab, model still sees cwd /
branch / today's date. Removes the need for users to pass
--exclude-dynamic-system-prompt-sections to claude.
TransformInfo now carries staticChars / dynamicChars / dynamicBlockCount
for downstream telemetry.
Tests: 8 existing pass unchanged + 3 new (env survives as text after
image, cache_control on image only, all-dynamic input is pass-through).
README: Node + Workers quickstart, config table, architecture diagram,
limitations, dev commands. Legacy Python pointer.
CLAUDE.md: agent orientation — file layout, constraints (no node: in core,
no new runtime deps, don't hand-edit atlas.ts), verification
recipe, Anthropic API constraints we can't regress.
HANDOFF.md: current status (TS port lands, parity not yet verified),
what works, what doesn't, key design decisions, prioritized
list of follow-ups (parity test, live savings test, Workers
deploy, delete legacy/).
Project rename for the Show HN launch with framing:
'pixelpipe — what if Opus saw a UI instead of just append-only logs?'
Renamed everywhere:
- kebab-case 'claude-image-proxy' -> 'pixelpipe' (42 occurrences)
- snake_case 'claude_image_proxy' -> 'pixelpipe' (5 occurrences)
- 14 files touched: package.json, Cargo.toml, Cargo.lock, *.md,
bin/cli.js, scripts/install.js, src/proxy.py, src/rust/src/main.rs,
src/rust/examples/{render_sample,transform_only,diff_render}.rs,
src/zig/build.zig
Effects on build artifacts:
- npm package name -> 'pixelpipe'
- Rust binary name -> target/release/pixelpipe (was claude-image-proxy)
- Python cache dir -> ~/.cache/pixelpipe/ (was ~/.cache/claude-image-proxy/)
NOT YET DONE in this commit (Tier 2+3, handled in next commit):
- Repo on-disk directory still ~/Downloads/repos/claude-image-proxy/
- Cache dir on disk still ~/.cache/claude-image-proxy/ — Python proxy
currently writes here. Will be migrated alongside the running services
in a coordinated stop/move/restart sequence.
Validation: scripts/diff_transform.py still produces exactly 1 structural
diff (the PNG bytes, expected — different fonts Python/Menlo vs
Rust/JetBrains Mono). No regression from the rename.
Three bug fixes surfaced by scripts/diff_transform.py harness, which
synthesizes a realistic Claude Code request body and runs it through both
Python transform_request() and Rust transform(), then JSON-structurally
diffs the outputs.
Before these fixes, harness found:
- 2 structural diffs in the transformed body JSON
- 4+ TransformInfo divergences
After these fixes, harness finds:
- 1 structural diff: $.messages[0].content[1].source.data
(the base64 PNG, expected to differ — Menlo vs JetBrains Mono fonts)
- TransformInfo still differs on byte-vs-char counts (telemetry-only)
Fixes:
1. src/rust/src/transform.rs:93 — per-tool markdown header
was: '### Tool: {name}\n{desc}'
now: '#### Tool: {name}\n{desc}\n'
Matches proxy.py:230,233. Also restores the trailing newline that
Python emits between tool entries.
2. src/rust/src/transform.rs:123 — tool section markdown header
was: '\n\n# Tool Documentation\n\n'
now: '\n\n#### Tool Documentation\n\n'
Matches proxy.py:248.
3. src/rust/src/transform.rs:265 — preserve system field by default
was: always replaced system with billing_line OR remainder
now: only replaces if billing_line was stripped, otherwise leaves
the original system field untouched.
Matches proxy.py:417 which intentionally leaves system alone in the
default PLACEMENT="user" path 'so Anthropic still has guardrails'.
The image lives in messages[].content as a side-channel; system stays
as original text. This is the behavior our 95+ live Python requests
have validated against Anthropic's API.
4. src/rust/src/lib.rs — expose 'transform' module so harnesses and the
transform_only example can call transform() without going through HTTP.
Added:
- src/rust/examples/transform_only.rs — CLI binary that reads a body
from a file and writes the transformed body + TransformInfo as JSON.
- scripts/diff_transform.py — Python harness that calls both Python
transform_request() and the Rust transform_only binary, then runs a
structural JSON diff with field-by-field TransformInfo comparison.
Validation:
python3 scripts/diff_transform.py
# → 'found 1 structural diff'
# → $.messages[0].content[1].source.data (the PNG, expected)
Remaining known telemetry-only diffs (not blocking the swap):
- system_text_chars: Python counts Unicode chars, Rust counts UTF-8 bytes
- tool_text_added: same UTF-8 issue
- expected_image_tokens: ~30% higher in Rust due to ab_glyph 4px cell
vs PIL 3px cell — Rust images are 33% wider for the same text. Rust
PNG bytes are still smaller overall because of the tight-bound height
(which Python also has now, after the previous baseline commit).
- placement, png_sha8 fields missing from Rust TransformInfo
- min_chars threshold uses bytes (Rust) vs chars (Python)
These can be addressed later for full telemetry parity but the
upstream-visible behavior is byte-equivalent.
src/proxy.py:125 was computing image height as
tallest = max(lines_per_col, last_col_lines) if c_needed == 1 else lines_per_col
The max(lines_per_col, ...) made every single-column render pad to the
full MAX_EDGE=1568 canvas height regardless of content, wasting ~99% of
the image-token budget for small prompts.
Fix: tight-bound the single-column case.
tallest = max(1, last_col_lines) if c_needed == 1 else lines_per_col
Multi-column (c_needed > 1) is unchanged — all earlier columns must be
lines_per_col tall to fit them, so the image must be too.
Measured impact (scripts/visual_compare.py):
sample before after change
----------- ------------ ------------ -----------
17 chars 241x1568 241x14 -99% pixels
176 chars 241x1568 241x42 -97% pixels
885 chars 241x1568 241x315 -80% pixels
Verified edge cases hold:
- empty/short text: still gated by MIN_COMPRESS_CHARS
- 5000-char single line: 241x441 (63 wrapped lines, tight)
- 80-char boundary: 241x14 (2 lines, tight)
- 500-line multi-column: 731x1568 (unchanged, correctly padded)
- 20KB realistic system prompt: 68.5% savings (matches existing 65-73%)
Added scripts/visual_compare.py to inspect Python vs Rust renderings
side-by-side with labels and dimensions.
This is the immediate win that's independent of the Rust rewrite — every
proxied request gets fewer image tokens starting at the next proxy restart.
Functional reimplementation in Rust with the same render_chunks/transform
public surface as src/proxy.py. Compiles clean on darwin-arm64, smoke-
tested but NOT YET battle-tested against real Claude Code sessions.
Layout:
src/rust/
Cargo.toml hyper + tokio + reqwest + ab_glyph + png + clap
Cargo.lock committed (application crate, deterministic builds)
src/main.rs clap CLI matching bin/cli.js flags
src/lib.rs module roots
src/font.rs ab_glyph rasterization of bundled JetBrains Mono
src/render.rs newspaper-column text -> PNG (matches Python algo)
src/transform.rs request body transformation (system prompt -> image)
src/proxy.rs hyper HTTP server, upstream forwarding
src/stats.rs persisted savings counters mirroring proxy.py
assets/JetBrainsMono-Regular.ttf OFL-licensed, legally embeddable
examples/render_sample.rs smoke test: render fibonacci snippet
examples/diff_render.rs diff harness companion (paired with
scripts/diff_render.py)
Diff harness:
scripts/diff_render.py feeds same text through Python and Rust
render_chunks, decodes both PNGs, compares
pixel-by-pixel. First run found two divergences:
1. Rust font.rs:43 uses ab_glyph h_advance which returns ~3.4px ceiled
to 4 for Menlo-5pt 'M'; PIL's getlength('M') returns 3.0 exactly.
Every Rust column is 33% wider than Python's.
2. Python proxy.py:125 pads every single-column image to MAX_EDGE=1568
height regardless of content (already noted in baseline commit).
Neither is fixed yet — surfacing the divergences before any swap. Next:
visual inspect both PNGs to confirm Rust output is at least OCR-readable,
then decide whether to fix bug-for-bug compat or correct both sides.
bin/cli.js still spawns Python; this commit only adds the Rust tree, it
does NOT change runtime behavior. The live proxy at :47821 is unaffected.
Token-saving HTTP proxy for Claude Code that renders system prompt + tools
as 5pt Menlo monospace images. Achieves 65-73% token reduction with 100%
reasoning quality preserved on Opus 4.7.
Production state at this commit:
- src/proxy.py: 972 lines, battle-tested with 46+ real Claude Code sessions
observed (~551k tokens saved, ~$8.27 in stats.json as of commit time)
- bin/cli.js: Node wrapper that spawns Python proxy on port 47821
- scripts/gen_atlas.py: generates the bundled Menlo 5pt glyph atlas
- scripts/install.js: postinstall — verifies Python + Pillow + httpx
- src/zig/: earlier Zig 0.16 renderer (kept for reference, not built)
- CLAUDE.md, HANDOFF.md: contributor notes
- package.json: npm entry point, version 0.1.0
Known issue (deferred for separate fix):
src/proxy.py:125 — `tallest = max(lines_per_col, last_col_lines) if
c_needed == 1 else lines_per_col` pads every single-column image to
full MAX_EDGE=1568 height regardless of content, wasting image-token
budget for small inputs. Tight bounding would improve savings further.
This is the rollback point. Any subsequent changes (Rust port, Python
fixes, etc.) build on this proven baseline.