Files
pxpipe/src/core/transform.ts
T
teamchong e433b26caa feat(transform): lossless whitespace compactor on slab + reminder + tool_result
Production data (2026-05-20): real 161,101-char system slab being rejected
by the row-aware profitability gate as `not_profitable`. multiCol=2 packs
282 rows/image, but 2000+ newline-bounded lines need ~10 images, and at
2500 tok × 10 × 1.10 = 27,500 tok the gate (correctly) refuses since the
text baseline is cheaper.

Fix: strip per-line trailing whitespace and collapse 3+ consecutive
newlines to exactly 2 *before* feeding the renderer. Both transforms are
lossless for the model (whitespace at line-end carries no signal; blank-
line runs longer than 2 are visual noise). This drops visual-row count
without changing meaning, flipping borderline-rejected slabs profitable.

Accounting uses raw (pre-compaction) length for `origChars` and
`compressedChars`, so the savings credit reflects what Anthropic would
have billed — not the post-compaction figure.

Applied at four sites:
- combined static slab (system + tool docs)
- system-reminder block
- tool_result string content
- tool_result array-of-blocks (joined text)

8 new unit tests for `compactSlabWhitespace`: empty input, trailing
whitespace stripping, 3+ newline collapse, paragraph-break preservation,
indent preservation, idempotency, realistic markdown shrink.

270 tests pass, typecheck clean, build clean.
2026-05-20 00:07:42 -04:00

1807 lines
77 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,
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 `<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;
/** 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<TransformOptions> = {
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 <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;
/** 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 (<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 (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. <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.
*
* 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<number, number>;
/** 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<number, number>();
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<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.
//
// 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. <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;
}
// 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<TextBlock | ImageBlock> = [];
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<string, number> = {};
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;
}
}