From 6684e5c6b328091fe5a53a37a2c6655d6f17f326 Mon Sep 17 00:00:00 2001 From: Adam L Date: Sat, 11 Jul 2026 18:06:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20keep-system-text=20mode=20=E2=80=94=20n?= =?UTF-8?q?ever=20image=20session=20config=20(compressSystem=20option)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System-prompt-shaped content rendered inside user-message images trips Anthropic's reasoning_extraction refusal classifier (stop_reason: refusal on cold-start hellos; the same heuristic behind the banner/stub wording notes in transform.ts). Measured on production Claude Code traffic (events.jsonl 2026-07-11): 88/3448 (2.6%) refusals on reminder-imaged requests, 1/219 on other compressed, 0/6533 uncompressed. Wording tweaks lower the classifier score; keeping the config out of images clears it. - New TransformOptions.compressSystem (default true = unchanged). false skips the slab gate/render, the system-field rewrite, the first-message splice/5a, and env relocation; tool_result (5b) and history (6) compression still run, so discovery bulk keeps its savings. - PXPIPE_KEEP_SYSTEM_TEXT=1 env var (or config.json keepSystemText: true) makes the proxy pass compressSystem/compressTools/compressReminders all false — tool docs and reminders are config too and stubbed tools previously retripped the classifier (see 2026-07-02 wording note). - New HistoryCollapseOptions.preserveReminderText (default false = unchanged), set by the transform in keep mode: text blocks in the protected head survive verbatim instead of tombstoning — without slab pages, that head text is the only copy of the session config (CLAUDE.md, memory index), and demoting it drops the operating instructions from context. Verified against captured production traffic: a previously-refused cold-start request forwards with system/tools/reminders as native text and zero images; a long session still images tool_results and collapses history (10 turns -> pages) with the opening reminder intact; default mode output is byte-identical to before. --- src/core/history.ts | 34 +++- src/core/transform.ts | 431 +++++++++++++++++++++++------------------- src/node.ts | 24 ++- tests/history.test.ts | 44 +++++ 4 files changed, 335 insertions(+), 198 deletions(-) diff --git a/src/core/history.ts b/src/core/history.ts index c8c455dc..4b968aec 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -71,6 +71,14 @@ export interface HistoryCollapseOptions { * identical — it just removes the blank-row waste. `collapsedChars` still * reports the ORIGINAL transcript length. Default true. */ reflow: boolean; + /** Keep `` TEXT blocks in the protected head verbatim instead + * of tombstoning them. Set by the transform when the session config was NOT + * imaged (compressSystem=false): those blocks are the live operating + * instructions (CLAUDE.md, memory index) riding as text — demoting them to a + * 300-char preview would drop the harness config from context. Default false + * (imaged-slab sessions carry the config in the rendered pages; head text is + * stale by construction). */ + preserveReminderText: boolean; } export const HISTORY_DEFAULTS: HistoryCollapseOptions = { @@ -81,6 +89,7 @@ export const HISTORY_DEFAULTS: HistoryCollapseOptions = { freezeChunk: 10, protectedPrefix: 0, reflow: true, + preserveReminderText: false, }; /** Per-request telemetry surfaced back to TransformInfo. */ @@ -370,7 +379,10 @@ function typedUserText(content: string | ContentBlock[]): string { * cache_control breakpoint survive; the demotion is a pure function of the message, so * the protected prefix stays byte-stable across turns (one-time re-cache on deploy). */ -function demoteProtectedHeadText(head: Message[]): Message[] { +function demoteProtectedHeadText( + head: Message[], + preserveReminderText = false, +): Message[] { return head.map((m, idx) => { if (m.role !== 'user') return m; const tomb = (preview: string, cc?: CacheControl): TextBlock => { @@ -411,6 +423,21 @@ function demoteProtectedHeadText(head: Message[]): Message[] { out.push(blk); // slab images + fact-sheet + boundary: proxy scaffolding, kept verbatim continue; } + if ( + preserveReminderText && + blk && + typeof blk === 'object' && + (blk as { type?: string }).type === 'text' && + typeof (blk as TextBlock).text === 'string' && + (blk as TextBlock).text.trimStart().startsWith('') + ) { + // Session config (CLAUDE.md, memory index) riding as text — e.g. with + // compressSystem/compressReminders off, or reminders below the size + // gate. Operating instructions, not a stale request: tombstoning them + // would drop the harness config from context. Kept byte-identical. + out.push(blk); + continue; + } if (blk && typeof blk === 'object' && (blk as { type?: string }).type === 'text') { const preview = compactPreview((blk as TextBlock).text); if (preview) { @@ -667,7 +694,10 @@ export async function collapseHistory( }; // Demote stale request text in the protected head so the session's opening turn // can't surface as clean native text ahead of the history image and read as live. - const head = demoteProtectedHeadText(messages.slice(0, protectedPrefix)); + const head = demoteProtectedHeadText( + messages.slice(0, protectedPrefix), + o.preserveReminderText, + ); const tail = messages.slice(collapseLen); info.collapsedTurns = collapseLen - protectedPrefix; info.collapsedChars = text.length; diff --git a/src/core/transform.ts b/src/core/transform.ts index 22aae5a2..82c55568 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -74,6 +74,16 @@ export interface TransformOptions { compressReminders?: boolean; /** Compress large tool_result text content across all user messages. */ compressToolResults?: boolean; + /** Image the static system-prompt + tool-docs slab. `false` keeps the + * session config as native text — no slab render, no system-field + * rewrite, no env relocation — while tool_result and history + * compression still run (pair with compressTools=false and + * compressReminders=false to pin ALL config as text). Guard for + * Anthropic's reasoning_extraction refusal classifier, which fires on + * system-prompt-shaped content rendered inside user-message images + * (2.6% refusal rate on reminder-imaged requests vs 0% uncompressed, + * events.jsonl 2026-07-11). Default true. */ + compressSystem?: boolean; /** Don't compress if total compressible chars below this. */ minCompressChars?: number; /** Per-block threshold for compressReminders (chars). */ @@ -129,6 +139,7 @@ const DEFAULTS: Required = { compressTools: true, compressReminders: true, compressToolResults: true, + compressSystem: true, minCompressChars: 2000, // Below ~6k chars, per-image cost dominates savings (break-even territory). minReminderChars: 6000, @@ -1413,7 +1424,12 @@ async function runHistoryCollapseAndFinalize( const { messages: newMessages, info: histInfo } = await collapseHistory( req.messages, historyProfitable, - { cols: o.cols, protectedPrefix: 0, reflow: o.reflow }, + { + cols: o.cols, + protectedPrefix: 0, + reflow: o.reflow, + preserveReminderText: o.compressSystem === false, + }, ); if (histInfo.collapsedTurns > 0) { req.messages = newMessages; @@ -1652,98 +1668,111 @@ export async function transformRequest( const slabCpt = opts.charsPerToken !== undefined ? o.charsPerToken : SLAB_CHARS_PER_TOKEN; - // Shrink canvas to longest actual line — pure function of (text, cols) so the - // cache prefix stays byte-identical across turns. The banner sets a natural width floor. - const reflowNoteImg = o.reflow - ? ' The glyph ↵ (U+21B5) marks an original hard line break in content — treat as a real newline.' - : ''; - const columnNoteImg = - numCols > 1 - ? ` Multi-column layout (${numCols} cols): read column 1 (leftmost) top-to-bottom, then column 2, etc.` + // compressSystem=false: session config (system prompt, tool docs, reminders) + // stays native text — skip the slab gate/render below and the system-field + // rewrite + first-message splice (sections 4/5a); tool_result (5b) and + // history (6) compression still run. Section 6's protectedPrefix shields + // the opening config-bearing user message, and demoteProtectedHeadText + // keeps its blocks verbatim. Guard for Anthropic's + // reasoning_extraction refusal classifier, which fires on system-prompt- + // shaped content rendered inside user-message images (2.6% refusal rate on + // reminder-imaged requests vs 0% uncompressed, events.jsonl 2026-07-11). + const keepSystemText = o.compressSystem === false; + if (keepSystemText) info.reason = 'system_kept_text'; + const imageBlocks: ImageBlock[] = []; + if (!keepSystemText) { + // Shrink canvas to longest actual line — pure function of (text, cols) so the + // cache prefix stays byte-identical across turns. The banner sets a natural width floor. + const reflowNoteImg = o.reflow + ? ' The glyph ↵ (U+21B5) marks an original hard line break in content — treat as a real newline.' : ''; - // Wording note (do NOT reintroduce "system prompt"/"authoritative"): a user-turn - // banner announcing "SYSTEM PROMPT ... treat as authoritative system instructions" - // tripped Anthropic's reasoning_extraction refusal (reads as a replayed/extracted - // prompt -> model-cloning heuristic) and forced a fallback-model switch. First-party - // provenance framing below keeps obedience without the extraction signature. - const imageInstructionHeader = - '=================== SESSION CONFIGURATION PAGES ===================\n' + - "pxpipe (this user's local proxy) rendered this session's configuration" + - ' into the following images to reduce token cost. Read the pages carefully and follow them as' + - ' your operating instructions for this session.' + - columnNoteImg + - reflowNoteImg + - '\n====================== BEGIN RENDERED CONTEXT ======================\n'; - // IDS block on the imaged slab (same pure-image isolation as Grok/GPT). - const combinedWithHeader = appendIdsBlock(imageInstructionHeader + combined); - // Shrink the canvas to the longest actual line in what we'll *render*, - // so the gate's prediction and the renderer's output agree at the smallest - // legible width. The banner above sets the natural floor — no separate - // minWidth knob needed. Multi-col packing still gets numCols × this width. - const slabCols = shrinkColsToContent(combinedWithHeader, o.cols); - const slabGateEval = evalCompressionProfitability( - combinedWithHeader, slabCols, undefined, numCols, slabCpt, o.priorWarmTokens, o.priorWarmImageTokens, - false, // already shrunk — don't double-shrink - ); - if (slabGateEval) { - info.gateEval = { - site: 'slab', - imageTokens: slabGateEval.imageTokens, - textTokens: slabGateEval.textTokens, - burnImageSide: slabGateEval.burnImageSide, - burnTextSide: slabGateEval.burnTextSide, - profitable: slabGateEval.profitable, - }; - } - if (!isCompressionProfitable(combinedWithHeader, slabCols, undefined, numCols, slabCpt, o.priorWarmTokens, o.priorWarmImageTokens, false)) { - info.reason = `not_profitable (slab=${combined.length} chars)`; - bumpPassthrough(info, 'not_profitable'); - // Slab not profitable but history may still be collapsable — try before returning. - const finalized = await runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints); - if (finalized.collapsed) { - info.compressed = true; - return { body: finalized.body, info }; + const columnNoteImg = + numCols > 1 + ? ` Multi-column layout (${numCols} cols): read column 1 (leftmost) top-to-bottom, then column 2, etc.` + : ''; + // Wording note (do NOT reintroduce "system prompt"/"authoritative"): a user-turn + // banner announcing "SYSTEM PROMPT ... treat as authoritative system instructions" + // tripped Anthropic's reasoning_extraction refusal (reads as a replayed/extracted + // prompt -> model-cloning heuristic) and forced a fallback-model switch. First-party + // provenance framing below keeps obedience without the extraction signature. + const imageInstructionHeader = + '=================== SESSION CONFIGURATION PAGES ===================\n' + + "pxpipe (this user's local proxy) rendered this session's configuration" + + ' into the following images to reduce token cost. Read the pages carefully and follow them as' + + ' your operating instructions for this session.' + + columnNoteImg + + reflowNoteImg + + '\n====================== BEGIN RENDERED CONTEXT ======================\n'; + // IDS block on the imaged slab (same pure-image isolation as Grok/GPT). + const combinedWithHeader = appendIdsBlock(imageInstructionHeader + combined); + // Shrink the canvas to the longest actual line in what we'll *render*, + // so the gate's prediction and the renderer's output agree at the smallest + // legible width. The banner above sets the natural floor — no separate + // minWidth knob needed. Multi-col packing still gets numCols × this width. + const slabCols = shrinkColsToContent(combinedWithHeader, o.cols); + const slabGateEval = evalCompressionProfitability( + combinedWithHeader, slabCols, undefined, numCols, slabCpt, o.priorWarmTokens, o.priorWarmImageTokens, + false, // already shrunk — don't double-shrink + ); + if (slabGateEval) { + info.gateEval = { + site: 'slab', + imageTokens: slabGateEval.imageTokens, + textTokens: slabGateEval.textTokens, + burnImageSide: slabGateEval.burnImageSide, + burnTextSide: slabGateEval.burnTextSide, + profitable: slabGateEval.profitable, + }; + } + if (!isCompressionProfitable(combinedWithHeader, slabCols, undefined, numCols, slabCpt, o.priorWarmTokens, o.priorWarmImageTokens, false)) { + info.reason = `not_profitable (slab=${combined.length} chars)`; + bumpPassthrough(info, 'not_profitable'); + // Slab not profitable but history may still be collapsable — try before returning. + const finalized = await runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints); + if (finalized.collapsed) { + info.compressed = true; + return { body: finalized.body, info }; + } + return { body, info }; } - return { body, info }; - } - // Instruction header co-renders into the same PNG (+1.04pp L1 OCR vs baseline; - // single-modal framing keeps encoder in image-reading mode for both header + content). - // Header text is continuous prose (no hard \n) so the renderer soft-wraps densely. - // 3. Render to PNGs at slabCols width (banner sets natural floor). - const images = - numCols > 1 - ? await renderTextToPngsMultiCol(combinedWithHeader, slabCols, numCols) - : await renderTextToPngs(combinedWithHeader, slabCols); - 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); + // Instruction header co-renders into the same PNG (+1.04pp L1 OCR vs baseline; + // single-modal framing keeps encoder in image-reading mode for both header + content). + // Header text is continuous prose (no hard \n) so the renderer soft-wraps densely. + // 3. Render to PNGs at slabCols width (banner sets natural floor). + const images = + numCols > 1 + ? await renderTextToPngsMultiCol(combinedWithHeader, slabCols, numCols) + : await renderTextToPngs(combinedWithHeader, slabCols); + 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); + } + const imageBlock = makeImageBlock(b64, i === images.length - 1); + imageBlocks.push( + i === images.length - 1 && systemStaticCacheControl !== undefined + ? { ...imageBlock, cache_control: systemStaticCacheControl } + : imageBlock, + ); } - const imageBlock = makeImageBlock(b64, i === images.length - 1); - imageBlocks.push( - i === images.length - 1 && systemStaticCacheControl !== undefined - ? { ...imageBlock, cache_control: systemStaticCacheControl } - : imageBlock, - ); - } - info.imageCount = imageBlocks.length; - // Credit raw (pre-compaction) length — what Anthropic would have billed. - info.compressedChars += combinedRaw.length; - bumpBucket(info, 'static_slab', combinedRaw.length); - if (images.length > 0) { - info.firstImagePng = images[0]!.png; - info.firstImageWidth = images[0]!.width; - info.firstImageHeight = images[0]!.height; - (info.imagePngs ??= []).push(...images.map((i) => i.png)); - (info.imageDims ??= []).push(...images.map((i) => ({ width: i.width, height: i.height }))); - info.imageSourceText = combinedWithHeader.slice(0, 65_536); - } + info.imageCount = imageBlocks.length; + // Credit raw (pre-compaction) length — what Anthropic would have billed. + info.compressedChars += combinedRaw.length; + bumpBucket(info, 'static_slab', combinedRaw.length); + if (images.length > 0) { + info.firstImagePng = images[0]!.png; + info.firstImageWidth = images[0]!.width; + info.firstImageHeight = images[0]!.height; + (info.imagePngs ??= []).push(...images.map((i) => i.png)); + (info.imageDims ??= []).push(...images.map((i) => ({ width: i.width, height: i.height }))); + info.imageSourceText = combinedWithHeader.slice(0, 65_536); + } + } // keepSystemText: slab stays text // 4. Splice images back into the request. OCR framing is baked into the image. // @@ -1760,116 +1789,120 @@ export async function transformRequest( const volatileEnvParts: string[] = []; if (dynamicText) volatileEnvParts.push(dynamicText); if (envMarkdown) volatileEnvParts.push(envMarkdown); - const volatileEnvText = hasUserMsg ? volatileEnvParts.join('\n\n') : ''; + // keepSystemText: env markdown never left req.system, so nothing to relocate. + const volatileEnvText = + hasUserMsg && !keepSystemText ? volatileEnvParts.join('\n\n') : ''; // Images go into first user message — system field rejects images (400 system.N.type). { - const sysTail: SystemField = []; - // billingLine is session-stable (warm reads through the anchored prefix - // confirm it; a per-turn value here would zero every cache read). - if (billingLine) sysTail.push({ type: 'text', text: billingLine }); - if (!hasUserMsg) { - if (dynamicText) sysTail.push({ type: 'text', text: dynamicText }); - if (envMarkdown) sysTail.push({ type: 'text', text: envMarkdown }); - } - if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder); - // Tool Reference now rides INSIDE the imaged slab (combinedRaw above) — no - // text splice here. Stubbed tools[] descriptions cite the "## Tool: " - // headings inside the image; stub ↔ reference invariant holds because both - // are applied on this same path (gate-fail paths return earlier with - // original tools untouched). - 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. Compress text blocks. cache_control on source text - // moves to the LAST produced image (pxpipe never adds its own markers). - 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(''); - if (!isReminderText) { - processedExisting.push(blk); - continue; - } - // Caller fidelity override: pin this block as text, skip imaging. - if (callerKeepsSharp(o.keepSharp, { kind: 'reminder', text: (blk as TextBlock).text })) { - bumpPassthrough(info, 'kept_sharp'); - info.keptSharpBlocks = (info.keptSharpBlocks ?? 0) + 1; - 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 = maybeReflow(compactSlabWhitespace(reminderRaw), o.reflow); - if (!isCompressionProfitable(reminderText, denseGeo.cols, undefined, numCols, o.charsPerToken, 0, 0, true, denseGeo.maxChars)) { - bumpPassthrough(info, 'not_profitable'); - processedExisting.push(blk); - continue; - } - const { blocks: imgs, pngs: rawPngs, dims: rawDims, droppedChars, droppedCodepoints: dcp, pixels } = - await textToImageBlocks(reminderText, o.cols, numCols); - (info.imagePngs ??= []).push(...rawPngs); - (info.imageDims ??= []).push(...rawDims); - const srcCacheControl = (blk as { cache_control?: unknown }).cache_control; - for (let i = 0; i < imgs.length; i++) { - const img = imgs[i]!; - const out = - i === imgs.length - 1 && srcCacheControl !== undefined - ? { ...img, cache_control: srcCacheControl } - : img; - processedExisting.push(out as ImageBlock); - info.imageBytes += approxBlockBytes(img); - } - const reminderFactSheet = factSheetText(reminderRaw); - if (reminderFactSheet) processedExisting.push({ type: 'text', text: reminderFactSheet }); - info.imagePixels = (info.imagePixels ?? 0) + pixels; - info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length; - await recordRecoverable(info, o.emitRecoverable, { - kind: 'reminder', - text: reminderRaw, - imageCount: imgs.length, - }); - info.compressedChars += reminderRaw.length; - bumpBucket(info, 'reminder', 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); + if (!keepSystemText) { + const sysTail: SystemField = []; + // billingLine is session-stable (warm reads through the anchored prefix + // confirm it; a per-turn value here would zero every cache read). + if (billingLine) sysTail.push({ type: 'text', text: billingLine }); + if (!hasUserMsg) { + if (dynamicText) sysTail.push({ type: 'text', text: dynamicText }); + if (envMarkdown) sysTail.push({ type: 'text', text: envMarkdown }); + } + if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder); + // Tool Reference now rides INSIDE the imaged slab (combinedRaw above) — no + // text splice here. Stubbed tools[] descriptions cite the "## Tool: " + // headings inside the image; stub ↔ reference invariant holds because both + // are applied on this same path (gate-fail paths return earlier with + // original tools untouched). + 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. Compress text blocks. cache_control on source text + // moves to the LAST produced image (pxpipe never adds its own markers). + 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(''); + if (!isReminderText) { + processedExisting.push(blk); + continue; + } + // Caller fidelity override: pin this block as text, skip imaging. + if (callerKeepsSharp(o.keepSharp, { kind: 'reminder', text: (blk as TextBlock).text })) { + bumpPassthrough(info, 'kept_sharp'); + info.keptSharpBlocks = (info.keptSharpBlocks ?? 0) + 1; + 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 = maybeReflow(compactSlabWhitespace(reminderRaw), o.reflow); + if (!isCompressionProfitable(reminderText, denseGeo.cols, undefined, numCols, o.charsPerToken, 0, 0, true, denseGeo.maxChars)) { + bumpPassthrough(info, 'not_profitable'); + processedExisting.push(blk); + continue; + } + const { blocks: imgs, pngs: rawPngs, dims: rawDims, droppedChars, droppedCodepoints: dcp, pixels } = + await textToImageBlocks(reminderText, o.cols, numCols); + (info.imagePngs ??= []).push(...rawPngs); + (info.imageDims ??= []).push(...rawDims); + const srcCacheControl = (blk as { cache_control?: unknown }).cache_control; + for (let i = 0; i < imgs.length; i++) { + const img = imgs[i]!; + const out = + i === imgs.length - 1 && srcCacheControl !== undefined + ? { ...img, cache_control: srcCacheControl } + : img; + processedExisting.push(out as ImageBlock); + info.imageBytes += approxBlockBytes(img); + } + const reminderFactSheet = factSheetText(reminderRaw); + if (reminderFactSheet) processedExisting.push({ type: 'text', text: reminderFactSheet }); + info.imagePixels = (info.imagePixels ?? 0) + pixels; + info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length; + await recordRecoverable(info, o.emitRecoverable, { + kind: 'reminder', + text: reminderRaw, + imageCount: imgs.length, + }); + info.compressedChars += reminderRaw.length; + bumpBucket(info, 'reminder', 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); } - } else { - processedExisting.push(...existing); - } - const slabFactSheet = factSheetText(combinedRaw); - m.content = [ - ...imageBlocks, - ...(slabFactSheet ? [{ type: 'text' as const, text: slabFactSheet }] : []), - { type: 'text' as const, text: '[End of rendered context.]' }, - ...processedExisting, - ]; - } + const slabFactSheet = factSheetText(combinedRaw); + m.content = [ + ...imageBlocks, + ...(slabFactSheet ? [{ type: 'text' as const, text: slabFactSheet }] : []), + { type: 'text' as const, text: '[End of rendered context.]' }, + ...processedExisting, + ]; + } + } // keepSystemText: system field, first-message layout, and reminders untouched // 5b. Compress tool_result content across ALL user messages. if (o.compressToolResults) { @@ -2050,7 +2083,12 @@ export async function transformRequest( const { messages: newMessages, info: histInfo } = await collapseHistory( req.messages, historyProfitable, - { cols: o.cols, protectedPrefix: slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, reflow: o.reflow }, + { + cols: o.cols, + protectedPrefix: slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, + reflow: o.reflow, + preserveReminderText: keepSystemText, + }, ); if (histInfo.collapsedTurns > 0) { req.messages = newMessages; @@ -2123,7 +2161,10 @@ export async function transformRequest( } } - info.compressed = true; + // keepSystemText mode can reach here having imaged nothing (small session, + // no big tool_results, no collapsable history) — that's a passthrough, not + // a compression; the default path always imaged the slab to get this far. + info.compressed = keepSystemText ? info.imageCount > 0 : true; // Attribution signal for prompt-cache busts (#11): digest the exact pinned // prefix we send (history/slab boundary; live tail excluded) AFTER all marker // placement — incl. relocateAnchorToHistoryImage — is final. Read-only. diff --git a/src/node.ts b/src/node.ts index 5659dbfc..e2736d48 100644 --- a/src/node.ts +++ b/src/node.ts @@ -81,6 +81,15 @@ function applyConfigFileDefaults(): void { const models = normalizeModelsConfig(cfg.models); if (models !== undefined) process.env.PXPIPE_MODELS = models; } + if ( + process.env.PXPIPE_KEEP_SYSTEM_TEXT === undefined && + typeof (cfg as { keepSystemText?: unknown }).keepSystemText === 'boolean' + ) { + process.env.PXPIPE_KEEP_SYSTEM_TEXT = (cfg as { keepSystemText: boolean }) + .keepSystemText + ? '1' + : '0'; + } } function parseCli(argv: string[]): RuntimeConfig { @@ -164,7 +173,11 @@ Environment: default claude-fable-5 (Sol/Opus/GPT-5.5/Grok opt-in); off disables PXPIPE_CONFIG JSON config path (default ~/.config/pxpipe/config.json) - supports {"models": [...]} or {"models": "off"} + supports {"models": [...]}, {"models": "off"}, + and {"keepSystemText": true} + PXPIPE_KEEP_SYSTEM_TEXT 1 = never image session config (system prompt, tool + docs, init blocks stay text); + tool_results and collapsed history still image PXPIPE_LOG JSONL events path (default ~/.pxpipe/events.jsonl) PXPIPE_DUMP_DIR debug: write every rendered PNG here (what the model sees); off unless set. Compress arm only. @@ -945,6 +958,15 @@ async function main(): Promise { // still logging real usage + count_tokens baselines to its own PXPIPE_LOG. // (The dashboard kill switch does the same thing at runtime.) if (forcePassthrough || !dashboard.getCompressionEnabled()) return { compress: false }; + // PXPIPE_KEEP_SYSTEM_TEXT: session config (system prompt, tool docs, + // init blocks) stays native text; only tool_results + // and collapsed history image. Guards against Anthropic's + // reasoning_extraction refusal classifier, which fires on system- + // prompt-shaped content rendered inside user-message images (2.6% of + // reminder-imaged requests vs 0% uncompressed, events.jsonl 2026-07-11). + if (/^(1|true|on|yes)$/i.test(process.env.PXPIPE_KEEP_SYSTEM_TEXT ?? '')) { + return { compressSystem: false, compressTools: false, compressReminders: false }; + } // Active path: use DEFAULTS in transform.ts for break-even gating. return {}; }, diff --git a/tests/history.test.ts b/tests/history.test.ts index 4d33a259..d2534b1f 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -1104,6 +1104,50 @@ describe('collapseHistory — opening task carried verbatim from the demoted hea expect(pointer!.text).not.toContain('claudeMd noise'); }); + it('preserveReminderText keeps head blocks verbatim, still tombstones the typed task', async () => { + // compressSystem=false sessions carry the operating instructions (CLAUDE.md, + // memory index) as TEXT reminders in the opening message — no rendered pages + // hold a copy, so demoting them to a preview would drop the session config. + const msgs: Message[] = [ + usr([ + { type: 'text', text: 'claudeMd config — must survive' }, + { type: 'text', text: TASK }, + ]), + ]; + for (let i = 1; i <= 12; i++) { + msgs.push( + i % 2 === 1 + ? asst(`turn ${i}: ` + 'x'.repeat(2800)) + : usr([{ type: 'text', text: `nudge ${i} ` + 'x'.repeat(2800) + '' }]), + ); + } + msgs.push(usr('LIVE: you have read every file — answer now.')); + + const { messages: out, info } = await collapseHistory(msgs, isCompressionProfitable, { + keepTail: 1, + minCollapsePrefix: 5, + cols: 100, + collapseChunk: 0, + protectedPrefix: 1, + preserveReminderText: true, + }); + + expect(info.reason).toBe(undefined); + const headText = (out[0]!.content as Array>).filter( + (c) => c.type === 'text', + ) as Array<{ text: string }>; + // Reminder block survives byte-identical; the stale typed task still demotes. + expect(headText[0]!.text).toBe('claudeMd config — must survive'); + expect(headText[1]!.text).toContain('PRIOR CONTEXT ONLY'); + // The verbatim pointer still carries the task (unchanged from the default path). + const synthText = (out[1]!.content as Array>).filter( + (c) => c.type === 'text', + ) as Array<{ text: string }>; + const pointer = synthText.find((t) => t.text.includes('Most recent collapsed user turn')); + expect(pointer).toBeDefined(); + expect(pointer!.text).toContain('Reply as: balance=, count=, final=.'); + }); + it('elides the middle, never the tail, when the typed task exceeds the verbatim cap', async () => { const longTask = 'SETUP: ' + 'a'.repeat(6000) + ' Reply as: balance=, count=, final=.';