/** * Request-body transformer. Takes an Anthropic Messages API request body, * extracts the large static parts (system prompt + tool definitions), * renders them as PNG image blocks, and rewrites the body to reference * those images instead — saving 65-73% input tokens on Opus 4.7 while * preserving 100% reasoning quality. * * Originally ported from a Python reference implementation; the Python * has since been removed (live cache-rate validation passed at 98.7% by * tokens). Byte-output determinism is now verified by tests alone. */ import type { ContentBlock, ImageBlock, MessagesRequest, SystemField, TextBlock, ToolDef, ToolResultBlock, ToolUseBlock, } from './types.js'; import { renderTextToPngs, renderTextToPngsMultiCol, maxFittingCols, MAX_HEIGHT_PX, PAD_Y, } from './render.js'; import { bytesToBase64 } from './png.js'; import { ATLAS_CELL_H } from './atlas.js'; import { collapseHistory } from './history.js'; export interface TransformOptions { /** Master switch — false makes this a no-op pass-through. */ compress?: boolean; /** Move tool descriptions into the same image (and stub the originals). */ compressTools?: boolean; /** Include full input_schema JSON for each tool. Adds tokens but maximizes parity. */ compressSchemas?: boolean; /** Compress large `` text blocks in the first user message. * Claude Code re-injects these every turn; rendering them to images shares * the cache anchor with the system+tools render. */ compressReminders?: boolean; /** Compress large tool_result text content across all user messages. Tool * output is static once produced and accumulates across the conversation, * so image-rendering it compounds savings as the session grows. */ compressToolResults?: boolean; /** Don't compress if total compressible chars below this. */ minCompressChars?: number; /** Per-block threshold for compressReminders (chars). */ minReminderChars?: number; /** Per-block threshold for compressToolResults (chars). */ minToolResultChars?: number; /** Soft-wrap column count. */ cols?: number; /** Hard upper bound on images emitted per single tool_result. Above this, * the source text is truncated (head + paging marker + tail) BEFORE * rendering so the request stays under Anthropic's 100-image-per-request * cap even when a single tool dumps a huge log. Default 10. */ maxImagesPerToolResult?: number; /** R2 multi-column rendering: pack N text columns side-by-side per image * so each image covers `N×LINES_PER_IMAGE` wrapped lines instead of one. * Default 1 (single column = current behavior). 2 roughly halves image * count on real Claude Code workloads at the cost of OCR ordering risk * — the model must read column 1 fully top-to-bottom before column 2. * Modern vision LLMs handle this well on newspaper layouts; keep this * off until a smoke test against the real slab confirms ordering. * Auto-clamped if the resulting canvas would exceed 1568 px wide. */ multiCol?: number; /** Chars-per-token assumption used by `isCompressionProfitable()`. Default * 4 (Anthropic's published English-text average). Host may override per * request if it has a better number for the specific deployment. */ charsPerToken?: number; } const DEFAULTS: Required = { compress: true, compressTools: true, compressSchemas: true, compressReminders: true, compressToolResults: true, minCompressChars: 2000, // Coarse pre-filter — blocks below this length skip the per-block // break-even check entirely (saves CPU on the obviously-not-profitable // cases). The REAL gate is `isCompressionProfitable()` below; this is // just a fast-path skip. Set to 10,000 (= break-even point at the // current cell config) so anything below it can't possibly net-save. minReminderChars: 10000, minToolResultChars: 10000, // NOTE: Anthropic's `system` field accepts text blocks only — image blocks // there come back as `400 system.N.type: Input should be 'text'`. Images // are always attached to the first user message; there's no flag for this // because the system-field path is API-rejected. (Removed `placement` + // `compressSystem` knobs that gated the dead system-field branch.) cols: 100, // Cap at 10 images per tool_result. With ~14k chars/image at current cell, // a single tool_result can grow to ~140k chars before paging kicks in. A // `find` over a big tree or `grep -r` can easily exceed this; the paging // marker tells the model what was elided. Tuneable per session. maxImagesPerToolResult: 10, // English ~4 chars/tok default (= the CHARS_PER_TOKEN constant declared // later in this file — kept as a literal here to avoid forward-reference). // Host overrides per-request when the dashboard's live fit has converged. charsPerToken: 4, // R2 multi-column ON (2 cols) — at single-col the break-even gate // correctly rejects compression on real tool-doc-shaped slabs (~38 chars/ // row → ~29 imgs vs 39k text tokens → net loss). Two columns packs ~2× // rows per image, dropping image count to ~15 and crossing break-even. // Set to 1 via `--multi-col 1` if the OCR ordering ever turns out wrong. multiCol: 2, }; // --- per-block break-even check --- // // Anthropic's real per-image cost is ~2,500 tokens at SINGLE-COL // (history-researcher's round-3 N=33 measurement on cold-miss events // 2026-05-18, single-col 508×1559 PNGs). The published theoretical // formula `(w × h) / 750` gives ~1,056 tokens for that geometry and // underpredicts actual Anthropic billing by ~2.4×. We use the empirical // 2,500 at numCols=1 and SCALE LINEARLY by numCols for wider canvases // (multi-col packs N text columns side-by-side, multiplying pixel area // and — per Anthropic's area-proportional billing model — token cost). // // Safety: the gate's job is to compress a block only when doing so saves // tokens. The constants below bias CONSERVATIVE — every uncertainty // resolves in favor of pass-through, so a misprediction at worst leaves // money on the table; it never burns money on a net-loss image: // • CHARS_PER_TOKEN = 4 over-estimates tokens-per-char for typical // tool_result code/JSON (real cpt ≈ 3-3.5), which UNDER-estimates // text savings → bias toward pass-through. // • numCols=1 cost is empirical (2500). numCols≥2 cost is linearly // extrapolated + 10% margin since we don't yet have empirical // measurements at wider canvases. Over-stating image cost ALSO // biases toward pass-through. // // Production bug context (2026-05-19): a request with orig_chars=169k spread // across 88 small blocks each cost ~2,500 tokens as images = 220k tokens // when the text would have been only 42k tokens. The flat per-block-min // threshold (5k) was wide of the break-even point (10k) and let net-loss // compressions through. // // Multi-col safety hole (closed 2026-05-19): production runs multiCol=2 // by default. The OLD flat `TOKENS_PER_IMAGE = 2500` applied at all // numCols, so the gate believed multi-col images were ~2× cheaper than // they actually are and would compress slabs that net-lost in reality. // The scaled cost below fixes that — at multiCol≥2, image cost reflects // the wider canvas. /** English ~4 chars per token average. Holds well enough for code + prose * mix; tool_result content is typically code-shaped. */ const CHARS_PER_TOKEN = 4; /** Empirical per-image cost at numCols=1. Source: dashboard.ts measurement * trace. Kept here as a constant rather than imported from dashboard.ts * to keep `src/core/` free of dashboard imports — that's a one-way edge. */ const TOKENS_PER_IMAGE_SINGLE_COL = 2500; /** Effective per-image token cost at the given `numCols`. Single-col is * the calibrated measurement; multi-col scales linearly with the number * of text columns packed per image (Anthropic bills proportional to * pixel area, which doubles/triples with numCols). The 10% multi-col * margin absorbs extrapolation noise since we don't yet have empirical * cost measurements at numCols≥2 — biases toward pass-through, never * toward letting a net-loss through. * * Why bias conservative: the gate's only job is "compress if and only if * doing so saves tokens." If the constant is too low, we compress * net-losers and overpay. If it's too high, we miss profitable * compressions but never overpay. The user's constraint is "don't lose * money" — accept missed opportunities, reject misses-that-overpay. */ function effectiveTokensPerImage(numCols: number): number { const n = Math.max(1, numCols | 0); if (n === 1) return TOKENS_PER_IMAGE_SINGLE_COL; return Math.ceil(TOKENS_PER_IMAGE_SINGLE_COL * n * 1.10); } /** Characters per rendered image at the current renderer config. Derived * at runtime from `ATLAS_CELL_H` (cell height) and the render canvas * dimensions imported from render.ts — single source of truth. * * Formula: `cols × floor((MAX_HEIGHT_PX − 2·PAD_Y) / ATLAS_CELL_H)` * * At the shipping config (Unifont, cell 5×11, cols=100): * 100 × floor((1568 − 8) / 11) = 100 × 141 = 14,100 * * When the atlas swaps (e.g. Cozette 4×7, cell H=7), this auto-updates: * 100 × floor(1560 / 7) = 100 × 222 = 22,200 * …and the break-even threshold drops accordingly. Without this, the * hardcoded 14,100 would silently let net-loss compressions through on * every smaller-cell atlas. */ /** Visual rows per image at the current atlas cell. Derived once at module * load. Auto-updates when gen-atlas regenerates with a different font/size. */ export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / ATLAS_CELL_H)); export function maxCharsPerImage(cols: number): number { return cols * LINES_PER_IMAGE; } /** Lossless pre-render slab compactor. Reduces the visual-row count the * renderer sees (each `\n` is at least one row regardless of column width), * without changing what the model reads: * * 1. Strip trailing whitespace per line (preserves leading indent — code * and JSON structure stay intact). * 2. Collapse runs of 3+ newlines down to 2 (one blank line max between * paragraphs). Multi-blank "section dividers" cost rows but carry no * information once rendered to a flat image. * * Real Claude Code system slabs hit production data 2026-05-20 had ~2,000 * newline-bounded rows in 161 KB — the row-aware gate correctly rejected * them as unprofitable. This compactor typically drops 10-25% of rows on * markdown-heavy / tool-doc-heavy slabs, which is enough to flip a * meaningful fraction of currently-rejected slabs to profitable. * * Exported so the per-block reminder/tool_result compressions can share * the same pre-processor — same row-cost dynamics. */ export function compactSlabWhitespace(text: string): string { if (!text) return text; // Per-line trailing whitespace strip. Iterate the buffer once instead // of split/join — avoids materialising an intermediate array on a // ~160 KB slab. We only touch spaces/tabs (codepoint 32 and 9) so // newlines are passed through verbatim and the line count is unchanged // at this step. let trimmed = ''; let lineStart = 0; for (let i = 0; i <= text.length; i++) { if (i === text.length || text.charCodeAt(i) === 10 /* \n */) { let end = i; while (end > lineStart) { const c = text.charCodeAt(end - 1); if (c !== 32 && c !== 9) break; end--; } trimmed += text.slice(lineStart, end); if (i < text.length) trimmed += '\n'; lineStart = i + 1; } } // Collapse 3+ consecutive newlines to exactly 2. Preserves paragraph // breaks while killing multi-blank section dividers — they cost one // row each in the renderer and carry zero information once flattened // to an image. return trimmed.replace(/\n{3,}/g, '\n\n'); } /** Returns true iff image-compressing a text block would actually save tokens * vs leaving it as text. Used as the gate before every image-encoding * decision in transformRequest. * * Pass the **actual text string** when possible — the function will * soft-wrap-count visual rows to match what `renderTextToPngs` will * actually produce. Newline-heavy content (low fill ratio) renders to * *more* images than the naive `chars / charsPerImage` estimate, and * using the looser estimate lets net-losing compressions through. * * Passing a `number` falls back to the looser chars-only estimate for * back-compat with existing unit tests; production transform call sites * should always pass the string. * * `cols` defaults to `DEFAULTS.cols` (100) so existing callers and unit * tests that pass only `textLen` keep working byte-identically at the * current atlas. New call sites should pass `o.cols` so a runtime * `--cols` override flows into the break-even math too. */ export function isCompressionProfitable( textOrLen: string | number, cols: number = DEFAULTS.cols, imageCountCap?: number, numCols: number = 1, /** Chars-per-token assumption for the text side of the break-even math. * Default 4 (Anthropic's English-text average). Lower values = more * profitable text compressions (each char buys more tokens back). */ charsPerToken: number = CHARS_PER_TOKEN, ): boolean { const n = Math.max(1, numCols | 0); let estImages: number; let textLen: number; if (typeof textOrLen === 'string') { // Row-aware: matches renderTextToPngs() image budgeting exactly. estImages = estimateImageCount(textOrLen, cols, n); textLen = textOrLen.length; } else { // Looser chars-only estimate. Assumes lines fill width — wrong for // newline-heavy code/logs but kept for back-compat. const charsPerImage = maxCharsPerImage(cols) * n; estImages = Math.max(1, Math.ceil(textOrLen / charsPerImage)); textLen = textOrLen; } // For code paths that truncate before rendering (tool_results), the // actual image cost is bounded by the cap — text savings are still // measured against the full pre-truncation length. if (imageCountCap !== undefined && imageCountCap > 0) { estImages = Math.min(estImages, imageCountCap); } // Defensive clamp: a corrupt or pathological charsPerToken (≤0 / NaN) // would either crash or give a misleading-true. Fall back to the // baked-in default in that case. const cpt = Number.isFinite(charsPerToken) && charsPerToken > 0 ? charsPerToken : CHARS_PER_TOKEN; const imageTokensCost = estImages * effectiveTokensPerImage(n); const textTokensEquivalent = textLen / cpt; return imageTokensCost < textTokensEquivalent; } /** Increment a passthrough-reason counter on `info`. Lazily allocates the * `passthroughReasons` sub-object so happy-path events stay lean. */ function bumpPassthrough( info: TransformInfo, reason: 'below_threshold' | 'not_profitable', ): void { if (!info.passthroughReasons) info.passthroughReasons = {}; info.passthroughReasons[reason] = (info.passthroughReasons[reason] ?? 0) + 1; } /** Parsed contents of Claude Code's + git status blocks. All optional — * fields are only populated if the corresponding line is present. */ export interface EnvFields { /** Working directory at the time `claude` was launched. */ cwd?: string; isGitRepo?: boolean; /** Current git branch, parsed from or a "Branch:" line. */ gitBranch?: string; platform?: string; osVersion?: string; /** "Today's date" as Claude Code reported it (YYYY-MM-DD). */ today?: string; } export interface TransformInfo { compressed: boolean; reason?: string; origChars: number; /** Total chars of source text that were image-encoded across ALL blocks * this request (static slab + reminders + tool_results). Pairs with * `imageCount` for honest savings math: * textTokens = compressedChars / 4 * imageTokens = imageCount × 2500 * savings = textTokens − imageTokens * Unlike `origChars` (which is just static slab + tool docs), * `compressedChars` reflects what `imageCount` actually replaced. */ compressedChars: number; imageCount: number; imageBytes: number; /** Total pixel area summed across all rendered images this request * (`Σ width × height`). Pairs with `cache_create_tokens` on cold-miss * events to derive empirical pixels-per-token under the current model — * the dashboard's `OPUS_IMAGE_TOKEN_COST` and the gate's `TOKENS_PER_IMAGE` * are both stale empirical constants from a different model; this gives * us the raw data to re-ground them via regression instead of guessing. */ imagePixels?: number; /** Total chars of TEXT remaining in the outgoing transformed body — every * TextBlock across `system`, `messages[].content`, and any tool_result * text that didn't get image-compressed. Pairs with `imagePixels` and * the upstream token count so we can solve for chars-per-token (α) and * pixels-per-token (β) empirically: `total_tokens ≈ α·outgoingTextChars + * β·imagePixels`. On a cold-miss event the upstream `cache_create_tokens` * is the full LHS, so a regression over N cold-misses pins both. */ outgoingTextChars?: number; /** Length of the static (cacheable) slab rendered into the image. */ staticChars: number; /** Length of the dynamic (per-turn) slab kept as plain text. */ dynamicChars: number; /** Number of dynamic blocks detected (, , etc.). */ dynamicBlockCount: number; /** Tag-shaped blocks found in the *static* slab that are NOT in * DYNAMIC_BLOCK_TAGS. Early-warning canary: if Claude Code ships a new * per-turn tag, it'll show up here before our cache hit rate collapses. */ unknownStaticTags?: string[]; /** Parsed env block, if Claude Code injected one. Useful for telemetry * (per-project compression ratios, etc.). */ env?: EnvFields; /** sha256[0..8] of the static slab + tool docs (what ends up in the image). * Repeats across turns → cache_control SHOULD be hitting upstream. */ systemSha8?: string; /** sha256[0..8] of just the CLAUDE.md section if detectable. Lets us * bucket requests by project even when cwd is absent. */ claudeMdSha8?: string; /** sha256[0..8] of the first user message text (first 4 KiB). Rough * thread/session id since the wire protocol carries none. */ firstUserSha8?: string; /** Raw bytes of the FIRST rendered image. Used by the in-process dashboard * to show a preview. NOT persisted to JSONL (toTrackEvent drops it). */ firstImagePng?: Uint8Array; /** Pixel dimensions of the first image. */ firstImageWidth?: number; firstImageHeight?: number; /** Number of images we added by compressing `` blocks in * the first user message. */ reminderImgs?: number; /** Number of images we added by compressing tool_result content across * user messages. */ toolResultImgs?: number; /** Codepoints in the rendered text that weren't in the atlas. They * rendered as blank cells. A non-zero count means the user is producing * glyphs we don't ship — useful telemetry for tuning the atlas profile * (e.g. switch from `practical` → `full-bmp` if Hangul shows up). */ droppedChars?: number; /** Top dropped codepoints by frequency for this request, keyed `U+HHHH` * (uppercase hex, at least 4 digits). At most 20 entries, sorted by count * descending. Only set when `droppedChars > 0`. Lets the operator * identify which Unicode blocks to add to the atlas profile without * having to capture & inspect the request body. */ droppedCodepointsTop?: Record; /** Counters for why blocks didn't get image-compressed this request. * Helps tune the break-even check vs the flat threshold: * - `below_threshold`: block below `minReminderChars` / `minToolResultChars` * (the fast-path skip; saves CPU on obvious-no cases) * - `not_profitable`: block above the threshold but `isCompressionProfitable` * returned false (image cost ≥ text cost at current cell config) * Only emitted when at least one counter is > 0. */ passthroughReasons?: { below_threshold?: number; not_profitable?: number }; /** Number of tool_result blocks where the source text exceeded the * per-tool_result image budget and was truncated before rendering. */ truncatedToolResults?: number; /** Total chars elided by paging across all tool_results this request. */ omittedChars?: number; /** Variant C history-image: how many original `messages[]` entries got * collapsed into the prepended synthetic user message. 0 / unset when * no collapse happened (no closed prefix, too few turns, gate rejected * as not_profitable, etc. — see `historyReason`). */ collapsedTurns?: number; /** Variant C: total chars of text serialized into the history image(s) * before render (pre-OCR loss). */ collapsedChars?: number; /** Variant C: number of PNG image blocks emitted for the history. Folded * into `info.imageCount` too — surfaced separately so dashboards can * attribute image-count growth to history vs system-slab vs reminders. */ collapsedImages?: number; /** Variant C: why the history collapse didn't run (or did). Diagnostic * only — see `HistoryCollapseInfo.reason` for the value set. */ historyReason?: | 'no_history' | 'prefix_too_short' | 'no_closed_prefix' | 'not_profitable' | 'render_empty' | 'collapsed'; /** Ground-truth baseline token count for THIS request, from a parallel * call to /v1/messages/count_tokens on the PRE-COMPRESSION body. The * endpoint is free (no input-token billing). Absent when the probe * failed (network, 4xx) — that event is then excluded from the * savings rollup. */ baselineTokens?: number; /** Second baseline probe: input_tokens of the original body TRUNCATED at * the last `cache_control` marker — the prefix that would have cached * on the unproxied path. Used by the dashboard to weight the baseline by * the SAME cache class the proxied request landed in (cache_create ×1.25, * cache_read ×0.10, no-cache ×1.0), giving an exact cache-aware * counterfactual instead of cold-every-time. Absent when the original * body has no cache_control markers anywhere (in which case the unproxied * path doesn't cache and cacheable_prefix_tokens = 0). */ baselineCacheableTokens?: number; } // --- helpers --------------------------------------------------------------- /** Extract `(text, remainder)` from a system field that may be string or list. */ function extractSystemText(sys: SystemField | undefined): { text: string; kept: SystemField } { if (sys == null) return { text: '', kept: [] }; if (typeof sys === 'string') return { text: sys, kept: '' }; const textParts: string[] = []; const kept: SystemField = []; for (const block of sys) { if (block && typeof block === 'object' && block.type === 'text') { textParts.push(block.text); } else { kept.push(block); } } return { text: textParts.join('\n\n'), kept }; } /** * Claude Code injects a handful of per-turn dynamic blocks into the system * prompt (e.g. , , , , * ). Including these in the rendered image kills the * Anthropic prompt cache because the bytes drift turn-to-turn. Splitting * them out lets us render the static slab (CLAUDE.md, agent defs, tool docs) * with cache_control while forwarding the dynamic slab as cheap text so the * model still sees cwd / git status / today's date. */ const DYNAMIC_BLOCK_TAGS = [ 'env', 'context', 'git_status', 'directoryStructure', 'system-reminder', ] as const; /** * Tag-shaped blocks that DO appear in the static slab and SHOULD be baked into * the cached image. These are part of Claude Code's built-in system prompt / * tool documentation, not per-turn injections, so they're stable across turns. * * The canary in splitStaticDynamic flags any tag-shaped block in the static * slab that isn't in DYNAMIC_BLOCK_TAGS — designed to catch a new Claude Code * release that ships a per-turn tag we'd accidentally cache. Without this * allowlist, known-static tags like trigger the canary on most turns * and drown out the real signal. Add a tag here only after confirming it's * static (appears in the cacheable part of the prompt, not rotated per turn). */ const KNOWN_STATIC_TAGS = ['types'] as const; function splitStaticDynamic(text: string): { staticText: string; dynamicText: string; blockCount: number; unknownTags: string[]; } { if (!text) return { staticText: '', dynamicText: '', blockCount: 0, unknownTags: [] }; // Match ... where tag ∈ DYNAMIC_BLOCK_TAGS. Closing tag // must match opening tag exactly. Non-greedy body — earliest close wins. const pattern = new RegExp( `<(${DYNAMIC_BLOCK_TAGS.join('|')})(\\s[^>]*)?>[\\s\\S]*?`, 'g', ); const dynamicParts: string[] = []; let staticBuf = ''; let cursor = 0; let m: RegExpExecArray | null; while ((m = pattern.exec(text)) !== null) { staticBuf += text.slice(cursor, m.index); dynamicParts.push(m[0]); cursor = m.index + m[0].length; } staticBuf += text.slice(cursor); // Sniff for OTHER tag-shaped blocks in the static slab. If Claude Code // ships a new per-turn tag (say ...) we'd // silently bake it into the cached image and our cache hit rate would // collapse. Surfacing the tag name as telemetry lets us detect that // within hours of a Claude Code release. const known = new Set(DYNAMIC_BLOCK_TAGS); const knownStatic = new Set(KNOWN_STATIC_TAGS); const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g; const unknown = new Set(); let s: RegExpExecArray | null; while ((s = sniffer.exec(staticBuf)) !== null) { const tag = s[1]!; if (!known.has(tag) && !knownStatic.has(tag) && tag.length <= 64) unknown.add(tag); } return { // Collapse the run of blank lines left behind by removed blocks. staticText: staticBuf.replace(/\n{3,}/g, '\n\n').trim(), dynamicText: dynamicParts.join('\n\n'), blockCount: dynamicParts.length, unknownTags: [...unknown], }; } /** * Compute sha256 and return the first 8 hex chars. Web Crypto so it works * the same in Node 18+ and Workers. 8 chars = 32 bits = collision-safe for * the request volume we'd see in a single proxy instance. */ export async function sha8(text: string): Promise { const buf = new TextEncoder().encode(text); const digest = await crypto.subtle.digest('SHA-256', buf); const bytes = new Uint8Array(digest); let hex = ''; for (let i = 0; i < 4; i++) hex += bytes[i]!.toString(16).padStart(2, '0'); return hex; } /** * Best-effort: pull out the CLAUDE.md slab from a system text. Heuristic — * Claude Code typically wraps it with a heading like "Claude Code Rules" * or includes it under a `# CLAUDE.md` / system-reminder block. Returns * empty string if nothing CLAUDE.md-shaped is detected; callers should * skip hashing in that case. */ export function extractClaudeMdSlab(staticText: string): string { if (!staticText) return ''; // Common markers Claude Code uses around the CLAUDE.md content. const startPatterns = [ /^\s*#+\s*Claude\s+Code\s+Rules\s*$/im, /^\s*#+\s*CLAUDE\.md\s*$/im, /^\s*Claude\s+Code\s+Rules:?\s*$/im, ]; let startIdx = -1; for (const p of startPatterns) { const m = p.exec(staticText); if (m && (startIdx === -1 || m.index < startIdx)) startIdx = m.index; } if (startIdx === -1) return ''; // Run until the next top-level heading (# foo) or end of text. const tail = staticText.slice(startIdx); const endMatch = /\n#\s+\S/.exec(tail.slice(1)); const end = endMatch ? endMatch.index + 1 : tail.length; return tail.slice(0, end).trim(); } /** * Hash the first user message text, capped at 4 KiB so very long initial * pastes don't dominate hashing time and so we still get a stable id for * the conversation thread (initial prompt usually fits well within 4 KiB). */ export function firstUserText(req: MessagesRequest): string { const msgs = req.messages ?? []; for (const m of msgs) { if (m.role !== 'user') continue; if (typeof m.content === 'string') return m.content.slice(0, 4096); if (Array.isArray(m.content)) { for (const block of m.content) { if (block && (block as any).type === 'text' && typeof (block as any).text === 'string') { return ((block as any).text as string).slice(0, 4096); } } } // First user message found but unreadable → return empty so we don't // accidentally hash some downstream user message. return ''; } return ''; } /** * Pull structured fields out of the dynamic slab. Only reads — does not * modify the text. Used purely for telemetry / improvement signals. */ export function extractEnvFields(dynamicText: string): EnvFields { const out: EnvFields = {}; if (!dynamicText) return out; const envMatch = /([\s\S]*?)<\/env>/i.exec(dynamicText); if (envMatch) { const body = envMatch[1]!; const cwd = /(?:^|\n)\s*Working directory:\s*(.+?)\s*(?:\n|$)/i.exec(body); if (cwd) out.cwd = cwd[1]!.trim(); const gitRepo = /(?:^|\n)\s*Is directory a git repo:\s*(Yes|No)\b/i.exec(body); if (gitRepo) out.isGitRepo = gitRepo[1]!.toLowerCase() === 'yes'; const platform = /(?:^|\n)\s*Platform:\s*(.+?)\s*(?:\n|$)/i.exec(body); if (platform) out.platform = platform[1]!.trim(); const osVer = /(?:^|\n)\s*OS Version:\s*(.+?)\s*(?:\n|$)/i.exec(body); if (osVer) out.osVersion = osVer[1]!.trim(); const today = /(?:^|\n)\s*Today'?s date:\s*(.+?)\s*(?:\n|$)/i.exec(body); if (today) out.today = today[1]!.trim(); } // Git branch may live in , , or just a // "Branch: foo" / "On branch foo" line somewhere in the dynamic slab. const branch = /(?:^|\n)\s*(?:On branch|Branch:)\s*([^\s\n]+)/i.exec(dynamicText) ?? /(?:^|\n)\s*Current branch:\s*([^\s\n]+)/i.exec(dynamicText); if (branch) out.gitBranch = branch[1]!.trim(); return out; } /** * Strip the per-turn random billing header line that Claude Code injects. * It changes every turn and would defeat prompt-cache hits if we left it * inside the image. We keep it as a leading text block so the upstream * still receives it. */ function stripBillingLine(text: string): { kept: string | null; body: string } { const nl = text.indexOf('\n'); const first = nl === -1 ? text : text.slice(0, nl); if (first.startsWith('x-anthropic-billing-header:')) { return { kept: first, body: nl === -1 ? '' : text.slice(nl + 1) }; } return { kept: null, body: text }; } /** Maximum recursion depth when stripping descriptions out of an input_schema. * Real tool schemas can be deeper than naive 3-level shapes — think filter * DSLs, query objects, structured-output schemas. 20 is generous enough to * handle anything realistic; deeper than that and we leave the node untouched * rather than corrupt it. */ const SCHEMA_STRIP_MAX_DEPTH = 20; /** Long-form description / metadata keys that contribute tokens but no * validation. The image already carries this content for the model to read, * so we strip them from the wire payload to recover the tokens. */ const SCHEMA_STRIP_KEYS = new Set([ 'description', 'title', 'examples', 'default', '$schema', '$id', '$comment', ]); /** JSON Schema composition keys whose values are *arrays of subschemas*. We * recurse into each element so descriptions inside variant branches still get * stripped while the variant structure is preserved. */ const SCHEMA_COMPOSITION_KEYS = new Set(['oneOf', 'anyOf', 'allOf']); /** JSON Schema keys whose values are *objects keyed by name* (each value is * itself a subschema). Both `properties` and `patternProperties` use this * shape; `definitions` / `$defs` are pre-2020 and 2020-12 spellings of the * same idea and we strip descriptions inside them too. */ const SCHEMA_NAMED_SUBSCHEMA_KEYS = new Set([ 'properties', 'patternProperties', 'definitions', '$defs', ]); /** Keys whose values are a *single subschema* — recurse but don't unwrap. */ const SCHEMA_SINGLE_SUBSCHEMA_KEYS = new Set([ 'items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties', 'if', 'then', 'else', ]); /** Keys that are *arrays of primitives* (or otherwise opaque) — preserve * verbatim, don't recurse. */ const SCHEMA_VERBATIM_KEYS = new Set([ 'required', 'enum', 'const', 'type', // string or array of strings '$ref', // we don't resolve refs but we mustn't drop them 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'minProperties', 'maxProperties', 'multipleOf', 'uniqueItems', 'pattern', ]); /** `format` values from JSON Schema's vocabulary are short tokens * (`date-time`, `uri`, `email`, `ipv4`, …). If something larger than this * shows up it's almost certainly a human-readable hint that belongs in the * image, not the wire payload. */ const FORMAT_MAX_LEN = 32; /** Strip long-form metadata from a JSON-Schema-shaped node while preserving * the structural keys Anthropic's tool-use validator needs to type-check the * model's calls. * * PRESERVED (verbatim or recursed): * - `type`, `enum`, `const`, `$ref` * - `properties` / `patternProperties` / `definitions` / `$defs` (recurse * into each named subschema) * - `items` / `additionalProperties` / `not` / `contains` / * `propertyNames` / conditional `if`/`then`/`else` (single-subschema) * - `oneOf` / `anyOf` / `allOf` (recurse into each variant) * - `required` arrays * - All numeric / length / pattern constraints (`minLength`, `pattern`, …) * - `format` if its value is ≤ 32 chars (real format tokens are tiny) * * STRIPPED: * - `description`, `title`, `examples`, `default` * - `$schema`, `$id`, `$comment` * - `format` longer than 32 chars (treated as a description in disguise) * * PASS-THROUGH for unknown keys: copy primitive/string values verbatim; * recurse into nested objects so descriptions hidden under custom keys still * get stripped. * * Returns a fresh object — never mutates the input. */ function stripSchemaDescriptions(node: unknown, depth: number): unknown { // Beyond depth cap: leave the subtree alone. Brief: "if anything's deeper, // that tool is pathological and we leave it untouched." Better to ship a // slightly bigger schema than to corrupt one. if (depth > SCHEMA_STRIP_MAX_DEPTH) return node; // Arrays at top level (e.g. a bare `required: [...]` if we land here by // accident) get passed through. Real subschema arrays — `oneOf`/`anyOf`/ // `allOf` — are handled by the parent object below. if (Array.isArray(node)) return node; // Primitives and null bottom-out unchanged. if (!node || typeof node !== 'object') return node; const obj = node as Record; const out: Record = {}; for (const [k, v] of Object.entries(obj)) { if (SCHEMA_STRIP_KEYS.has(k)) continue; if (k === 'format' && typeof v === 'string' && v.length > FORMAT_MAX_LEN) { // Long "format" values are descriptions in disguise; the real // vocabulary tokens are <32 chars. continue; } if (SCHEMA_VERBATIM_KEYS.has(k)) { out[k] = v; continue; } if ( SCHEMA_NAMED_SUBSCHEMA_KEYS.has(k) && v && typeof v === 'object' && !Array.isArray(v) ) { // properties / patternProperties / definitions / $defs: object whose // values are themselves schemas. const nested: Record = {}; for (const [pk, pv] of Object.entries(v as Record)) { nested[pk] = stripSchemaDescriptions(pv, depth + 1); } out[k] = nested; continue; } if (SCHEMA_COMPOSITION_KEYS.has(k) && Array.isArray(v)) { // oneOf / anyOf / allOf: array of subschemas. out[k] = v.map((sub) => stripSchemaDescriptions(sub, depth + 1)); continue; } if (SCHEMA_SINGLE_SUBSCHEMA_KEYS.has(k)) { // items / additionalProperties / not / etc. May be a schema OR a // boolean (additionalProperties: true/false is legal). Booleans pass // through untouched. if (typeof v === 'boolean') { out[k] = v; } else { out[k] = stripSchemaDescriptions(v, depth + 1); } continue; } // Unknown key. If the value is a nested object, recurse so descriptions // hidden under vendor extensions still get stripped. Primitives pass // through. if (v && typeof v === 'object') { out[k] = stripSchemaDescriptions(v, depth + 1); } else { out[k] = v; } } return out; } /** Keys whose presence in a (stripped) schema gives Anthropic's validator * something to bind the model's tool call against. If a stripped schema has * *none* of these, we treat it as no-structure and ship the legacy bare stub * with a `schema_no_properties` advisory. */ const SCHEMA_STRUCTURAL_KEYS = [ 'properties', 'patternProperties', 'oneOf', 'anyOf', 'allOf', 'items', '$ref', 'enum', 'const', ]; function schemaHasStructure(schema: Record): boolean { for (const k of SCHEMA_STRUCTURAL_KEYS) { if (k in schema) return true; } return false; } /** Build the "## Tool: name\n\n" block for one tool definition. * * Schema serialization is **compact** (no whitespace). Pretty-printing * with 2-space indent was the dominant source of sparse fill: each schema * key on its own line, indented, wastes 70%+ of horizontal space at * cols=100. Live measurement on 2026-05-19 showed 150 KB of pretty * tool-doc JSON across ~30 tools rendering to 31 static-slab images per * request — a 40% fill ratio that pushed every request well past the * break-even point. * * Compact form is still unambiguous JSON. Descriptions are already * stripped under compressSchemas so only structural keys * (type/properties/required/enum/items) remain — they read fluently * on one line. Frontier models handle compact JSON natively (it's the * default wire format for tool_use blocks). */ function renderToolDoc(t: ToolDef, includeSchema: boolean): string { const parts: string[] = [`## Tool: ${t.name ?? '?'}`]; if (t.description) parts.push(t.description); if (includeSchema && t.input_schema !== undefined) { parts.push('```json\n' + JSON.stringify(t.input_schema) + '\n```'); } return parts.join('\n'); } function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock { const blk: ImageBlock = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: pngB64 }, }; // ttl='1h' is mandatory, not cosmetic. Claude Code marks its own // user-message content with cache_control ttl='1h'; Anthropic enforces // "ttl='1h' must not come after ttl='5m'" in processing order // (tools → system → messages). If we leave ttl unset it defaults to '5m' // and our block lands BEFORE Claude Code's 1h block → 400 at runtime. if (ephemeral) blk.cache_control = { type: 'ephemeral', ttl: '1h' }; return blk; } /** Render a long text blob to one or more PNG image blocks. Helper for the * per-message compressions (reminders, tool_results) — no cache_control on * these (Anthropic caps at 4 breakpoints; the system+tools image already * anchors the cacheable prefix). * * Also returns the total `droppedChars` across all rendered images plus the * merged codepoint→count map so the caller can fold both into the request's * `info.droppedChars` / `info.droppedCodepointsTop`. */ // --- paging / truncation ------------------------------------------------- // // Anthropic's API caps a request at 100 images. A single huge tool_result // (find over a big tree, multi-MB log dump) can blow that cap by itself. // To keep the request valid AND not waste tokens on dozens of bottom-of-log // images, we truncate the source text before render with a marker that // tells the model what was elided. /** Visual rows a single input line will consume after soft-wrap at `cols`. */ function lineRows(line: string, cols: number): number { return Math.max(1, Math.ceil(line.length / cols)); } /** Count the visual rows `text` will consume after soft-wrap at `cols`. */ function countVisualRows(text: string, cols: number): number { let rows = 0; let lineStart = 0; const len = text.length; for (let i = 0; i <= len; i++) { if (i === len || text.charCodeAt(i) === 10 /* \n */) { const lineLen = i - lineStart; rows += Math.max(1, Math.ceil(lineLen / cols)); lineStart = i + 1; } } return rows; } /** Estimate how many images `text` will render to at the given column width. * Counts soft-wrapped visual rows, which is what render.ts actually budgets * against. Exported for tests + the paging gate. * * `numCols` (default 1) packs that many text columns side-by-side per * image — must match the `multiCol` setting wired through to the renderer * for the math to predict the actual image count. */ export function estimateImageCount( textOrLen: string | number, cols: number, numCols: number = 1, ): number { const n = Math.max(1, numCols | 0); const linesPerImage = LINES_PER_IMAGE * n; if (typeof textOrLen === 'number') { // Back-compat shim — numeric arg gets the looser chars-based estimate. return Math.max(1, Math.ceil(textOrLen / Math.max(1, maxCharsPerImage(cols) * n))); } const rows = countVisualRows(textOrLen, cols); return Math.max(1, Math.ceil(rows / linesPerImage)); } /** Classify content so we can pick a truncation strategy. Cheap heuristics on * the first ~4 KiB. Returns: * - `'structured'`: JSON/YAML/diff markers at the top. Truncate tail. * - `'log'`: ≥30% of lines start with a log level or timestamp. Truncate middle. * - `'other'`: prose, file dumps, etc. Truncate middle. * Exported for tests. */ export function classifyContent(text: string): 'structured' | 'log' | 'other' { const head = text.slice(0, 4096); const trimmed = head.trimStart(); if (trimmed.startsWith('{') && /^\{\s*("|\})/.test(trimmed)) return 'structured'; if (trimmed.startsWith('[') && /^\[\s*("|\{|\[|-?\d|true\b|false\b|null\b|\])/.test(trimmed)) return 'structured'; if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) return 'structured'; if (trimmed.startsWith('diff --git ') || /^---\s+\S/.test(trimmed)) return 'structured'; const lines = head.split('\n').slice(0, 40).filter((l) => l.length > 0); if (lines.length < 4) return 'other'; const LOG_LINE = /^(\[?(DEBUG|INFO|WARN|WARNING|ERROR|TRACE|FATAL)\]?\b|\d{4}-\d{2}-\d{2}[T ]?|\d{2}:\d{2}:\d{2}\b)/; let logHits = 0; for (const line of lines) if (LOG_LINE.test(line)) logHits++; if (logHits / lines.length >= 0.3) return 'log'; return 'other'; } /** Build the paging marker text. The model sees this verbatim INSIDE the * rendered image so it can reason about what was elided. */ function buildPagingMarker(args: { originalChars: number; originalLines: number; originalEstImages: number; shownHeadLines: number; shownTailLines: number; omittedLines: number; omittedChars: number; }): string { const tailNote = args.shownTailLines > 0 ? ` Showing first ${args.shownHeadLines} lines and last ${args.shownTailLines} lines.` : ` Showing first ${args.shownHeadLines} lines (tail elided).`; return ( `\n\n[ pixelpipe paging: omitted ${args.omittedLines.toLocaleString('en-US')} lines ` + `(${args.omittedChars.toLocaleString('en-US')} chars) of content here. ` + `Original length: ${args.originalChars.toLocaleString('en-US')} chars ` + `(${args.originalLines.toLocaleString('en-US')} lines, ~${args.originalEstImages} images).` + `${tailNote} ]\n\n` ); } /** Truncate `text` so it renders to roughly `maxImages` images at the given * `cols`. Picks head/tail split based on `classifyContent`. Budget measured * in visual rows (what render.ts actually slices on). Returns the truncated * text (with paging marker embedded) and the count of chars omitted. If * `text` already fits, returns unchanged with `omittedChars: 0`. Exported * for tests. */ export function truncateForBudget( text: string, maxImages: number, cols: number, numCols: number = 1, ): { text: string; omittedChars: number; truncated: boolean } { const n = Math.max(1, numCols | 0); const estImages = estimateImageCount(text, cols, n); if (estImages <= maxImages) return { text, omittedChars: 0, truncated: false }; const totalRowBudget = Math.max(8, maxImages * LINES_PER_IMAGE * n - 6); const shape = classifyContent(text); const lines = text.split('\n'); const originalLines = lines.length; const originalChars = text.length; if (shape === 'structured') { let rows = 0; let cut = 0; for (let i = 0; i < lines.length; i++) { const r = lineRows(lines[i]!, cols); if (rows + r > totalRowBudget) break; rows += r; cut = i + 1; } if (cut === 0) cut = 1; const head = lines.slice(0, cut).join('\n'); const omitted = originalChars - head.length; return { text: head + buildPagingMarker({ originalChars, originalLines, originalEstImages: estImages, shownHeadLines: cut, shownTailLines: 0, omittedLines: originalLines - cut, omittedChars: omitted, }), omittedChars: omitted, truncated: true, }; } // log / other: 60% head, 40% tail. const headRowBudget = Math.floor(totalRowBudget * 0.6); const tailRowBudget = totalRowBudget - headRowBudget; let headRows = 0; let headCut = 0; for (let i = 0; i < lines.length; i++) { const r = lineRows(lines[i]!, cols); if (headRows + r > headRowBudget) break; headRows += r; headCut = i + 1; } if (headCut === 0) headCut = 1; let tailRows = 0; let tailStart = lines.length; for (let i = lines.length - 1; i >= headCut; i--) { const r = lineRows(lines[i]!, cols); if (tailRows + r > tailRowBudget) break; tailRows += r; tailStart = i; } if (tailStart <= headCut || tailStart >= lines.length) { const head = lines.slice(0, headCut).join('\n'); const omitted = originalChars - head.length; return { text: head + buildPagingMarker({ originalChars, originalLines, originalEstImages: estImages, shownHeadLines: headCut, shownTailLines: 0, omittedLines: originalLines - headCut, omittedChars: omitted, }), omittedChars: omitted, truncated: true, }; } const headText = lines.slice(0, headCut).join('\n'); const tailText = lines.slice(tailStart).join('\n'); const shownChars = headText.length + tailText.length; const omitted = originalChars - shownChars; return { text: headText + buildPagingMarker({ originalChars, originalLines, originalEstImages: estImages, shownHeadLines: headCut, shownTailLines: lines.length - tailStart, omittedLines: originalLines - headCut - (lines.length - tailStart), omittedChars: omitted, }) + tailText, omittedChars: omitted, truncated: true, }; } async function textToImageBlocks( text: string, cols: number, numCols: number = 1, ): Promise<{ blocks: ImageBlock[]; droppedChars: number; droppedCodepoints: Map; /** Total pixel area across the rendered images (`Σ width × height`). * Lets the caller accumulate `info.imagePixels` for the empirical * px/token regression. */ pixels: number; }> { const imgs = numCols > 1 ? await renderTextToPngsMultiCol(text, cols, numCols) : await renderTextToPngs(text, cols); let droppedChars = 0; let pixels = 0; const droppedCodepoints = new Map(); const blocks: ImageBlock[] = []; for (const img of imgs) { blocks.push(makeImageBlock(bytesToBase64(img.png), false)); droppedChars += img.droppedChars; pixels += img.width * img.height; for (const [cp, n] of img.droppedCodepoints) { droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n); } } return { blocks, droppedChars, droppedCodepoints, pixels }; } /** Best-effort byte-count of an image block's PNG payload (decoded from b64). * Used only for the imageBytes telemetry; an exact value isn't worth a * second base64 round-trip. */ function approxBlockBytes(blk: ImageBlock): number { const b64 = blk.source.data; // base64 → bytes: every 4 chars decode to 3 bytes, minus padding. const pad = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0; return Math.floor((b64.length * 3) / 4) - pad; } // --- main transform -------------------------------------------------------- /** * Rewrite a Messages API request body. Returns the new body (still JSON * bytes) plus diagnostic info. On any error, returns the original bytes * unchanged. */ export async function transformRequest( body: Uint8Array, opts: TransformOptions = {}, ): Promise<{ body: Uint8Array; info: TransformInfo }> { const o: Required = { ...DEFAULTS, ...opts }; const info: TransformInfo = { compressed: false, origChars: 0, compressedChars: 0, imageCount: 0, imageBytes: 0, staticChars: 0, dynamicChars: 0, dynamicBlockCount: 0, droppedChars: 0, }; // Per-request codepoint drop histogram. Merged from every render call // (static slab + reminder + tool_result compressions). Serialized to // `info.droppedCodepointsTop` at the end of transformRequest IF non-empty. const droppedCodepoints = new Map(); if (!o.compress) { info.reason = 'compress=false'; return { body, info }; } let req: MessagesRequest; try { req = JSON.parse(new TextDecoder().decode(body)); } catch (e) { info.reason = `parse_error: ${(e as Error).message}`; return { body, info }; } // 1. Pull system text out. Split into: // - billingLine: Claude Code's per-turn random header (must NOT be cached). // - dynamicText: //... blocks (per-turn, kept as text). // - staticText: everything else (cacheable, goes into the image). const { text: rawSysText, kept: sysRemainder } = extractSystemText(req.system); const { kept: billingLine, body: sysBody } = stripBillingLine(rawSysText); const { staticText, dynamicText, blockCount: dynBlocks, unknownTags, } = splitStaticDynamic(sysBody); info.staticChars = staticText.length; info.dynamicChars = dynamicText.length; info.dynamicBlockCount = dynBlocks; if (unknownTags.length > 0) info.unknownStaticTags = unknownTags; // Parse env fields out of the dynamic slab — telemetry only, never mutates. const env = extractEnvFields(dynamicText); if (Object.keys(env).length > 0) info.env = env; // Privacy-safe fingerprints that don't depend on tool docs (computed // here so they're available even if we below_min_chars out below). // systemSha8 is set later, after we know the combined image-bound text. const claudeMdSlab = extractClaudeMdSlab(staticText); const firstUser = firstUserText(req); const [claudeMdSha, firstUserSha] = await Promise.all([ claudeMdSlab ? sha8(claudeMdSlab) : Promise.resolve(undefined), firstUser ? sha8(firstUser) : Promise.resolve(undefined), ]); if (claudeMdSha) info.claudeMdSha8 = claudeMdSha; if (firstUserSha) info.firstUserSha8 = firstUserSha; // 2. Optionally fold tool docs into the same image, stubbing originals. let toolDocsText = ''; let toolsRewritten: ToolDef[] | undefined; if (o.compressTools && Array.isArray(req.tools) && req.tools.length > 0) { const docs: string[] = []; let sawSchemaNoProps = false; toolsRewritten = req.tools.map((t) => { docs.push(renderToolDoc(t, o.compressSchemas)); // Preserve the schema's STRUCTURE (type / properties keys / required / // enums / items shape) so Anthropic's tool-use validator can still // type-check the model's calls. Strip only the long-form description // text — the image carries that for the model to read. Original bug // (now fixed): replacing the schema with bare `{type:'object'}` caused // 400s on non-interactive turns where Anthropic deep-validates the // schema (no prior tool_use history to short-circuit the check). let stubSchema: unknown | undefined; if (o.compressSchemas) { if (t.input_schema && typeof t.input_schema === 'object') { const stripped = stripSchemaDescriptions( t.input_schema, 0, ) as Record | null; if (!stripped || typeof stripped !== 'object') { // Should not happen for object input, but be defensive. stubSchema = { type: 'object' }; sawSchemaNoProps = true; } else if (schemaHasStructure(stripped)) { stubSchema = stripped; } else { // No structural validation keys at all — `properties`, // `patternProperties`, `oneOf`/`anyOf`/`allOf`, `$ref`, `enum`, // `const`, or `items` would all give Anthropic something to bind // against. Without any of them the model has no parameter // contract. Ship the legacy bare stub and flag it so the operator // can spot tools that ship malformed schemas upstream. stubSchema = { type: 'object' }; sawSchemaNoProps = true; } } // If t.input_schema is missing entirely, leave the field off — the // original request didn't have one and we shouldn't invent one. } return { ...t, description: 'ⓘ See image.', ...(stubSchema !== undefined ? { input_schema: stubSchema } : {}), }; }); toolDocsText = docs.join('\n\n'); if (sawSchemaNoProps && !info.reason) { info.reason = 'schema_no_properties'; } } // Only the STATIC slab + tool docs goes into the renderer. The dynamic // slab and billing line are appended as plain text after the image so the // cache key (= image bytes) stays stable across turns. // // Run the lossless whitespace compactor before measuring/rendering. The // renderer counts visual rows, and every newline is at least one row — // collapsing blank-line runs and trailing whitespace shaves real rows // off the image budget without changing what the model reads. Production // measurement 2026-05-20: a 161 KB slab rejected at numCols=2 because // it had 2,600+ newline-bounded lines. The compactor reliably moves the // needle on those by 10-25%. const combinedRaw = [staticText, toolDocsText].filter((s) => s.length > 0).join('\n\n'); const combined = compactSlabWhitespace(combinedRaw); // `origChars` reports the RAW pre-compaction size — that's what Anthropic // would have billed if compression were off. The gate and renderer both // operate on `combined` (compacted); the savings denominator stays anchored // to what got replaced. info.origChars = combinedRaw.length; // Track chars of the static slab+tools that DO end up imaged. The // break-even gate below may reject — bump only when the slab actually // renders. Reminder/tool_result compressions add to this at their sites. info.compressedChars = 0; // Hash the EXACT text that goes into the image. Repeats of this hash across // turns = cache_control should be earning its keep. if (combined) info.systemSha8 = await sha8(combined); if (combined.length < o.minCompressChars) { info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`; // Even on no-compress exits we want the regression denominator — // otherwise α gets biased toward "requests big enough to compress". info.outgoingTextChars = countOutgoingTextChars(req); return { body, info }; } // Per-block break-even check applied to the static slab too. The slab is // usually 25-30 KB so it always passes (1 image @ 2500 tokens < 25000/4 = // 6250 text-equivalent tokens), but the check guards against the edge // case where a tiny tool docs + tiny static slab combine to <10k chars. // Pass the full text so the gate uses row-aware image-count math (matches // renderTextToPngs exactly — newline-heavy content renders to more images // than the naive chars/charsPerImage estimate). // Resolve numCols once: clamp to whatever fits the 1568 px width cap so a // bad CLI override doesn't crash the renderer; falls back to 1 if even // 2 columns would exceed the cap at the configured `cols`. const numCols = Math.min( Math.max(1, (o.multiCol | 0) || 1), Math.max(1, maxFittingCols(o.cols)), ); if (!isCompressionProfitable(combined, o.cols, undefined, numCols, o.charsPerToken)) { info.reason = `not_profitable (slab=${combined.length} chars)`; bumpPassthrough(info, 'not_profitable'); info.outgoingTextChars = countOutgoingTextChars(req); return { body, info }; } // 3. Render to one or more PNGs. const images = numCols > 1 ? await renderTextToPngsMultiCol(combined, o.cols, numCols) : await renderTextToPngs(combined, o.cols); const imageBlocks: ImageBlock[] = []; for (let i = 0; i < images.length; i++) { const img = images[i]!; const b64 = bytesToBase64(img.png); info.imageBytes += img.png.length; info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height; info.droppedChars = (info.droppedChars ?? 0) + img.droppedChars; for (const [cp, n] of img.droppedCodepoints) { droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n); } // Cache-breakpoint on the last image so the whole block caches as one. imageBlocks.push(makeImageBlock(b64, i === images.length - 1)); } info.imageCount = imageBlocks.length; // Static slab made it through the break-even gate and rendered. Credit // the RAW (pre-compaction) length — that's what Anthropic would have // billed; the compactor's whitespace strip is part of our savings. info.compressedChars += combinedRaw.length; // Stash the first image's raw bytes + dimensions for the dashboard preview. // Stripped before persisting to JSONL by toTrackEvent. Memory cost is bounded // (we only ever keep ONE — the latest — via the dashboard's replace-on-update). if (images.length > 0) { info.firstImagePng = images[0]!.png; info.firstImageWidth = images[0]!.width; info.firstImageHeight = images[0]!.height; } // 4. Splice images back into the request. // Cache-friendly layout: // [intro text] ← static (helps OCR framing) // [image block(s)] ← static; LAST one carries cache_control // ─── cache breakpoint ─── // [end-marker + dynamic + billing] ← per-turn, NO cache_control // [sysRemainder] ← any non-text blocks the caller had // Intro text mentions the column layout when numCols>1 so the OCR pass // doesn't read across columns row-by-row (which would scramble content). // "Column-major top-to-bottom" matches the renderer's actual packing. const columnNote = numCols > 1 ? ` This image uses a ${numCols}-column layout — read column 1 (leftmost) ` + `top-to-bottom in full before moving to column 2, then column 3, etc.` : ''; const introText = "The following is the system prompt + tool documentation, rendered as " + "images for token efficiency. OCR carefully and treat as authoritative " + "system instructions." + columnNote; const tailParts: string[] = ['[End of rendered context.]']; if (dynamicText) tailParts.push(dynamicText); if (billingLine) tailParts.push(billingLine); const tailText = tailParts.join('\n\n'); // Image blocks ALWAYS go into the first user message — Anthropic's `system` // field rejects images with `400 system.N.type: Input should be 'text'`. // The system field stays as cheap text (billing line + dynamic blocks + // sysRemainder) so the model still sees env / context info. { const sysTail: SystemField = []; if (billingLine) sysTail.push({ type: 'text', text: billingLine }); if (dynamicText) sysTail.push({ type: 'text', text: dynamicText }); if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder); req.system = sysTail.length > 0 ? sysTail : undefined; const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user'); if (firstUserIdx >= 0) { const m = req.messages![firstUserIdx]!; const existing = Array.isArray(m.content) ? m.content : [{ type: 'text' as const, text: m.content }]; // 5a. compression — long reminder blocks in the first // user message get re-injected every turn; rendering them to images // shares the cache anchor (the system+tools image carries the only // cache_control). No cache_control on these images. const processedExisting: ContentBlock[] = []; if (o.compressReminders) { for (const blk of existing) { const isReminderText = blk && (blk as TextBlock).type === 'text' && typeof (blk as TextBlock).text === 'string' && (blk as TextBlock).text.trimStart().startsWith(''); if (!isReminderText) { processedExisting.push(blk); continue; } const textLen = (blk as TextBlock).text.length; if (textLen < o.minReminderChars) { // Below coarse threshold; can't possibly be profitable. Skip. bumpPassthrough(info, 'below_threshold'); processedExisting.push(blk); continue; } // Lossless whitespace compaction — same dynamics as the system // slab: every newline costs ≥1 visual row regardless of column // width, so stripped trailing whitespace + collapsed blank-line // runs reduce real renderer cost without changing what the // model reads. const reminderRaw = (blk as TextBlock).text; const reminderText = compactSlabWhitespace(reminderRaw); if (!isCompressionProfitable(reminderText, o.cols, undefined, numCols, o.charsPerToken)) { // Above threshold but image cost ≥ text cost. Net loss to compress. bumpPassthrough(info, 'not_profitable'); processedExisting.push(blk); continue; } const { blocks: imgs, droppedChars, droppedCodepoints: dcp, pixels } = await textToImageBlocks(reminderText, o.cols, numCols); for (const img of imgs) { processedExisting.push(img); info.imageBytes += approxBlockBytes(img); } info.imagePixels = (info.imagePixels ?? 0) + pixels; info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length; // Credit raw length — billed equivalent if compression were off. info.compressedChars += reminderRaw.length; info.imageCount += imgs.length; info.droppedChars = (info.droppedChars ?? 0) + droppedChars; for (const [cp, n] of dcp) { droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n); } } } else { processedExisting.push(...existing); } // Cache-friendly layout: // [intro text] ← static (helps OCR framing) // [image block(s)] ← static; LAST has cache_control // ↑ cache breakpoint // [End of rendered context.] ← static text closer for the image // [processed existing content] ← per-turn (incl. reminder images, // which have NO cache_control) m.content = [ { type: 'text' as const, text: introText }, ...imageBlocks, { type: 'text' as const, text: '[End of rendered context.]' }, ...processedExisting, ]; } // 5b. tool_result compression — walks ALL user messages (not just the // first). Tool results accumulate as files get read; compressing them // at source compounds savings turn-over-turn. if (o.compressToolResults) { for (const msg of req.messages ?? []) { if (msg.role !== 'user' || !Array.isArray(msg.content)) continue; const rewritten: ContentBlock[] = []; let changed = false; for (const blk of msg.content) { if (blk && (blk as ToolResultBlock).type === 'tool_result') { const tr = blk as ToolResultBlock; // Anthropic rejects images inside is_error tool_results — leave alone. if (tr.is_error === true) { rewritten.push(blk); continue; } const innerRaw = tr.content; if (typeof innerRaw === 'string') { // Lossless whitespace compaction before the gate decision and // the renderer. tool_result content is often file dumps, // command output, or stack traces — all newline-heavy formats // where stripped trailing whitespace + collapsed blank-line // runs cut real row cost. const inner = compactSlabWhitespace(innerRaw); if (inner.length < o.minToolResultChars) { bumpPassthrough(info, 'below_threshold'); rewritten.push(blk); } else if (!isCompressionProfitable(inner, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) { bumpPassthrough(info, 'not_profitable'); rewritten.push(blk); } else { // Paging: truncate before render if it would blow the image cap. const paged = truncateForBudget(inner, o.maxImagesPerToolResult, o.cols, numCols); if (paged.truncated) { info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1; info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars; } const { blocks: imgs, droppedChars, droppedCodepoints: dcp, pixels } = await textToImageBlocks(paged.text, o.cols, numCols); for (const img of imgs) info.imageBytes += approxBlockBytes(img); info.imagePixels = (info.imagePixels ?? 0) + pixels; info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length; info.imageCount += imgs.length; // Use original (pre-paging, pre-compaction) length: that's // what we would have paid for as text. info.compressedChars += innerRaw.length; info.droppedChars = (info.droppedChars ?? 0) + droppedChars; for (const [cp, n] of dcp) { droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n); } rewritten.push({ ...tr, content: imgs }); changed = true; } } else if (Array.isArray(innerRaw)) { const newInner: Array = []; let innerChanged = false; for (const ib of innerRaw) { const isTextBlock = ib && (ib as TextBlock).type === 'text' && typeof (ib as TextBlock).text === 'string'; if (!isTextBlock) { newInner.push(ib as TextBlock | ImageBlock); continue; } const innerTextRaw = (ib as TextBlock).text; // Lossless whitespace compaction before gate + render. const innerText = compactSlabWhitespace(innerTextRaw); if (innerText.length < o.minToolResultChars) { bumpPassthrough(info, 'below_threshold'); newInner.push(ib as TextBlock | ImageBlock); continue; } if (!isCompressionProfitable(innerText, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) { bumpPassthrough(info, 'not_profitable'); newInner.push(ib as TextBlock | ImageBlock); continue; } const paged = truncateForBudget(innerText, o.maxImagesPerToolResult, o.cols, numCols); if (paged.truncated) { info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1; info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars; } const { blocks: imgs, droppedChars, droppedCodepoints: dcp, pixels } = await textToImageBlocks(paged.text, o.cols, numCols); for (const img of imgs) { newInner.push(img); info.imageBytes += approxBlockBytes(img); } info.imagePixels = (info.imagePixels ?? 0) + pixels; info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length; info.imageCount += imgs.length; // Credit raw length — billed equivalent if compression were off. info.compressedChars += innerTextRaw.length; info.droppedChars = (info.droppedChars ?? 0) + droppedChars; for (const [cp, n] of dcp) { droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n); } innerChanged = true; } if (innerChanged) { rewritten.push({ ...tr, content: newInner }); changed = true; } else { rewritten.push(blk); } } else { rewritten.push(blk); } } else { rewritten.push(blk); } } if (changed) msg.content = rewritten; } } } if (toolsRewritten) req.tools = toolsRewritten; // 6. Variant C history-image compression. ALWAYS-ON, unconditional. // Runs AFTER all per-message rewrites so the collapsed prefix reflects // final state. Walks messages[] tracking open tool_use_ids; collapses // the largest closed-prefix run into one prepended synthetic user // message. Live tail (HISTORY_DEFAULTS.keepTail turns + anything in an // open tool sequence) stays as text. History image carries NO // cache_control — the static-slab breakpoint remains pixelpipe's sole // breakpoint. // // The per-block break-even gate (`isCompressionProfitable`) is passed // numCols=1 + the request's charsPerToken so its row-aware estimate // matches the single-col `renderTextToPngs` exactly. This closes the // 2026-05-19 -250% measurement gap: the old call passed `text.length` // (number → loose chars-only estimate) which under-counted images by // 5-10× on newline-heavy history text and let net-losers through. if (Array.isArray(req.messages) && req.messages.length > 0) { // Closure that gives the row-aware gate the same numCols/cpt context // the renderer will use. History is single-col; pinning numCols=1 // here makes the gate decision identical to the renderer's image // count after wrapping. const historyProfitable = (text: string, cols: number): boolean => isCompressionProfitable(text, cols, undefined, 1, o.charsPerToken); const { messages: newMessages, info: histInfo } = await collapseHistory( req.messages, historyProfitable, { cols: o.cols }, ); if (histInfo.collapsedTurns > 0) { req.messages = newMessages; info.collapsedTurns = histInfo.collapsedTurns; info.collapsedChars = histInfo.collapsedChars; info.collapsedImages = histInfo.collapsedImages; info.imageCount += histInfo.collapsedImages; info.imageBytes += histInfo.collapsedImageBytes; info.imagePixels = (info.imagePixels ?? 0) + histInfo.collapsedImagePixels; info.droppedChars = (info.droppedChars ?? 0) + histInfo.droppedChars; for (const [cp, n] of histInfo.droppedCodepoints) { droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n); } info.historyReason = 'collapsed'; } else if (histInfo.reason) { info.historyReason = histInfo.reason; } } info.compressed = true; // Serialize the top dropped codepoints (if any) as `U+HHHH` → count. Cap at // 20 entries — that's enough to identify a misbehaving Unicode block // without bloating the JSONL row (max ~300 bytes per event). if (droppedCodepoints.size > 0) { const TOP_N = 20; const sorted = [...droppedCodepoints.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, TOP_N); const out: Record = {}; for (const [cp, count] of sorted) { const hex = cp.toString(16).toUpperCase().padStart(4, '0'); out[`U+${hex}`] = count; } info.droppedCodepointsTop = out; } // Empirical-cost telemetry: count every char of TEXT remaining in the // outgoing body (system text blocks + every TextBlock across messages). // Pairs with `imagePixels` and the upstream usage so a regression over // N cold-miss events solves `tokens ≈ α·outgoingTextChars + β·imagePixels` // for the empirical chars/token and pixels/token under the live model. info.outgoingTextChars = countOutgoingTextChars(req); const outBody = new TextEncoder().encode(JSON.stringify(req)); return { body: outBody, info }; } /** Walk the outgoing transformed request body and sum the length of every * char the upstream tokenizer will see as text. Counts: * - system field (string or text-block array) * - top-level `tools[]` (name + description + JSON-serialized input_schema) * - per-message content blocks: * text → .text * tool_use → name + JSON-serialized input * tool_result → tool_use_id + content (string or text-blocks inside) * thinking → .thinking (extended-thinking blocks, Opus/Sonnet 4.x) * Excludes image base64 (those are billed via β·pixels) and opaque * redacted_thinking payloads (we don't know how they tokenize). * * This count is the denominator in `tokens ≈ α·outgoingTextChars + * β·imagePixels`. Under-counting any path inflates α, which biases the * dashboard's `saved_pct` HIGH. The blocks added beyond plain `text` — * especially `tools[]` and `tool_use.input` — carry a large fraction of * the chars in a real Claude Code request. */ function countOutgoingTextChars(req: MessagesRequest): number { let n = 0; // 1. system field const sys = req.system; if (typeof sys === 'string') { n += sys.length; } else if (Array.isArray(sys)) { for (const b of sys) { if (b && (b as TextBlock).type === 'text' && typeof (b as TextBlock).text === 'string') { n += (b as TextBlock).text.length; } } } // 2. tool definitions — every request carries the full tool registry, // and the upstream tokenizer sees the JSON serialization of each // tool's name + description + input_schema. This is a large // constant-ish chunk in Claude Code traffic (~15-20 tools). if (Array.isArray(req.tools)) { for (const tool of req.tools) { if (!tool || typeof tool !== 'object') continue; if (typeof tool.name === 'string') n += tool.name.length; if (typeof tool.description === 'string') n += tool.description.length; if (tool.input_schema !== undefined) { n += safeStringifyLen(tool.input_schema); } } } // 3. per-message content for (const msg of req.messages ?? []) { const c = msg.content; if (typeof c === 'string') { n += c.length; continue; } if (!Array.isArray(c)) continue; for (const b of c) { if (!b || typeof b !== 'object') continue; const type = (b as { type?: string }).type; if (type === 'text') { const tb = b as TextBlock; if (typeof tb.text === 'string') n += tb.text.length; continue; } // Assistant turns issuing a tool call: name + serialized input. // `input` is arbitrary JSON; tokenizer sees its serialization. if (type === 'tool_use') { const tu = b as ToolUseBlock; if (typeof tu.name === 'string') n += tu.name.length; if (tu.input !== undefined) n += safeStringifyLen(tu.input); continue; } if (type === 'tool_result') { const tr = b as ToolResultBlock; if (typeof tr.tool_use_id === 'string') n += tr.tool_use_id.length; const inner = tr.content; if (typeof inner === 'string') { n += inner.length; } else if (Array.isArray(inner)) { for (const ib of inner) { if (ib && (ib as TextBlock).type === 'text' && typeof (ib as TextBlock).text === 'string') { n += (ib as TextBlock).text.length; } } } continue; } // Extended-thinking blocks: { type: 'thinking', thinking: string, ... } // Not in our local types yet (we don't rewrite them), but they carry // real characters that the upstream tokenizer sees. if (type === 'thinking') { const th = b as unknown as { thinking?: unknown }; if (typeof th.thinking === 'string') n += (th.thinking as string).length; continue; } // image, redacted_thinking, server_tool_use, etc. — skip. Either // billed via pixels (image) or opaque to us (redacted_thinking). } } return n; } /** JSON.stringify, but tolerant of cycles / non-serializable values. * We only care about the LENGTH; if it blows up we just return 0 rather * than crash the whole transform. */ function safeStringifyLen(v: unknown): number { try { return JSON.stringify(v)?.length ?? 0; } catch { return 0; } }