diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 3397075..29425a2 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -33,6 +33,9 @@ export interface TrackEvent { static_chars?: number; dynamic_chars?: number; dynamic_block_count?: number; + /** Image count attributable to compressing `` 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; diff --git a/src/core/transform.ts b/src/core/transform.ts index 434d66d..97318b6 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -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 `` 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 = { compressSystem: true, compressTools: true, compressSchemas: true, + compressReminders: true, minCompressChars: 2000, + // Matches Python defaults: 1000 chars for . + 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 `` 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 { + 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. 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('') && + (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, + ]; } } diff --git a/src/node.ts b/src/node.ts index ad6e84e..1ad96ea 100644 --- a/src/node.ts +++ b/src/node.ts @@ -21,7 +21,9 @@ interface CliOpts { compress: boolean; compressTools: boolean; compressSchemas: boolean; + compressReminders: boolean; minCompressChars: number; + minReminderChars: number; placement: 'system' | 'user'; cols: number; /** When true, append per-request events to eventsFile. Default-on. */ @@ -43,7 +45,9 @@ function parseCli(argv: string[]): CliOpts { compress: envFlag('COMPRESS', true), compressTools: envFlag('COMPRESS_TOOLS', true), compressSchemas: envFlag('COMPRESS_SCHEMAS', true), + compressReminders: envFlag('COMPRESS_REMINDERS', true), minCompressChars: Number(process.env.MIN_COMPRESS_CHARS ?? 2000), + minReminderChars: Number(process.env.MIN_REMINDER_CHARS ?? 1000), placement: (process.env.PLACEMENT as 'system' | 'user') ?? 'user', cols: Number(process.env.COLS ?? 100), track: envFlag('PIXELPIPE_TRACK', true), @@ -61,7 +65,9 @@ function parseCli(argv: string[]): CliOpts { case '--no-compress': o.compress = false; break; case '--no-tools': o.compressTools = false; break; case '--no-schemas': o.compressSchemas = false; break; + case '--no-reminders': o.compressReminders = false; break; case '--min-chars': o.minCompressChars = Number(eat()); break; + case '--min-reminder-chars': o.minReminderChars = Number(eat()); break; case '--placement': o.placement = eat() as 'system' | 'user'; break; case '--cols': o.cols = Number(eat()); break; case '--no-track': o.track = false; break; @@ -92,7 +98,9 @@ Options: --no-compress disable all compression (pure passthrough) --no-tools don't fold tool docs into the image --no-schemas don't include input_schema JSON in the image - --min-chars skip compression below this many chars (default 2000) + --no-reminders don't image-compress blocks + --min-chars skip system compression below this many chars (default 2000) + --min-reminder-chars per-block threshold for --no-reminders (default 1000) --placement 'system' or 'user' (default user; 'system' is rejected by the API for image blocks) --cols soft-wrap column count (default 100) @@ -103,8 +111,8 @@ Options: Environment: Same as flags via PORT, ANTHROPIC_UPSTREAM, COMPRESS, COMPRESS_TOOLS, - COMPRESS_SCHEMAS, MIN_COMPRESS_CHARS, PLACEMENT, COLS, PIXELPIPE_TRACK, - PIXELPIPE_LOG. + COMPRESS_SCHEMAS, COMPRESS_REMINDERS, MIN_COMPRESS_CHARS, + MIN_REMINDER_CHARS, PLACEMENT, COLS, PIXELPIPE_TRACK, PIXELPIPE_LOG. Use with Claude Code: ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude @@ -305,7 +313,9 @@ async function main(): Promise { compress: opts.compress, compressTools: opts.compressTools, compressSchemas: opts.compressSchemas, + compressReminders: opts.compressReminders, minCompressChars: opts.minCompressChars, + minReminderChars: opts.minReminderChars, placement: opts.placement, cols: opts.cols, }; @@ -326,8 +336,11 @@ async function main(): Promise { // info.firstImagePng, so capturing has to happen on the raw event. dashboard.update(e); // Terse human-readable console line. + const extra: string[] = []; + if (e.info?.reminderImgs) extra.push(`rem+${e.info.reminderImgs}`); + const extraTag = extra.length > 0 ? ` (${extra.join(' ')})` : ''; const tag = e.info?.compressed - ? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B` + ? `compressed ${e.info.origChars}ch → ${e.info.imageCount}img/${e.info.imageBytes}B${extraTag}` : (e.info?.reason ?? ''); const cacheRead = e.usage?.cache_read_input_tokens ?? 0; const inputTokens = e.usage?.input_tokens ?? 0; @@ -389,7 +402,7 @@ async function main(): Promise { server.listen(opts.port, () => { console.log(`[pixelpipe] listening on http://127.0.0.1:${opts.port} → ${opts.upstream}`); console.log( - `[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`, + `[pixelpipe] config: compress=${opts.compress} tools=${opts.compressTools} schemas=${opts.compressSchemas} reminders=${opts.compressReminders} min=${opts.minCompressChars} placement=${opts.placement} cols=${opts.cols}`, ); if (opts.track) console.log(`[pixelpipe] tracking events → ${opts.eventsFile}`); else console.log('[pixelpipe] tracking disabled (--no-track or PIXELPIPE_TRACK=0)'); diff --git a/src/worker.ts b/src/worker.ts index 76bfe5f..5aaaed8 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -22,7 +22,9 @@ export interface Env { COMPRESS?: string; COMPRESS_TOOLS?: string; COMPRESS_SCHEMAS?: string; + COMPRESS_REMINDERS?: string; MIN_COMPRESS_CHARS?: string; + MIN_REMINDER_CHARS?: string; PLACEMENT?: string; COLS?: string; /** When "0" / "false", disable per-request event JSON logs. Default-on. @@ -40,7 +42,9 @@ export default { compress: truthy(env.COMPRESS, true), compressTools: truthy(env.COMPRESS_TOOLS, true), compressSchemas: truthy(env.COMPRESS_SCHEMAS, true), + compressReminders: truthy(env.COMPRESS_REMINDERS, true), minCompressChars: env.MIN_COMPRESS_CHARS ? Number(env.MIN_COMPRESS_CHARS) : 2000, + minReminderChars: env.MIN_REMINDER_CHARS ? Number(env.MIN_REMINDER_CHARS) : 1000, placement: (env.PLACEMENT as 'system' | 'user') ?? 'user', cols: env.COLS ? Number(env.COLS) : 100, }; diff --git a/tests/render.test.ts b/tests/render.test.ts index 6a0c13a..c901ae5 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -373,4 +373,66 @@ describe('transform', () => { expect(cached.length).toBe(1); expect(cached[0].cache_control.ttl).toBe('1h'); }); + + it('compresses long blocks in the first user message', async () => { + const reminder = '\n' + 'a long policy note. '.repeat(200) + '\n'; + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'real user prompt' }, + { type: 'text', text: reminder }, + ], + }, + ], + system: 'claude.md\n'.repeat(500), + }), + ); + const { body: outBytes, info } = await transformRequest(body); + expect(info.reminderImgs).toBeGreaterThanOrEqual(1); + + const out = JSON.parse(new TextDecoder().decode(outBytes)); + const content = out.messages[0].content as any[]; + // Reminder text must NOT appear as a text block anymore. + for (const b of content) { + if (b.type === 'text') expect(b.text).not.toContain(''); + } + // But the user's actual prompt must still be there. + const userTexts = content.filter((b: any) => b.type === 'text').map((b: any) => b.text); + expect(userTexts.some((t: string) => t.includes('real user prompt'))).toBe(true); + + // Reminder images carry NO cache_control (only the system+tools image + // does — Anthropic caps at 4 breakpoints). + const reminderImageBlocks = content.filter( + (b: any) => b.type === 'image' && !b.cache_control, + ); + expect(reminderImageBlocks.length).toBeGreaterThanOrEqual(info.reminderImgs ?? 0); + }); + + it('leaves short blocks alone (below minReminderChars)', async () => { + const shortReminder = '\nshort note\n'; + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: shortReminder }], + }, + ], + system: 'claude.md\n'.repeat(500), + }), + ); + const { body: outBytes, info } = await transformRequest(body); + expect(info.reminderImgs ?? 0).toBe(0); + const out = JSON.parse(new TextDecoder().decode(outBytes)); + const allText = (out.messages[0].content as any[]) + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join('\n'); + expect(allText).toContain(''); + }); });