diff --git a/src/core/transform.ts b/src/core/transform.ts index 04fc8ff..93bc9ad 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -9,7 +9,7 @@ * minimum. Stricter byte-for-byte parity is verified in tests. */ -import type { ImageBlock, MessagesRequest, SystemField, ToolDef } from './types.js'; +import type { ImageBlock, MessagesRequest, SystemField, TextBlock, ToolDef } from './types.js'; import { renderTextToPngs } from './render.js'; import { bytesToBase64 } from './png.js'; @@ -46,6 +46,12 @@ export interface TransformInfo { origChars: number; imageCount: number; imageBytes: number; + /** Length of the static (cacheable) slab rendered into the image. */ + staticChars: number; + /** Length of the dynamic (per-turn) slab kept as plain text. */ + dynamicChars: number; + /** Number of dynamic blocks detected (, , etc.). */ + dynamicBlockCount: number; } // --- helpers --------------------------------------------------------------- @@ -66,6 +72,53 @@ function extractSystemText(sys: SystemField | undefined): { text: string; kept: return { text: textParts.join('\n\n'), kept }; } +/** + * Claude Code injects a handful of per-turn dynamic blocks into the system + * prompt (e.g. , , , , + * ). Including these in the rendered image kills the + * Anthropic prompt cache because the bytes drift turn-to-turn. Splitting + * them out lets us render the static slab (CLAUDE.md, agent defs, tool docs) + * with cache_control while forwarding the dynamic slab as cheap text so the + * model still sees cwd / git status / today's date. + */ +const DYNAMIC_BLOCK_TAGS = [ + 'env', + 'context', + 'git_status', + 'directoryStructure', + 'system-reminder', +] as const; + +function splitStaticDynamic(text: string): { + staticText: string; + dynamicText: string; + blockCount: number; +} { + if (!text) return { staticText: '', dynamicText: '', blockCount: 0 }; + // Match ... where tag ∈ DYNAMIC_BLOCK_TAGS. Closing tag + // must match opening tag exactly. Non-greedy body — earliest close wins. + const pattern = new RegExp( + `<(${DYNAMIC_BLOCK_TAGS.join('|')})(\\s[^>]*)?>[\\s\\S]*?`, + 'g', + ); + const dynamicParts: string[] = []; + let staticBuf = ''; + let cursor = 0; + let m: RegExpExecArray | null; + while ((m = pattern.exec(text)) !== null) { + staticBuf += text.slice(cursor, m.index); + dynamicParts.push(m[0]); + cursor = m.index + m[0].length; + } + staticBuf += text.slice(cursor); + 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, + }; +} + /** * 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 @@ -117,6 +170,9 @@ export async function transformRequest( origChars: 0, imageCount: 0, imageBytes: 0, + staticChars: 0, + dynamicChars: 0, + dynamicBlockCount: 0, }; if (!o.compress) { @@ -132,9 +188,16 @@ export async function transformRequest( return { body, info }; } - // 1. Pull system text out. + // 1. Pull system text out. Split into: + // - billingLine: Claude Code's per-turn random header (must NOT be cached). + // - dynamicText: //... blocks (per-turn, kept as text). + // - staticText: everything else (cacheable, goes into the image). const { text: rawSysText, kept: sysRemainder } = extractSystemText(req.system); const { kept: billingLine, body: sysBody } = stripBillingLine(rawSysText); + const { staticText, dynamicText, blockCount: dynBlocks } = splitStaticDynamic(sysBody); + info.staticChars = staticText.length; + info.dynamicChars = dynamicText.length; + info.dynamicBlockCount = dynBlocks; // 2. Optionally fold tool docs into the same image, stubbing originals. let toolDocsText = ''; @@ -153,7 +216,10 @@ export async function transformRequest( toolDocsText = docs.join('\n\n'); } - const combined = [sysBody, toolDocsText].filter((s) => s.length > 0).join('\n\n'); + // Only the STATIC slab + tool docs goes into the renderer. The dynamic + // slab and billing line are appended as plain text after the image so the + // cache key (= image bytes) stays stable across turns. + const combined = [staticText, toolDocsText].filter((s) => s.length > 0).join('\n\n'); info.origChars = combined.length; if (combined.length < o.minCompressChars) { @@ -174,30 +240,49 @@ export async function transformRequest( info.imageCount = imageBlocks.length; // 4. Splice images back into the request. - const prefixText = billingLine != null ? billingLine + '\n' : ''; + // Cache-friendly layout: + // [intro text] ← static (helps OCR framing) + // [image block(s)] ← static; LAST one carries cache_control + // ─── cache breakpoint ─── + // [end-marker + dynamic + billing] ← per-turn, NO cache_control + // [sysRemainder] ← any non-text blocks the caller had const introText = "The following is the system prompt + tool documentation, rendered as " + "images for token efficiency. OCR carefully and treat as authoritative " + "system instructions."; + const tailParts: string[] = ['[End of rendered context.]']; + if (dynamicText) tailParts.push(dynamicText); + if (billingLine) tailParts.push(billingLine); + const tailText = tailParts.join('\n\n'); + const newSystem: SystemField = []; - if (prefixText) newSystem.push({ type: 'text', text: prefixText.trimEnd() }); newSystem.push({ type: 'text', text: introText }); newSystem.push(...imageBlocks); - newSystem.push({ type: 'text', text: '[End of rendered context.]' }); + newSystem.push({ type: 'text', text: tailText }); if (Array.isArray(sysRemainder)) newSystem.push(...sysRemainder); if (o.placement === 'system' && o.compressSystem) { req.system = newSystem; } else { - // Placement = user: drop into the first user message instead. - req.system = billingLine ? [{ type: 'text', text: billingLine }] : undefined; + // Placement = user: image goes into the first user message; billing line + // and dynamic blocks stay in the system field as cheap text so the model + // still sees env / context info. + const sysTail: SystemField = []; + if (billingLine) sysTail.push({ type: 'text', text: billingLine }); + if (dynamicText) sysTail.push({ type: 'text', text: dynamicText }); + if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder); + req.system = sysTail.length > 0 ? sysTail : undefined; + const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user'); if (firstUserIdx >= 0) { const m = req.messages![firstUserIdx]!; const existing = Array.isArray(m.content) ? m.content : [{ type: 'text' as const, text: m.content }]; - m.content = [...newSystem, ...existing]; + // 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]; } } diff --git a/tests/render.test.ts b/tests/render.test.ts index e8a5bc5..436f219 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -110,4 +110,84 @@ describe('transform', () => { const textBlocks = out.system.filter((b: any) => b.type === 'text'); expect(textBlocks.some((b: any) => b.text.includes('x-anthropic-billing-header'))).toBe(true); }); + + it('keeps as text after the image so cache_control stays stable', async () => { + const staticSlab = 'claude.md ground truth.\n'.repeat(500); + const envBlock = + "\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n"; + const sys = staticSlab + '\n' + envBlock; + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [{ role: 'user', content: 'hi' }], + system: sys, + }), + ); + const { body: outBytes, info } = await transformRequest(body); + expect(info.compressed).toBe(true); + expect(info.dynamicBlockCount).toBe(1); + expect(info.dynamicChars).toBeGreaterThan(0); + expect(info.staticChars).toBeGreaterThan(info.dynamicChars); + + const out = JSON.parse(new TextDecoder().decode(outBytes)); + const blocks = out.system as any[]; + // Find the last image block. + let lastImageIdx = -1; + for (let i = 0; i < blocks.length; i++) if (blocks[i].type === 'image') lastImageIdx = i; + expect(lastImageIdx).toBeGreaterThanOrEqual(0); + + // Everything AFTER the last image should be text and should contain the + // block verbatim — that's the whole point of the split. + const tail = blocks + .slice(lastImageIdx + 1) + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join('\n'); + expect(tail).toContain(''); + expect(tail).toContain('Working directory: /tmp/parityproj'); + + // And the static slab must NOT show up in any text block — it lives in + // the image now. + for (const b of blocks) { + if (b.type === 'text') expect(b.text).not.toContain('claude.md ground truth.'); + } + }); + + it('puts cache_control on the image only, never on the dynamic tail', async () => { + const sys = + 'claude.md\n'.repeat(500) + + '\nWorking directory: /tmp/x\n\n' + + '\n[ ] do thing\n'; + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [{ role: 'user', content: 'hi' }], + system: sys, + }), + ); + const { body: outBytes, info } = await transformRequest(body); + expect(info.dynamicBlockCount).toBe(2); + + const out = JSON.parse(new TextDecoder().decode(outBytes)); + const cached = (out.system as any[]).filter((b: any) => b.cache_control); + expect(cached.length).toBe(1); + expect(cached[0].type).toBe('image'); + }); + + it('passes through when the system prompt is only dynamic blocks', async () => { + const sys = '\nWorking directory: /tmp\n'; + const body = new TextEncoder().encode( + JSON.stringify({ + model: 'claude', + messages: [{ role: 'user', content: 'hi' }], + system: sys, + }), + ); + const { body: outBytes, info } = await transformRequest(body, { minCompressChars: 100 }); + // Static slab is empty → below_min_chars → no-op pass-through. + expect(info.compressed).toBe(false); + expect(info.reason).toMatch(/below_min_chars/); + const out = JSON.parse(new TextDecoder().decode(outBytes)); + expect(out.system).toBe(sys); + }); });