mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
transform: split static/dynamic system text for cache stability
Claude Code injects per-turn blocks (<env>, <context>, <git_status>,
<directoryStructure>, <system-reminder>) into the system prompt.
Previously those got rendered into the image alongside the static
slab (CLAUDE.md, agent defs, tool docs), which kills Anthropic's
prompt cache: image bytes drift turn-to-turn → 0% cache hits.
Now we split:
- static (cacheable) → image with cache_control: ephemeral
- dynamic (per-turn) → plain text block AFTER the image
- billing line → also AFTER the image (was BEFORE, also a
cache-killer — bug fix as a side effect)
Net effect: stable cache key on the big slab, model still sees cwd /
branch / today's date. Removes the need for users to pass
--exclude-dynamic-system-prompt-sections to claude.
TransformInfo now carries staticChars / dynamicChars / dynamicBlockCount
for downstream telemetry.
Tests: 8 existing pass unchanged + 3 new (env survives as text after
image, cache_control on image only, all-dynamic input is pass-through).
This commit is contained in:
+94
-9
@@ -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 (<env>, <context>, 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. <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;
|
||||
|
||||
function splitStaticDynamic(text: string): {
|
||||
staticText: string;
|
||||
dynamicText: string;
|
||||
blockCount: number;
|
||||
} {
|
||||
if (!text) return { staticText: '', dynamicText: '', blockCount: 0 };
|
||||
// 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);
|
||||
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: <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 } = 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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <env> as text after the image so cache_control stays stable', async () => {
|
||||
const staticSlab = 'claude.md ground truth.\n'.repeat(500);
|
||||
const envBlock =
|
||||
"<env>\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n</env>";
|
||||
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
|
||||
// <env> 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('<env>');
|
||||
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) +
|
||||
'<env>\nWorking directory: /tmp/x\n</env>\n' +
|
||||
'<context name="todoList">\n[ ] do thing\n</context>';
|
||||
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 = '<env>\nWorking directory: /tmp\n</env>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user