Files
pxpipe/src/core/transform.ts
T
teamchong 8fbd53ea3e dashboard: fix bogus savings math + emit compressed_chars
The dashboard headline was showing 0% saved because the math was
apples-to-oranges:
  - orig_chars = static slab + tool docs ONLY (~135 KB)
  - image_count = ALL images including reminders + tool_results (~33)
  - txtReplaced = 135k/4 = 33.7k tokens
  - imgTokensEst = 33 × 2500 = 82.5k tokens
  - extraText = max(0, 33.7k - 82.5k) = 0  ← clamped negative
  - baseline == paid → 'saved 0%' headline despite real savings happening

Two fixes:

1. Track 'compressedChars' separately from 'origChars'. It accumulates
   only when a block actually image-renders (passes the break-even gate
   AND any per-block min thresholds). Includes the static slab + all
   compressed reminders + all compressed tool_results.

2. Remove the Math.max(0,...) clamp in baselineCost(). Negative savings
   surface honestly — they're the signal that small-block compressions
   are cost-bleeding, which the break-even gate is supposed to prevent.

Wires:
  - TransformInfo.compressedChars (new field, mandatory).
  - All 4 image-render success paths accumulate (static slab + reminder
    + tool_result string branch + tool_result array branch).
  - TrackEvent.compressed_chars (new optional field, emitted when > 0).
  - dashboard.baselineCost() takes compressedChars now; clamp removed.
  - sessions.aggregateSessions() also uses compressed_chars + honest math.

229/229 tests passing. Bundle stays at 496 KB gzipped.

Post-restart, the dashboard 'tokens saved' will show real numbers
including negatives. Empirically expect ~1-5% net positive in
steady-state cache-hit traffic.
2026-05-18 23:01:12 -04:00

1419 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
} from './types.js';
import { renderTextToPngs, 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;
/** Compress the system field. */
compressSystem?: 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 `<system-reminder>` 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;
/** Where to attach the image block — system field, or first user message. */
placement?: 'system' | 'user';
/** 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;
/** Variant C history-image compression: walk `messages[]` from the head,
* find the largest closed-tool-sequence prefix, render its text into one
* prepended user message with image blocks, and collapse those messages
* out of the live tail. Off by default — round-3 spec marked this as
* MARGINAL (~1% per-call cost reduction) with HIGH risk on cache topology.
* Enable opt-in once telemetry confirms the cache breakpoint won't fight
* with Claude Code's own upstream breakpoint placement. */
compressHistory?: boolean;
/** Number of tail turns to KEEP as text when `compressHistory` is on. The
* most-recent assistant turn (carrying Opus 4.7's thinking signature) is
* always in the tail by construction. Default 4. */
historyKeepTail?: number;
/** Minimum closed-prefix turn count before we bother collapsing. Cache-
* amortization math from round-3 only pays out at scale — collapsing 2-3
* turns costs more in image overhead than it saves. Default 10. */
historyMinPrefix?: number;
}
const DEFAULTS: Required<TransformOptions> = {
compress: true,
compressSystem: 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,
// Anthropic's `system` field accepts text blocks only — image blocks there
// come back as `400 system.N.type: Input should be 'text'`. Images must go
// into a user message instead.
placement: 'user',
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,
// Variant C history-image: OFF by default. Round-3 measurement put the
// savings at ~1% per call against ~3% cost-bleed risk if cache topology
// turns out wrong upstream. Enable via env / CLI flag once telemetry
// shows the static-slab breakpoint still hits when this is wired.
compressHistory: false,
historyKeepTail: 4,
historyMinPrefix: 10,
};
// --- per-block break-even check ---
//
// Anthropic's real per-image cost is ~2,500 tokens (history-researcher's
// round-3 N=33 measurement). Compressing a text block to an image is only
// profitable when the text would have cost MORE than `ceil(textLen / chars-
// per-image) × tokens-per-image` tokens. For small blocks, this is a NET
// LOSS — image cost dominates — and we should leave them as text.
//
// 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. The check below is the real gate.
/** 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 (see dashboard.ts comment for the 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 = 2500;
/** 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;
}
/** Returns true iff image-compressing a text block of `textLen` chars would
* actually save tokens vs leaving it as text. Used as the gate before every
* image-encoding decision in transformRequest.
*
* `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(textLen: number, cols: number = DEFAULTS.cols): boolean {
const charsPerImage = maxCharsPerImage(cols);
const estImages = Math.max(1, Math.ceil(textLen / charsPerImage));
const imageTokensCost = estImages * TOKENS_PER_IMAGE;
const textTokensEquivalent = textLen / CHARS_PER_TOKEN;
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 <env> + 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 <git_status> 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;
/** 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 (<env>, <context>, 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 `<system-reminder>` 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<string, number>;
/** 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 (compressHistory off, no closed prefix, etc.). */
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';
}
// --- 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. <env>, <context>, <git_status>, <directoryStructure>,
* <system-reminder>). 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 <types> 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 <tag ...?>...</tag> 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]*?</\\1>`,
'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 <recent_files>...</recent_files>) 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<string>(DYNAMIC_BLOCK_TAGS);
const knownStatic = new Set<string>(KNOWN_STATIC_TAGS);
const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g;
const unknown = new Set<string>();
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<string> {
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 = /<env>([\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 <git_status>, <context name="git">, 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<string, unknown>;
const out: Record<string, unknown> = {};
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<string, unknown> = {};
for (const [pk, pv] of Object.entries(v as Record<string, unknown>)) {
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<string, unknown>): boolean {
for (const k of SCHEMA_STRUCTURAL_KEYS) {
if (k in schema) return true;
}
return false;
}
/** Build the "## Tool: name\n<desc>\n<schema>" block for one tool definition. */
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, null, 2) + '\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. */
export function estimateImageCount(textOrLen: string | number, cols: number): number {
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))));
}
const rows = countVisualRows(textOrLen, cols);
return Math.max(1, Math.ceil(rows / LINES_PER_IMAGE));
}
/** 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,
): { text: string; omittedChars: number; truncated: boolean } {
const estImages = estimateImageCount(text, cols);
if (estImages <= maxImages) return { text, omittedChars: 0, truncated: false };
const totalRowBudget = Math.max(8, maxImages * LINES_PER_IMAGE - 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,
): Promise<{
blocks: ImageBlock[];
droppedChars: number;
droppedCodepoints: Map<number, number>;
}> {
const imgs = await renderTextToPngs(text, cols);
let droppedChars = 0;
const droppedCodepoints = new Map<number, number>();
const blocks: ImageBlock[] = [];
for (const img of imgs) {
blocks.push(makeImageBlock(bytesToBase64(img.png), false));
droppedChars += img.droppedChars;
for (const [cp, n] of img.droppedCodepoints) {
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
}
}
return { blocks, droppedChars, droppedCodepoints };
}
/** 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<TransformOptions> = { ...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<number, number>();
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: <env>/<context>/... 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<string, unknown> | 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.
const combined = [staticText, toolDocsText].filter((s) => s.length > 0).join('\n\n');
info.origChars = combined.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})`;
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.
if (!isCompressionProfitable(combined.length, o.cols)) {
info.reason = `not_profitable (slab=${combined.length} chars)`;
bumpPassthrough(info, 'not_profitable');
return { body, info };
}
// 3. Render to one or more PNGs.
const images = 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.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.
info.compressedChars += combined.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
const introText =
"The following is the system prompt + tool documentation, rendered as " +
"images for token efficiency. OCR carefully and treat as authoritative " +
"system instructions.";
const tailParts: string[] = ['[End of rendered context.]'];
if (dynamicText) tailParts.push(dynamicText);
if (billingLine) tailParts.push(billingLine);
const tailText = tailParts.join('\n\n');
const newSystem: SystemField = [];
newSystem.push({ type: 'text', text: introText });
newSystem.push(...imageBlocks);
newSystem.push({ type: 'text', text: tailText });
if (Array.isArray(sysRemainder)) newSystem.push(...sysRemainder);
if (o.placement === 'system' && o.compressSystem) {
req.system = newSystem;
} else {
// Placement = user: image goes into the first user message; billing line
// and dynamic blocks stay in the system field as cheap text 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. <system-reminder> 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('<system-reminder>');
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;
}
if (!isCompressionProfitable(textLen, o.cols)) {
// Above threshold but image cost ≥ text cost. Net loss to compress.
bumpPassthrough(info, 'not_profitable');
processedExisting.push(blk);
continue;
}
const reminderText = (blk as TextBlock).text;
const { blocks: imgs, droppedChars, droppedCodepoints: dcp } =
await textToImageBlocks(reminderText, o.cols);
for (const img of imgs) {
processedExisting.push(img);
info.imageBytes += approxBlockBytes(img);
}
info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length;
info.compressedChars += reminderText.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 inner = tr.content;
if (typeof inner === 'string') {
if (inner.length < o.minToolResultChars) {
bumpPassthrough(info, 'below_threshold');
rewritten.push(blk);
} else if (!isCompressionProfitable(inner.length, o.cols)) {
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);
if (paged.truncated) {
info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
}
const { blocks: imgs, droppedChars, droppedCodepoints: dcp } =
await textToImageBlocks(paged.text, o.cols);
for (const img of imgs) info.imageBytes += approxBlockBytes(img);
info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length;
info.imageCount += imgs.length;
// Use original (pre-paging) length: that's what we would have
// paid for as text.
info.compressedChars += inner.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(inner)) {
const newInner: Array<TextBlock | ImageBlock> = [];
let innerChanged = false;
for (const ib of inner) {
const isTextBlock =
ib &&
(ib as TextBlock).type === 'text' &&
typeof (ib as TextBlock).text === 'string';
if (!isTextBlock) {
newInner.push(ib as TextBlock | ImageBlock);
continue;
}
const innerText = (ib as TextBlock).text;
if (innerText.length < o.minToolResultChars) {
bumpPassthrough(info, 'below_threshold');
newInner.push(ib as TextBlock | ImageBlock);
continue;
}
if (!isCompressionProfitable(innerText.length, o.cols)) {
bumpPassthrough(info, 'not_profitable');
newInner.push(ib as TextBlock | ImageBlock);
continue;
}
const paged = truncateForBudget(innerText, o.maxImagesPerToolResult, o.cols);
if (paged.truncated) {
info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
}
const { blocks: imgs, droppedChars, droppedCodepoints: dcp } =
await textToImageBlocks(paged.text, o.cols);
for (const img of imgs) {
newInner.push(img);
info.imageBytes += approxBlockBytes(img);
}
info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length;
info.imageCount += imgs.length;
info.compressedChars += innerText.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. Runs AFTER all per-message
// rewrites so the collapsed prefix reflects final state. Off by default —
// round-3 spec marks the savings (~1% per call) as marginal vs the cache-
// topology risk. When on, walks messages[] back-to-front tracking open
// tool_use_ids; collapses the largest closed-prefix run into one prepended
// synthetic user message. Live tail (keepTail turns + anything in an open
// tool sequence) stays as text. History image carries NO cache_control on
// first ship — the static-slab breakpoint remains the sole pixelpipe
// breakpoint until telemetry shows otherwise.
if (o.compressHistory && Array.isArray(req.messages) && req.messages.length > 0) {
const { messages: newMessages, info: histInfo } = await collapseHistory(
req.messages,
isCompressionProfitable,
{
keepTail: o.historyKeepTail,
minCollapsePrefix: o.historyMinPrefix,
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.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<string, number> = {};
for (const [cp, count] of sorted) {
const hex = cp.toString(16).toUpperCase().padStart(4, '0');
out[`U+${hex}`] = count;
}
info.droppedCodepointsTop = out;
}
const outBody = new TextEncoder().encode(JSON.stringify(req));
return { body: outBody, info };
}