transform: compress <system-reminder> blocks in the first user message

Claude Code re-injects the same long <system-reminder> blocks (CLAUDE.md
hints, task-tools reminders, the dynamic skills list, etc.) into the
FIRST user message on every turn. They're text, so they're not part of
the system+tools image we already cache — they hit the wire fresh every
turn. This rewrite renders any reminder block ≥ minReminderChars (1000
by default, matching legacy/python/proxy.py) to PNG images placed inside
the user message content array, just like the static slab.

Why: the system+tools image is the only cache_control breakpoint we're
allowed (Anthropic caps at 4 and Claude Code uses the others). Per-block
reminder images therefore carry NO cache_control — they're per-turn
content that the model OCRs without prefix-cache benefit. The win is
token cost, not cache: reminders are 65-73% cheaper as PNG than as text,
and a typical turn has 1-3 of them.

Layout inside the first user message after compression:
  [intro text]                       ← static (helps OCR framing)
  [image block(s)]                   ← static; LAST has cache_control
                                        ↑ cache breakpoint (the only one)
  [End of rendered context.]         ← static text closer for the image
  [processed existing content]       ← per-turn (incl. reminder images)

Wire-up:
  - src/core/transform.ts: new TransformOptions.compressReminders (default
    true) + minReminderChars (default 1000). Two new helpers,
    textToImageBlocks() (wraps renderTextToPngs + makeImageBlock with
    no cache_control) and approxBlockBytes() (b64 → byte-count for the
    imageBytes telemetry, no second base64 round-trip). The placement
    ='user' branch now walks `existing` and rewrites long reminders.
  - src/core/tracker.ts: TrackEvent.reminder_imgs flows from
    TransformInfo.reminderImgs through toTrackEvent so `pixelpipe stats`
    and the dashboard can attribute compression contributions.
  - src/worker.ts: COMPRESS_REMINDERS + MIN_REMINDER_CHARS env vars.
  - src/node.ts: matching CliOpts, parseCli cases (--no-reminders,
    --min-reminder-chars), help text, env-var summary, and `rem+N` in
    the per-request console line when reminder_imgs > 0.

Tests:
  - "compresses long <system-reminder> blocks in the first user message"
    confirms info.reminderImgs ≥ 1, the reminder text is gone from
    user-content text blocks, the user's actual prompt survives, and the
    new reminder image blocks carry NO cache_control.
  - "leaves short <system-reminder> blocks alone (below minReminderChars)"
    confirms info.reminderImgs is 0 and the reminder text passes through
    as text.
This commit is contained in:
teamchong
2026-05-18 17:29:56 -04:00
parent ba13bf3a42
commit 40037568e2
5 changed files with 170 additions and 10 deletions
+4
View File
@@ -33,6 +33,9 @@ export interface TrackEvent {
static_chars?: number;
dynamic_chars?: number;
dynamic_block_count?: number;
/** Image count attributable to compressing `<system-reminder>` blocks in
* the first user message. */
reminder_imgs?: number;
/** Tag names found in the static slab we don't recognize. Canary for
* Claude Code releases that add new dynamic tags. */
unknown_static_tags?: string[];
@@ -92,6 +95,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent {
if (info.staticChars !== undefined) out.static_chars = info.staticChars;
if (info.dynamicChars !== undefined) out.dynamic_chars = info.dynamicChars;
if (info.dynamicBlockCount !== undefined) out.dynamic_block_count = info.dynamicBlockCount;
if (info.reminderImgs !== undefined) out.reminder_imgs = info.reminderImgs;
if (info.unknownStaticTags && info.unknownStaticTags.length > 0)
out.unknown_static_tags = info.unknownStaticTags;
if (info.systemSha8) out.system_sha8 = info.systemSha8;
+82 -5
View File
@@ -9,7 +9,14 @@
* minimum. Stricter byte-for-byte parity is verified in tests.
*/
import type { ImageBlock, MessagesRequest, SystemField, TextBlock, ToolDef } from './types.js';
import type {
ContentBlock,
ImageBlock,
MessagesRequest,
SystemField,
TextBlock,
ToolDef,
} from './types.js';
import { renderTextToPngs } from './render.js';
import { bytesToBase64 } from './png.js';
@@ -22,8 +29,14 @@ export interface TransformOptions {
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;
/** Don't compress if total compressible chars below this. */
minCompressChars?: number;
/** Per-block threshold for compressReminders (chars). */
minReminderChars?: number;
/** Where to attach the image block — system field, or first user message. */
placement?: 'system' | 'user';
/** Soft-wrap column count. */
@@ -35,7 +48,10 @@ const DEFAULTS: Required<TransformOptions> = {
compressSystem: true,
compressTools: true,
compressSchemas: true,
compressReminders: true,
minCompressChars: 2000,
// Matches Python defaults: 1000 chars for <system-reminder>.
minReminderChars: 1000,
// 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.
@@ -91,6 +107,9 @@ export interface TransformInfo {
/** 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;
}
// --- helpers ---------------------------------------------------------------
@@ -314,6 +333,25 @@ function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock {
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). */
async function textToImageBlocks(text: string, cols: number): Promise<ImageBlock[]> {
const imgs = await renderTextToPngs(text, cols);
return imgs.map((img) => makeImageBlock(bytesToBase64(img.png), false));
}
/** 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 --------------------------------------------------------
/**
@@ -472,10 +510,49 @@ export async function transformRequest(
const existing = Array.isArray(m.content)
? m.content
: [{ type: 'text' as const, text: m.content }];
// Only the intro + images belong in a user message — the end marker
// and dynamic blocks live in the system field above.
const userPrefix: TextBlock[] = [{ type: 'text', text: introText }];
m.content = [...userPrefix, ...imageBlocks, ...existing];
// 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) {
if (
blk &&
(blk as TextBlock).type === 'text' &&
typeof (blk as TextBlock).text === 'string' &&
(blk as TextBlock).text.trimStart().startsWith('<system-reminder>') &&
(blk as TextBlock).text.length >= o.minReminderChars
) {
const imgs = await textToImageBlocks((blk as TextBlock).text, o.cols);
for (const img of imgs) {
processedExisting.push(img);
info.imageBytes += approxBlockBytes(img);
}
info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length;
info.imageCount += imgs.length;
} else {
processedExisting.push(blk);
}
}
} 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,
];
}
}