- README: Fable clip as headline demo (Drive link + committed thumb),
verified numbers from the recording: plain $42.21 / 96% context vs
pxpipe $4.51; honest caveat on single-reply format compliance
- ATTEMPTS.md: attempt 1 (invalid run, model-switch pitfall) and
attempt 2 (legibility PASS on fable, format miss documented)
- a.sh/b.sh: prompt fix — numbering starts at filler-000
Measured 2026-07-01 (count_tokens sweep, claude-sonnet-4-5; see
docs/LEGIBILITY-AUDIT-2026-07-01.md): the API downscales every image to fit
BOTH long-edge ≤1568 AND ~1.15 MP (≈1,143,750 px), then bills ≈px/750
(~1525 tok/img cap). The old 1932×1932 page hit the cap but was resampled
0.555× before the vision encoder, so 5×8 glyphs arrived at ~2.8×4.4 px — the
root cause of the legibility misses this session.
New page shape 1568×728 = 1,141,504 px fits both bounds → glyphs reach the
encoder WYSIWYG (and still ≤2000 px/side for >20-image requests).
render.ts geometry:
MAX_HEIGHT_PX 1932 -> 728
MAX_WIDTH_PX 1932 -> 1568
DEFAULT_COLS 313 -> 312 (1568 px exactly; 313=1573 would 0.997× blur)
DENSE_CONTENT_COLS 384 -> 312
READABLE/DENSE chars 50000/92160 -> 28080 (312×90, tracks real pagination)
Decouple the GPT path: OpenAI resizes differently (2048 bbox, 768 short side),
so a 768-wide strip up to 2048 tall survives un-resampled. Add
GPT_MAX_HEIGHT_PX = 1932 (gpt-model-profiles.ts) where the built-in cost
numbers were calibrated; openai-history.ts now uses it instead of the
now-Anthropic-specific MAX_HEIGHT_PX. Doc-only touch-ups in library.ts/export.ts.
tests/render + tests/paging updated to the new geometry (171 pass).
A fresh same-session prior within the TTL is no longer sufficient to price
the text counterfactual warm: deriveBaselineWarmth now also requires the
static-prefix hash (system_sha8) to match. When opencode rotates the system
prompt / tool docs mid-session, the cacheable prefix changes, so a text-only
client would hit a new provider cache key too — pricing it warm against a
cold actual fabricated a huge phantom "loss" (the dashboard's 800%-worse
report). cr>0 still rescues a genuine warm read with no in-memory prior.
Wired through update(), replay(), and aggregateSessions via system_sha8.
Also surface losses honestly in the recent table (Saved/lost: negative
deltas in red instead of hidden as "—") and clarify headings as
billing-equivalent input tokens. Docs updated to match.
The savings accounting priced the text baseline as cold whenever pxpipe's own
image cache missed this turn (cr==0), fabricating a 1.25x create the text path
would never have paid and inflating reported savings ~2x. Decouple them: the
text prefix reads warm whenever a fresh same-session prior exists within the
300s TTL (wall-clock), unioned with an observed read (cr>0) for the post-restart
case. Centralised in deriveBaselineWarmth; used by all three call sites
(sessions, dashboard, fragments).
Empirical replay over ~/.pxpipe/events.jsonl (13,402 message rows): honest
headline 504.6M tokens saved vs the old cr-gated 1.05B; 1,409/12,305 fresh-prior
rows (11.4%) read warm despite a busted image cache. Dashboard now narrates the
busted-image case explicitly. Adds baseline/sessions/context-map regression
tests. Measurement only — no change to proxy behavior.
typecheck + 600 tests + build all green.
Collapse old conversation history into rendered PNG sections so the model
reads a compact image instead of re-billed text, while preserving prompt
caching and tool-call behavior. Measures real vs compressed token/cost.
Core:
- GPT history collapse (openai-history.ts): append-only, o200k token-length
sectioning. Sections seal only at a tool-closed boundary (open call-id set
empty), so a function_call and its function_call_output never split across
the collapse cut. Fixes the OpenAI 400 "No tool call found for function
call output with call_id ..." that hit long Responses-API sessions.
- Anthropic cache contract (history.ts): append-only per-chunk rendering;
cache_control markers are preserved/moved, never added; chunk boundaries
align with caller marker seams for byte-stable prefix caching.
- GPT image budget (openai.ts): detail:'original' for gpt-5.x, flagship
vision-multiplier fix, patch cap; schema-strip preserves real descriptions.
- Savings accounting (openai-savings.ts): cached_tokens + vision-token basis.
Model scope (applicability.ts):
- Default imaged scope = claude-fable-5 + gpt-5.6.
- gpt-5.5 and claude-opus-4-8 stay opt-in: same pipeline, but they degrade
reading dense imaged history (gist drift), so silently imaging them by
default is wrong. Promotion is gated on an OCR/recall threshold.
Dashboard: GPT + Anthropic rendering, per-family model toggles, persisted
metrics, thumbnail-expired session UI, reflow/newline handling.
Tests: cache-alignment (GPT + Anthropic), history sectioning + tool-boundary
invariants, savings, dashboard, sessions/restart-restore. 452 passing.
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.
Demo clip moved to Google Drive (committed copy was too low-res); README keeps the preview thumbnail linking to the Drive video. Restoring the thumbnail also un-breaks the demo image on the npm 0.3.0 page. Version 0.3.0 -> 0.3.1 + CHANGELOG.
Attach the recorded plain-vs-pxpipe demo (cost-ab fix + effective-context recall) plus a poster thumbnail, embedded in a new Demo section with an honest caption (Opus opt-in; demo 2 shows pxpipe surfacing the lossy-count limit rather than fabricating). Fable headline clip to follow when Fable is back.
Fable 5 / Opus 4.8 accept up to 2576px / 4784 visual tokens per image, but a
request with >20 images (pxpipe always sends many) is held to the stricter
<=2000px/side rule, so the real ceiling is ~1932x1932 (1928x1928 = 69x69 = 4761
tokens). MAX_HEIGHT_PX 1568->1932; dense tool/history pages now render at
DENSE_CONTENT_COLS=384 / DENSE_CONTENT_CHARS_PER_IMAGE=92160 (1928x1928 full
page) -- fewer image blocks at the same OCR-validated 5x8 cell. The static slab
is unchanged (313 cols / 1573x1280).
Also fixes a regression the code review caught: the break-even gate,
truncateForBudget, and estimateImageCount predicted against the slab geometry
(313 cols / 159 rows) while the dense renderer emits 384 cols / 240 rows, so
large tool_results were truncated far earlier than the 10-image cap required --
silently dropping output that would have rendered. A per-page char budget is
threaded through the prediction chain (defaults to READABLE so the slab and unit
tests are byte-identical) plus a denseGateGeometry helper, so the gate prices the
same page the renderer actually produces.
New docs/CACHING_AND_SAVINGS.md explains the two things that keep coming up:
1. How pxpipe stays aligned with Anthropic's prompt cache when it images the
static prefix: split static/dynamic blocks first (keep drifting <env> etc.
as cheap text, never imaged), then relocate (never add) the caller's
cache_control onto the last image so the breakpoint anchors at the end of
the stable slab. Covers the cache-friendly splice, the symmetric
anti-flapping burn term, and the horizon-amortized history-collapse gate.
2. How savings are measured without double-counting the cache discount: both
the real request and a count_tokens counterfactual of the original body are
priced with identical cache rates at the same moment, so the discount
cancels in the difference and only the token reduction survives.
Also reconcile the priorWarmTokens JSDoc in transform.ts: the burn term is
applied undivided, matching all three gate call sites and the symmetric
priorWarmImageTokens doc. The '/ N' was stale (the per-turn gate has no
horizon to divide by; the horizon scales the lifetime cost, not the burn).
Adds the data collection half of the adaptive chars-per-token plan
without touching the gate. Every isCompressionProfitable call site
now credits its chars to a named bucket on TransformInfo, and those
counts flow through toTrackEvent into the persisted JSONL.
transform.ts:
- New BucketName union: 'static_slab' | 'reminder' |
'tool_result_{structured,log,prose}' | 'history'
- bumpBucket() helper lazily allocates info.bucketChars
- toolResultBucket() routes by classifyContent shape so the post-
compaction text we credit matches the shape the gate evaluated
- Bump sites: static slab (combinedRaw), reminder (reminderRaw),
tool_result single-string (innerRaw), tool_result multi-block
per TextBlock (innerTextRaw), history (collapsedChars)
- Surfaces historyTextChars separately so history's no-collapse
rejections still contribute a denominator
tracker.ts:
- TrackEvent gains bucket_chars?: Partial<Record<BucketName, number>>
and history_text_chars?: number
- toTrackEvent copies them when populated, drops empty bucket maps
so happy-path events stay small
tests/tracker.test.ts:
- Asserts snake_case field names and nested shape land correctly
- Asserts absent buckets and pass-through events stay clean
No gate logic changed. CHARS_PER_TOKEN / SLAB_CHARS_PER_TOKEN /
HISTORY_CHARS_PER_TOKEN still drive isCompressionProfitable. Phase 2
will fit per-bucket slopes from the captured events and wire a
bucket-aware override.
docs/ADAPTIVE_CPT_PLAN.md: full plan + empirical basis (slab-only
regression on 87 events recovered cpt≈1.50 vs the baked 2.5
constant — 40% on non-slab compressions ship today).
Verified: tsc --noEmit clean; 290/290 tests pass (+2 new);
build emits dist/node.js.
A single canonical document explaining how pixelpipe compresses Claude
Code requests end-to-end. Covers, in order:
1. Why this proxy exists
2. The static / dynamic split (DYNAMIC_BLOCK_TAGS, KNOWN_STATIC_TAGS)
3. What the final request looks like (placement='user' layout)
4. The cache_control budget (the one invariant that matters: ttl='1h')
5. Per-message compressions (reminders + tool_results, no cache_control)
6. Determinism and fingerprints (systemSha8, claudeMdSha8, firstUserSha8)
7. The unknown-tag canary
8. The savings math (effectiveCost / baselineCost from src/dashboard.ts)
9. What deliberately did NOT get built
10. Wiring map
Intended as the file a new contributor reads after CLAUDE.md but before
touching src/core/transform.ts. HANDOFF.md is session-state and rotates;
README.md is install/run; CLAUDE.md is rules; this is the architecture
reference that explains WHY the code looks the way it does.
Authored by the doc-writer teammate, KNOWN_STATIC_TAGS section
post-edited to reflect that e219b37 landed (not "future commit"). The
"source of truth" line in section 8 points at src/dashboard.ts directly
now that the Python reference has been removed (be372a0).
334 lines. No code changes; gates not re-run since this is doc-only.