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=.';