@xamukavila/pxpipe 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/cli.js +7 -0
  4. package/dist/core/applicability.d.ts +31 -0
  5. package/dist/core/applicability.js +96 -0
  6. package/dist/core/atlas-gray.d.ts +26 -0
  7. package/dist/core/atlas-gray.js +64 -0
  8. package/dist/core/atlas.d.ts +34 -0
  9. package/dist/core/atlas.js +71 -0
  10. package/dist/core/baseline.d.ts +80 -0
  11. package/dist/core/baseline.js +101 -0
  12. package/dist/core/caveman.d.ts +38 -0
  13. package/dist/core/caveman.js +183 -0
  14. package/dist/core/export.d.ts +128 -0
  15. package/dist/core/export.js +390 -0
  16. package/dist/core/factsheet.d.ts +78 -0
  17. package/dist/core/factsheet.js +216 -0
  18. package/dist/core/gpt-model-profiles.d.ts +60 -0
  19. package/dist/core/gpt-model-profiles.js +161 -0
  20. package/dist/core/history.d.ts +141 -0
  21. package/dist/core/history.js +553 -0
  22. package/dist/core/index.d.ts +8 -0
  23. package/dist/core/index.js +8 -0
  24. package/dist/core/library.d.ts +74 -0
  25. package/dist/core/library.js +133 -0
  26. package/dist/core/measurement.d.ts +22 -0
  27. package/dist/core/measurement.js +213 -0
  28. package/dist/core/openai-history.d.ts +124 -0
  29. package/dist/core/openai-history.js +494 -0
  30. package/dist/core/openai-savings.d.ts +44 -0
  31. package/dist/core/openai-savings.js +75 -0
  32. package/dist/core/openai.d.ts +24 -0
  33. package/dist/core/openai.js +839 -0
  34. package/dist/core/png.d.ts +11 -0
  35. package/dist/core/png.js +132 -0
  36. package/dist/core/proxy.d.ts +81 -0
  37. package/dist/core/proxy.js +730 -0
  38. package/dist/core/render.d.ts +188 -0
  39. package/dist/core/render.js +785 -0
  40. package/dist/core/schema-strip.d.ts +29 -0
  41. package/dist/core/schema-strip.js +160 -0
  42. package/dist/core/tracker.d.ts +154 -0
  43. package/dist/core/tracker.js +216 -0
  44. package/dist/core/transform.d.ts +362 -0
  45. package/dist/core/transform.js +1828 -0
  46. package/dist/core/types.d.ts +77 -0
  47. package/dist/core/types.js +8 -0
  48. package/dist/dashboard/fragments.d.ts +36 -0
  49. package/dist/dashboard/fragments.js +938 -0
  50. package/dist/dashboard/types.d.ts +154 -0
  51. package/dist/dashboard/types.js +3 -0
  52. package/dist/dashboard/vendor.d.ts +3 -0
  53. package/dist/dashboard/vendor.js +6 -0
  54. package/dist/dashboard.d.ts +245 -0
  55. package/dist/dashboard.js +1140 -0
  56. package/dist/export-collect.d.ts +36 -0
  57. package/dist/export-collect.js +59 -0
  58. package/dist/node.d.ts +9 -0
  59. package/dist/node.js +9038 -0
  60. package/dist/sessions.d.ts +172 -0
  61. package/dist/sessions.js +510 -0
  62. package/dist/stats.d.ts +74 -0
  63. package/dist/stats.js +248 -0
  64. package/dist/worker.d.ts +53 -0
  65. package/dist/worker.js +102 -0
  66. package/package.json +96 -0
@@ -0,0 +1,1828 @@
1
+ /**
2
+ * Request-body transformer. Extracts the static system prompt + tool definitions,
3
+ * renders them as PNG image blocks, and rewrites the body to reference those images —
4
+ * saving 65-73% input tokens while preserving reasoning quality.
5
+ */
6
+ import { renderTextToPngs, renderTextToPngsMultiCol, reflow, maxFittingCols, shrinkColsToContent, MAX_HEIGHT_PX, NL_SENTINEL, neutralizeSentinel, PAD_X, PAD_Y, CELL_W, CELL_H, READABLE_CHARS_PER_IMAGE, DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_CONTENT_COLS, DENSE_RENDER_STYLE, renderTextToPngsWithCharLimit, } from './render.js';
7
+ import { factSheetText } from './factsheet.js';
8
+ import { cavemanize } from './caveman.js';
9
+ import { stripSchemaDescriptions, schemaHasStructure } from './schema-strip.js';
10
+ import { bytesToBase64 } from './png.js';
11
+ import { collapseHistory, HISTORY_SYNTHETIC_INTRO } from './history.js';
12
+ import { CACHE_CREATE_RATE, CACHE_READ_RATE } from './baseline.js';
13
+ const DEFAULTS = {
14
+ compress: true,
15
+ compressTools: true,
16
+ compressReminders: true,
17
+ compressToolResults: true,
18
+ minCompressChars: 2000,
19
+ // Below ~6k chars, per-image cost dominates savings (break-even territory).
20
+ minReminderChars: 6000,
21
+ minToolResultChars: 6000,
22
+ // system field rejects images (400 system.N.type: Input should be 'text') —
23
+ // images always go into the first user message.
24
+ // 313 cols × 5 px + 8 px pad = 1573 px slab width (under 2000 px ceiling).
25
+ cols: 313,
26
+ maxImagesPerToolResult: 10,
27
+ charsPerToken: 4,
28
+ historyAmortizationHorizon: 1,
29
+ priorWarmTokens: 0,
30
+ priorWarmImageTokens: 0,
31
+ // Multi-col off: single-col slab already holds ~50k chars; extra OCR risk not worth it.
32
+ multiCol: 1,
33
+ reflow: true,
34
+ keepSharp: () => false,
35
+ emitRecoverable: false,
36
+ // Experiment; changes image bytes (= prompt-cache key), so flipping it
37
+ // mid-session busts the warm image cache. Keep off outside the harness.
38
+ caveman: false,
39
+ // GPT-only knobs; the Anthropic transform ignores them but Required<> needs them.
40
+ collapseHistory: true,
41
+ gptHistory: {},
42
+ };
43
+ // --- per-block break-even check ---
44
+ //
45
+ // Image token cost is computed from pixel area (Anthropic formula: w×h/750,
46
+ // empirically accurate to ~5% on dense PNGs). Constants bias CONSERVATIVE:
47
+ // CHARS_PER_TOKEN=4 under-estimates text savings; multi-col cost is linearly
48
+ // scaled from single-col + 10% margin. Mispredictions leave money on the
49
+ // table; they never generate net-loss images.
50
+ /** English ~4 chars per token average (conservative for code/JSON content). */
51
+ const CHARS_PER_TOKEN = 4;
52
+ /** Empirical cpt for the system-slab path (Opus 4.7 tokenizer, N=391, observed 1.91).
53
+ * Slab-specific because reminders/tool_results have unknown shape; those stay at 4. */
54
+ export const SLAB_CHARS_PER_TOKEN = 2.0;
55
+ // Tools whose stub description keeps a live-text read-before-edit precondition
56
+ // when full docs move into the imaged Tool Reference (read-gate audit, 2026-07-03).
57
+ const READ_FIRST_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
58
+ /** Empirical cpt for the history-collapse path (same Opus 4.7 telemetry as SLAB_CHARS_PER_TOKEN).
59
+ * History is even denser (tool_use JSON dominates), so 2.0 is doubly conservative. */
60
+ export const HISTORY_CHARS_PER_TOKEN = 2.0;
61
+ /** Chars-per-token for the `pxpipe export` *reporting* estimate (factsheet & savings %).
62
+ * Less conservative than the gate's CHARS_PER_TOKEN=4: reporting wants an accurate
63
+ * figure (~3.7 for source/prose text), not a safe-side under-estimate. Single source
64
+ * of truth — src/core/export.ts imports this rather than redefining it. */
65
+ export const REPORT_CHARS_PER_TOKEN = 3.7;
66
+ /** Anthropic image-billing formula: `tokens ≈ width × height / 750`.
67
+ * https://docs.anthropic.com/en/docs/build-with-claude/vision#image-tokens
68
+ * Accurate to ~5% on dense glyph PNGs (N=14 empirical calibration). The renderer
69
+ * sizes height to content, so per-block images cost far less than full-canvas.
70
+ * Exported so the export pipeline can reuse the same constant rather than hardcoding. */
71
+ export const ANTHROPIC_PIXELS_PER_TOKEN = 750;
72
+ /** Conservative 10% upward bias on Anthropic image token estimates — keeps the gate
73
+ * on the safe (pass-through) side when the true cost is near the break-even point.
74
+ * Exported so the export pipeline reuses the same value. */
75
+ export const IMAGE_COST_SAFETY_MARGIN = 1.10;
76
+ /** Width in px of a single-col PNG. Must stay in sync with `renderChunkToPng` (render.ts). */
77
+ function singleColWidthPx(cols) {
78
+ return 2 * PAD_X + cols * CELL_W;
79
+ }
80
+ /** Width in px of a multi-col PNG. Mirrors `multiColWidth()` in render.ts. */
81
+ function multiColWidthPx(cols, numCols) {
82
+ const n = Math.max(1, numCols | 0);
83
+ if (n === 1)
84
+ return singleColWidthPx(cols);
85
+ const GUTTER_CELLS = 4; // must match render.ts (not exported)
86
+ return 2 * PAD_X + n * cols * CELL_W + (n - 1) * GUTTER_CELLS * CELL_W;
87
+ }
88
+ /** Exact image-token cost for `visualRows` at given column/multi-col geometry.
89
+ * Mirrors the renderer's height math so the gate matches Anthropic billing.
90
+ * Last image is partial-height; each image cost ∝ pixel area. */
91
+ function imageTokensForRows(visualRows, cols, numCols = 1, imageCountCap, maxCharsPerImage = READABLE_CHARS_PER_IMAGE) {
92
+ if (!Number.isFinite(visualRows) || visualRows <= 0)
93
+ return 0;
94
+ const n = Math.max(1, numCols | 0);
95
+ const widthPx = multiColWidthPx(cols, n);
96
+ const hardLinesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
97
+ const readableLinesPerCol = Math.max(1, Math.floor(maxCharsPerImage / Math.max(1, cols)));
98
+ const linesPerImg = Math.min(hardLinesPerImg, readableLinesPerCol);
99
+ const rowsPerImage = linesPerImg; // pixel rows per image (height)
100
+ const linesPerImage = linesPerImg * n; // wrapped-text lines per image (n cols side-by-side)
101
+ let imagesNeeded = Math.ceil(visualRows / linesPerImage);
102
+ if (imageCountCap !== undefined && imageCountCap > 0) {
103
+ imagesNeeded = Math.min(imagesNeeded, imageCountCap);
104
+ }
105
+ const fullImages = Math.max(0, imagesNeeded - 1);
106
+ const linesInLast = visualRows - fullImages * linesPerImage;
107
+ // Column-major layout: pixel rows = min(linesInLast, rowsPerImage).
108
+ const rowsInLast = Math.min(Math.max(1, linesInLast), rowsPerImage);
109
+ const fullImageHeight = 2 * PAD_Y + rowsPerImage * CELL_H;
110
+ const lastImageHeight = 2 * PAD_Y + rowsInLast * CELL_H;
111
+ const totalPixels = fullImages * widthPx * fullImageHeight + widthPx * lastImageHeight;
112
+ return Math.ceil((totalPixels / ANTHROPIC_PIXELS_PER_TOKEN) * IMAGE_COST_SAFETY_MARGIN);
113
+ }
114
+ /** Exact image-token cost for `text`. Uses `countVisualRows` and optionally
115
+ * `shrinkColsToContent` (default true) so narrow blocks aren't priced at full
116
+ * canvas width. Pass `shrinkWidth=false` for the system slab (fills full `cols`). */
117
+ function imageTokensCost(text, cols, numCols = 1, imageCountCap, shrinkWidth = true, maxCharsPerImage = READABLE_CHARS_PER_IMAGE) {
118
+ const effectiveCols = shrinkWidth ? shrinkColsToContent(text, cols) : cols;
119
+ const rows = countVisualRows(text, effectiveCols);
120
+ return imageTokensForRows(rows, effectiveCols, numCols, imageCountCap, maxCharsPerImage);
121
+ }
122
+ /** Gate geometry for the single-col dense path (tool_result, reminder, history).
123
+ * Dense single-col uses DENSE_CONTENT_COLS/DENSE_CONTENT_CHARS_PER_IMAGE;
124
+ * multi-col uses configured `cols` at READABLE budget. Slab uses its own path. */
125
+ function denseGateGeometry(cols, numCols) {
126
+ return Math.max(1, numCols | 0) > 1
127
+ ? { cols, maxChars: READABLE_CHARS_PER_IMAGE }
128
+ : { cols: DENSE_CONTENT_COLS, maxChars: DENSE_CONTENT_CHARS_PER_IMAGE };
129
+ }
130
+ /** Visual rows per image: `floor((MAX_HEIGHT_PX − 2·PAD_Y) / CELL_H)`. Derived
131
+ * from render.ts constants so break-even math auto-tracks cell geometry changes. */
132
+ export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
133
+ export function maxCharsPerImage(cols) {
134
+ return Math.min(cols * LINES_PER_IMAGE, READABLE_CHARS_PER_IMAGE);
135
+ }
136
+ /** Lossless pre-render whitespace compactor (each `\n` costs ≥1 visual row):
137
+ * 1. Strip trailing whitespace per line (preserves leading indent).
138
+ * 2. Collapse 3+ consecutive newlines to 2. Typically saves 10-25% rows on
139
+ * markdown/tool-doc slabs, enough to flip borderline gates to profitable. */
140
+ export function compactSlabWhitespace(text) {
141
+ if (!text)
142
+ return text;
143
+ // Single-pass trailing whitespace strip (avoids materializing a split array on ~160 KB slabs).
144
+ let trimmed = '';
145
+ let lineStart = 0;
146
+ for (let i = 0; i <= text.length; i++) {
147
+ if (i === text.length || text.charCodeAt(i) === 10 /* \n */) {
148
+ let end = i;
149
+ while (end > lineStart) {
150
+ const c = text.charCodeAt(end - 1);
151
+ if (c !== 32 && c !== 9)
152
+ break;
153
+ end--;
154
+ }
155
+ trimmed += text.slice(lineStart, end);
156
+ if (i < text.length)
157
+ trimmed += '\n';
158
+ lineStart = i + 1;
159
+ }
160
+ }
161
+ // Collapse 3+ newlines → 2 (kills multi-blank dividers; each costs a render row).
162
+ return trimmed.replace(/\n{3,}/g, '\n\n');
163
+ }
164
+ /** Apply R3 reflow when enabled. Run after `compactSlabWhitespace`, before
165
+ * the gate (gate/renderer/paging all see the same dense text). Falls back to
166
+ * input unchanged on sentinel collision. */
167
+ function maybeReflow(text, enabled) {
168
+ if (!enabled)
169
+ return text;
170
+ // Neutralize any pre-existing ↵ so reflow packs newlines instead of bailing to a raw,
171
+ // unpacked render (the tool_result "newlines not converted to ↵" case — common when the
172
+ // content is about pxpipe itself). Render-only; originals are preserved via
173
+ // recordRecoverable(innerRaw), so this substitution never reaches recovery.
174
+ const safe = neutralizeSentinel(text);
175
+ return reflow(safe) ?? safe;
176
+ }
177
+ /** Decompose the break-even gate into components for telemetry. Returns the
178
+ * imageTokens, textTokens, and symmetric burn terms the gate uses internally,
179
+ * or `null` for empty/non-finite input. */
180
+ export function evalCompressionProfitability(text, cols, imageCountCap = undefined, numCols = 1, charsPerToken = CHARS_PER_TOKEN, priorWarmTokens = 0, priorWarmImageTokens = 0, shrinkWidth = true) {
181
+ const n = Math.max(1, numCols | 0);
182
+ if (typeof text !== 'string' || text.length === 0)
183
+ return null;
184
+ const cpt = Number.isFinite(charsPerToken) && charsPerToken > 0
185
+ ? charsPerToken
186
+ : CHARS_PER_TOKEN;
187
+ const imageTokens = imageTokensCost(text, cols, n, imageCountCap, shrinkWidth);
188
+ const textTokens = text.length / cpt;
189
+ const burnImageSide = Number.isFinite(priorWarmTokens) && priorWarmTokens > 0
190
+ ? priorWarmTokens * (CACHE_CREATE_RATE - CACHE_READ_RATE)
191
+ : 0;
192
+ const burnTextSide = Number.isFinite(priorWarmImageTokens) && priorWarmImageTokens > 0
193
+ ? priorWarmImageTokens * (CACHE_CREATE_RATE - CACHE_READ_RATE)
194
+ : 0;
195
+ return {
196
+ imageTokens,
197
+ textTokens,
198
+ burnImageSide,
199
+ burnTextSide,
200
+ profitable: imageTokens + burnImageSide < textTokens + burnTextSide,
201
+ };
202
+ }
203
+ export function isCompressionProfitable(text, cols = DEFAULTS.cols, imageCountCap, numCols = 1, charsPerToken = CHARS_PER_TOKEN, priorWarmTokens = 0, priorWarmImageTokens = 0, shrinkWidth = true, maxCharsPerImage = READABLE_CHARS_PER_IMAGE) {
204
+ const n = Math.max(1, numCols | 0);
205
+ if (typeof text !== 'string' || text.length === 0)
206
+ return false;
207
+ const cpt = Number.isFinite(charsPerToken) && charsPerToken > 0
208
+ ? charsPerToken
209
+ : CHARS_PER_TOKEN;
210
+ const imageTokensCost_ = imageTokensCost(text, cols, n, imageCountCap, shrinkWidth, maxCharsPerImage);
211
+ const textTokensEquivalent = text.length / cpt;
212
+ // Symmetric burn penalty (anti-flapping): switching modes invalidates the warm
213
+ // cache on whichever side was warm, paying cache_create. Burn is added to the
214
+ // side that would flip — pinning the session in its current mode until
215
+ // per-turn savings exceed the burn cost.
216
+ const burnImageSide = Number.isFinite(priorWarmTokens) && priorWarmTokens > 0
217
+ ? priorWarmTokens * (CACHE_CREATE_RATE - CACHE_READ_RATE)
218
+ : 0;
219
+ const burnTextSide = Number.isFinite(priorWarmImageTokens) && priorWarmImageTokens > 0
220
+ ? priorWarmImageTokens * (CACHE_CREATE_RATE - CACHE_READ_RATE)
221
+ : 0;
222
+ return imageTokensCost_ + burnImageSide < textTokensEquivalent + burnTextSide;
223
+ }
224
+ /**
225
+ * Horizon-aware variant of `isCompressionProfitable` for history-collapse.
226
+ *
227
+ * Evaluates expected lifetime cost over N turns: worst-case-warm for image
228
+ * (cache_create turn 1, cache_read turns 2..N) vs best-case-warm for text
229
+ * (cache_read all N). Gate condition: I×(CC + CR×(N-1)) < T×CR×N.
230
+ * Examples: N=5 → I < 0.30×T; N=10 → I < 0.47×T.
231
+ * Falls back to cold per-turn gate when `horizon <= 1`. See docs/HISTORY_CACHE_MODEL.md.
232
+ */
233
+ export function isCompressionProfitableAmortized(text, cols, imageCountCap, numCols, charsPerToken, horizon, priorWarmTokens = 0, priorWarmImageTokens = 0, shrinkWidth = true, maxCharsPerImage = READABLE_CHARS_PER_IMAGE) {
234
+ if (!Number.isFinite(horizon) || horizon <= 1) {
235
+ return isCompressionProfitable(text, cols, imageCountCap, numCols, charsPerToken, priorWarmTokens, priorWarmImageTokens, shrinkWidth, maxCharsPerImage);
236
+ }
237
+ const N = Math.max(2, Math.floor(horizon));
238
+ const n = Math.max(1, numCols | 0);
239
+ if (typeof text !== 'string' || text.length === 0)
240
+ return false;
241
+ const cpt = Number.isFinite(charsPerToken) && charsPerToken > 0
242
+ ? charsPerToken
243
+ : CHARS_PER_TOKEN;
244
+ const imageTokens = imageTokensCost(text, cols, n, imageCountCap, shrinkWidth, maxCharsPerImage);
245
+ const textTokens = text.length / cpt;
246
+ // Worst-case-for-image vs best-case-for-text (conservative, on purpose).
247
+ const imageLifetime = imageTokens * (CACHE_CREATE_RATE + CACHE_READ_RATE * (N - 1));
248
+ const textLifetime = textTokens * CACHE_READ_RATE * N;
249
+ // Symmetric burn — see isCompressionProfitable for anti-flapping rationale.
250
+ const burnImageSide = Number.isFinite(priorWarmTokens) && priorWarmTokens > 0
251
+ ? priorWarmTokens * (CACHE_CREATE_RATE - CACHE_READ_RATE)
252
+ : 0;
253
+ const burnTextSide = Number.isFinite(priorWarmImageTokens) && priorWarmImageTokens > 0
254
+ ? priorWarmImageTokens * (CACHE_CREATE_RATE - CACHE_READ_RATE)
255
+ : 0;
256
+ return imageLifetime + burnImageSide < textLifetime + burnTextSide;
257
+ }
258
+ /** Increment a passthrough-reason counter on `info`. Lazily allocates `passthroughReasons`. */
259
+ function bumpPassthrough(info, reason) {
260
+ if (!info.passthroughReasons)
261
+ info.passthroughReasons = {};
262
+ info.passthroughReasons[reason] = (info.passthroughReasons[reason] ?? 0) + 1;
263
+ }
264
+ /** Invoke `keepSharp` defensively; a throw or non-`true` return means "image as usual". */
265
+ function callerKeepsSharp(fn, block) {
266
+ if (typeof fn !== 'function')
267
+ return false;
268
+ try {
269
+ return fn(block) === true;
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ }
275
+ /** Attribute `chars` to a compression bucket (called whether gate accepted or rejected). */
276
+ function bumpBucket(info, bucket, chars) {
277
+ if (chars <= 0)
278
+ return;
279
+ if (!info.bucketChars)
280
+ info.bucketChars = {};
281
+ info.bucketChars[bucket] = (info.bucketChars[bucket] ?? 0) + chars;
282
+ }
283
+ /** Map `classifyContent` shape to a tool_result bucket name. */
284
+ function toolResultBucket(shape) {
285
+ if (shape === 'structured')
286
+ return 'tool_result_json';
287
+ if (shape === 'log')
288
+ return 'tool_result_log';
289
+ return 'tool_result_prose';
290
+ }
291
+ // --- helpers ---------------------------------------------------------------
292
+ /** Extract (text, remainder) from a system field that may be string or block list. */
293
+ function extractSystemText(sys) {
294
+ if (sys == null)
295
+ return { text: '', kept: [] };
296
+ if (typeof sys === 'string')
297
+ return { text: sys, kept: '' };
298
+ const textParts = [];
299
+ const kept = [];
300
+ for (const block of sys) {
301
+ if (block && typeof block === 'object' && block.type === 'text') {
302
+ textParts.push(block.text);
303
+ }
304
+ else {
305
+ kept.push(block);
306
+ }
307
+ }
308
+ return { text: textParts.join('\n\n'), kept };
309
+ }
310
+ function lastStaticSystemCacheControl(sys) {
311
+ if (!Array.isArray(sys))
312
+ return undefined;
313
+ let cacheControl;
314
+ for (const block of sys) {
315
+ if (!block || block.type !== 'text' || block.cache_control === undefined)
316
+ continue;
317
+ const { body } = stripBillingLine(block.text);
318
+ if (splitStaticDynamic(body).staticText.length > 0) {
319
+ cacheControl = block.cache_control;
320
+ }
321
+ }
322
+ return cacheControl;
323
+ }
324
+ // Per-turn dynamic blocks injected by Claude Code. These drift turn-to-turn and
325
+ // must not be baked into the cached image. Split out so only the stable static
326
+ // slab (CLAUDE.md + tool docs) carries cache_control.
327
+ const DYNAMIC_BLOCK_TAGS = [
328
+ 'env',
329
+ 'context',
330
+ 'git_status',
331
+ 'directoryStructure',
332
+ 'system-reminder',
333
+ ];
334
+ // Known-static slab tags — suppresses first-sighting `unknownStaticTags` noise
335
+ // only. Correctness doesn't depend on this list: observeStaticTagChurn catches
336
+ // a wrong entry on its second sighting.
337
+ const KNOWN_STATIC_TAGS = [
338
+ // Claude Code
339
+ 'types',
340
+ // opencode (codex system prompts have no tag-shaped blocks)
341
+ 'example',
342
+ 'available_skills',
343
+ // beast.txt + title.txt
344
+ 'examples',
345
+ 'rules',
346
+ 'task',
347
+ // copilot-gpt-5.txt
348
+ 'codeSearchInstructions',
349
+ 'codeSearchToolUseInstructions',
350
+ 'communicationGuidelines',
351
+ 'gptAgentInstructions',
352
+ 'outputFormatting',
353
+ 'structuredWorkflow',
354
+ 'toolUseInstructions',
355
+ ];
356
+ function splitStaticDynamic(text) {
357
+ if (!text)
358
+ return {
359
+ staticText: '',
360
+ dynamicText: '',
361
+ blockCount: 0,
362
+ unknownTags: [],
363
+ staticTagContents: new Map(),
364
+ };
365
+ const pattern = new RegExp(`<(${DYNAMIC_BLOCK_TAGS.join('|')})(\\s[^>]*)?>[\\s\\S]*?</\\1>`, 'g');
366
+ const dynamicParts = [];
367
+ let staticBuf = '';
368
+ let cursor = 0;
369
+ let m;
370
+ while ((m = pattern.exec(text)) !== null) {
371
+ staticBuf += text.slice(cursor, m.index);
372
+ dynamicParts.push(m[0]);
373
+ cursor = m.index + m[0].length;
374
+ }
375
+ staticBuf += text.slice(cursor);
376
+ // Sniff for unknown tag-shaped blocks in the static slab. A new per-turn
377
+ // Claude Code tag would silently bake into the image and collapse cache rate;
378
+ // surfacing the tag name lets us detect it within hours of a release.
379
+ const known = new Set(DYNAMIC_BLOCK_TAGS);
380
+ const knownStatic = new Set(KNOWN_STATIC_TAGS);
381
+ const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g;
382
+ const unknown = new Set();
383
+ const staticTagContents = new Map();
384
+ let s;
385
+ while ((s = sniffer.exec(staticBuf)) !== null) {
386
+ const tag = s[1];
387
+ if (tag.length > 64)
388
+ continue;
389
+ if (!known.has(tag) && !knownStatic.has(tag))
390
+ unknown.add(tag);
391
+ // Fold repeated tags (e.g. several <example>s) into one fingerprint.
392
+ staticTagContents.set(tag, (staticTagContents.get(tag) ?? '') + s[2]);
393
+ }
394
+ return {
395
+ // Collapse the run of blank lines left behind by removed blocks.
396
+ staticText: staticBuf.replace(/\n{3,}/g, '\n\n').trim(),
397
+ dynamicText: dynamicParts.join('\n\n'),
398
+ blockCount: dynamicParts.length,
399
+ unknownTags: [...unknown],
400
+ staticTagContents,
401
+ };
402
+ }
403
+ /** FNV-1a 32-bit — cheap synchronous content fingerprint for churn detection. */
404
+ function fnv1a(text) {
405
+ let h = 0x811c9dc5;
406
+ for (let i = 0; i < text.length; i++) {
407
+ h ^= text.charCodeAt(i);
408
+ h = Math.imul(h, 0x01000193);
409
+ }
410
+ return h >>> 0;
411
+ }
412
+ // Last content hash per (session, tag). Bounded LRU.
413
+ const TAG_OBSERVATIONS_MAX = 4096;
414
+ const tagObservations = new Map();
415
+ /** Returns slab tags whose content changed since the last sighting in the same
416
+ * session — proven per-turn dynamics, whatever the hardcoded lists say. */
417
+ function observeStaticTagChurn(sessionKey, tagContents) {
418
+ const churned = [];
419
+ for (const [tag, inner] of tagContents) {
420
+ const key = `${sessionKey}\0${tag}`;
421
+ const hash = fnv1a(inner);
422
+ const prev = tagObservations.get(key);
423
+ if (prev !== undefined) {
424
+ if (prev !== hash)
425
+ churned.push(tag);
426
+ tagObservations.delete(key); // refresh LRU position
427
+ }
428
+ tagObservations.set(key, hash);
429
+ }
430
+ while (tagObservations.size > TAG_OBSERVATIONS_MAX) {
431
+ const oldest = tagObservations.keys().next().value;
432
+ if (oldest === undefined)
433
+ break;
434
+ tagObservations.delete(oldest);
435
+ }
436
+ return churned;
437
+ }
438
+ /** sha256[0..8] hex via Web Crypto (works in Node 18+ and Workers). 32-bit collision-safe. */
439
+ export async function sha8(text) {
440
+ const buf = new TextEncoder().encode(text);
441
+ const digest = await crypto.subtle.digest('SHA-256', buf);
442
+ const bytes = new Uint8Array(digest);
443
+ let hex = '';
444
+ for (let i = 0; i < 4; i++)
445
+ hex += bytes[i].toString(16).padStart(2, '0');
446
+ return hex;
447
+ }
448
+ /** Record a recovery entry when `emitRecoverable` is on. No-op (no hash cost) when off. */
449
+ async function recordRecoverable(info, emit, entry) {
450
+ if (!emit)
451
+ return;
452
+ const id = 'rec_' + (await sha8(`${entry.kind}\u0000${entry.toolUseId ?? ''}\u0000${entry.text}`));
453
+ (info.recoverable ??= []).push({
454
+ id,
455
+ kind: entry.kind,
456
+ ...(entry.toolUseId !== undefined ? { toolUseId: entry.toolUseId } : {}),
457
+ text: entry.text,
458
+ imageCount: entry.imageCount,
459
+ });
460
+ }
461
+ /** Hash the concatenated base64 of every image block on `messages[0]` (the synthetic
462
+ * history message). Stable across the quantized collapse window → proves Anthropic
463
+ * can cache_read the history prefix. Returns undefined if no images on messages[0]. */
464
+ async function historyImageSha8(messages) {
465
+ const synthetic = messages[0];
466
+ if (!synthetic || !Array.isArray(synthetic.content))
467
+ return undefined;
468
+ let concat = '';
469
+ for (const blk of synthetic.content) {
470
+ if (blk.type === 'image')
471
+ concat += blk.source.data;
472
+ }
473
+ return concat ? sha8(concat) : undefined;
474
+ }
475
+ /**
476
+ * After a history collapse, move pxpipe's single relocated cache breakpoint off
477
+ * the slab image and onto the LAST history image.
478
+ *
479
+ * The history image sits AFTER the slab in prefix order, so one marker on it
480
+ * caches the WHOLE imaged prefix (slab + history) as a single stable segment —
481
+ * created once, then read at the ~0.1x rate every turn. Without this the history
482
+ * image (usually the largest block) only lands in a cached prefix when the
483
+ * caller's roaming downstream marker happens to fall after it; when it doesn't,
484
+ * the entire history image re-creates at the 1.25x rate turn after turn.
485
+ *
486
+ * Pure relocation: it acts only when a slab image already carries the anchor, so
487
+ * the total marker count never increases (pxpipe never *adds* — only moves).
488
+ */
489
+ function relocateAnchorToHistoryImage(messages, anchorOrdinal) {
490
+ if (!Array.isArray(messages))
491
+ return;
492
+ // The synthetic history message is identified by its banner text block.
493
+ let historyImg;
494
+ for (const m of messages) {
495
+ if (!Array.isArray(m.content))
496
+ continue;
497
+ const first = m.content[0];
498
+ if (!first || first.type !== 'text' || first.text !== HISTORY_SYNTHETIC_INTRO)
499
+ continue;
500
+ // Collect this message's images in order, then pin the carry-over anchor (the last
501
+ // byte-stable history image) when collapseHistory provided its ordinal; otherwise
502
+ // fall back to the last image. Pinning the LAST image is the #11 bust: it's the
503
+ // newest, still-growing chunk and its bytes change on every window advance.
504
+ const imgsInMsg = [];
505
+ for (const b of m.content) {
506
+ if (b && b.type === 'image') {
507
+ imgsInMsg.push(b);
508
+ }
509
+ }
510
+ historyImg =
511
+ anchorOrdinal !== undefined && anchorOrdinal >= 0 && anchorOrdinal < imgsInMsg.length
512
+ ? imgsInMsg[anchorOrdinal]
513
+ : imgsInMsg[imgsInMsg.length - 1];
514
+ break;
515
+ }
516
+ if (!historyImg)
517
+ return;
518
+ // The slab anchor is the marked image BEFORE the '[End of rendered context.]'
519
+ // boundary in the slab-bearing message. Reminder/tool images sit after that
520
+ // boundary (or in other messages) and keep their own caller markers.
521
+ let slabAnchor;
522
+ for (const m of messages) {
523
+ if (!Array.isArray(m.content))
524
+ continue;
525
+ const hasBoundary = m.content.some((b) => b && b.type === 'text' && b.text === '[End of rendered context.]');
526
+ if (!hasBoundary)
527
+ continue;
528
+ for (const b of m.content) {
529
+ if (b && b.type === 'text' && b.text === '[End of rendered context.]')
530
+ break;
531
+ if (b && b.type === 'image' && b.cache_control !== undefined) {
532
+ slabAnchor = b;
533
+ }
534
+ }
535
+ break;
536
+ }
537
+ if (!slabAnchor)
538
+ return; // nothing to relocate → never add a marker
539
+ historyImg.cache_control = slabAnchor.cache_control;
540
+ delete slabAnchor.cache_control;
541
+ }
542
+ /**
543
+ * Read-only digest of the cacheable prefix pxpipe actually sends: tools +
544
+ * system + message blocks up to and including the imaged history image (or, on
545
+ * no-collapse turns, the slab boundary). The naturally-growing live tail is
546
+ * excluded, so the digest only moves when something *inside the pinned prefix*
547
+ * moves. Pairs with per-turn cache_read/cache_create to attribute a prompt-cache
548
+ * bust: a digest that CHANGES between consecutive turns of one session means we
549
+ * serialized different prefix bytes (pxpipe-side — a per-turn block crossing the
550
+ * breakpoint, or marker drift); a STABLE digest on a turn that still re-created
551
+ * the prefix points upstream (eviction). Never mutates the request, so it cannot
552
+ * perturb the cache behavior it measures.
553
+ */
554
+ async function cachePrefixDigest(req) {
555
+ const msgs = Array.isArray(req.messages) ? req.messages : [];
556
+ // Boundary = latest message carrying pxpipe's imaged prefix: the history image
557
+ // (banner) when collapse ran, else the slab message ('[End of rendered
558
+ // context.]'). Identified exactly as relocateAnchorToHistoryImage does.
559
+ let boundary = -1;
560
+ for (let i = 0; i < msgs.length; i++) {
561
+ const content = msgs[i]?.content;
562
+ if (!Array.isArray(content))
563
+ continue;
564
+ const first = content[0];
565
+ const isHistory = first?.type === 'text' && first.text === HISTORY_SYNTHETIC_INTRO;
566
+ const hasSlab = content.some((b) => b && b.type === 'text' && b.text === '[End of rendered context.]');
567
+ if (isHistory || hasSlab)
568
+ boundary = i;
569
+ }
570
+ if (boundary < 0)
571
+ return undefined; // not an imaged-prefix shape — nothing pinned
572
+ const parts = [];
573
+ if (Array.isArray(req.tools))
574
+ for (const t of req.tools)
575
+ parts.push(JSON.stringify(t));
576
+ const sys = req.system;
577
+ if (typeof sys === 'string')
578
+ parts.push(sys);
579
+ else if (Array.isArray(sys))
580
+ for (const b of sys)
581
+ parts.push(JSON.stringify(b));
582
+ for (let i = 0; i <= boundary; i++) {
583
+ const content = msgs[i]?.content;
584
+ if (typeof content === 'string')
585
+ parts.push(content);
586
+ else if (Array.isArray(content))
587
+ for (const b of content)
588
+ parts.push(typeof b === 'string' ? b : JSON.stringify(b));
589
+ }
590
+ const prefix = parts.join('\x00');
591
+ return { sha8: await sha8(prefix), bytes: prefix.length };
592
+ }
593
+ /** Best-effort extraction of the CLAUDE.md slab from a system text (heuristic).
594
+ * Returns empty string if nothing CLAUDE.md-shaped is detected. */
595
+ export function extractClaudeMdSlab(staticText) {
596
+ if (!staticText)
597
+ return '';
598
+ // Headings Claude Code uses around CLAUDE.md content.
599
+ const startPatterns = [
600
+ /^\s*#+\s*Claude\s+Code\s+Rules\s*$/im,
601
+ /^\s*#+\s*CLAUDE\.md\s*$/im,
602
+ /^\s*Claude\s+Code\s+Rules:?\s*$/im,
603
+ ];
604
+ let startIdx = -1;
605
+ for (const p of startPatterns) {
606
+ const m = p.exec(staticText);
607
+ if (m && (startIdx === -1 || m.index < startIdx))
608
+ startIdx = m.index;
609
+ }
610
+ if (startIdx === -1)
611
+ return '';
612
+ // End at the next top-level heading or EOF.
613
+ const tail = staticText.slice(startIdx);
614
+ const endMatch = /\n#\s+\S/.exec(tail.slice(1));
615
+ const end = endMatch ? endMatch.index + 1 : tail.length;
616
+ return tail.slice(0, end).trim();
617
+ }
618
+ /** First user message text, capped at 4 KiB (stable thread id; hashing large pastes is wasteful). */
619
+ export function firstUserText(req) {
620
+ const msgs = req.messages ?? [];
621
+ for (const m of msgs) {
622
+ if (m.role !== 'user')
623
+ continue;
624
+ if (typeof m.content === 'string')
625
+ return m.content.slice(0, 4096);
626
+ if (Array.isArray(m.content)) {
627
+ for (const block of m.content) {
628
+ if (block && block.type === 'text' && typeof block.text === 'string') {
629
+ return block.text.slice(0, 4096);
630
+ }
631
+ }
632
+ }
633
+ // First user message found but unreadable — return empty rather than fall through to next.
634
+ return '';
635
+ }
636
+ return '';
637
+ }
638
+ /** Parse structured fields from the dynamic slab for telemetry. Read-only. */
639
+ export function extractEnvFields(dynamicText) {
640
+ const out = {};
641
+ if (!dynamicText)
642
+ return out;
643
+ const envMatch = /<env>([\s\S]*?)<\/env>/i.exec(dynamicText);
644
+ if (envMatch) {
645
+ const body = envMatch[1];
646
+ const cwd = /(?:^|\n)\s*Working directory:\s*(.+?)\s*(?:\n|$)/i.exec(body);
647
+ if (cwd)
648
+ out.cwd = cwd[1].trim();
649
+ const gitRepo = /(?:^|\n)\s*Is directory a git repo:\s*(Yes|No)\b/i.exec(body);
650
+ if (gitRepo)
651
+ out.isGitRepo = gitRepo[1].toLowerCase() === 'yes';
652
+ const platform = /(?:^|\n)\s*Platform:\s*(.+?)\s*(?:\n|$)/i.exec(body);
653
+ if (platform)
654
+ out.platform = platform[1].trim();
655
+ const osVer = /(?:^|\n)\s*OS Version:\s*(.+?)\s*(?:\n|$)/i.exec(body);
656
+ if (osVer)
657
+ out.osVersion = osVer[1].trim();
658
+ const today = /(?:^|\n)\s*Today'?s date:\s*(.+?)\s*(?:\n|$)/i.exec(body);
659
+ if (today)
660
+ out.today = today[1].trim();
661
+ }
662
+ // Branch may be in <git_status>, <context name="git">, or a bare "Branch:" / "On branch" line.
663
+ const branch = /(?:^|\n)\s*(?:On branch|Branch:)\s*([^\s\n]+)/i.exec(dynamicText) ??
664
+ /(?:^|\n)\s*Current branch:\s*([^\s\n]+)/i.exec(dynamicText);
665
+ if (branch)
666
+ out.gitBranch = branch[1].trim();
667
+ return out;
668
+ }
669
+ /** Strip the per-turn `x-anthropic-billing-header:` line (changes every turn;
670
+ * must not be baked into the image). Returned as `kept` for the system tail. */
671
+ function stripBillingLine(text) {
672
+ const nl = text.indexOf('\n');
673
+ const first = nl === -1 ? text : text.slice(0, nl);
674
+ if (first.startsWith('x-anthropic-billing-header:')) {
675
+ return { kept: first, body: nl === -1 ? '' : text.slice(nl + 1) };
676
+ }
677
+ return { kept: null, body: text };
678
+ }
679
+ /** Extract the `# Environment` markdown section Claude Code injects into its
680
+ * system text (working dir, git state, platform, model ID). It carries no XML
681
+ * wrapper, so splitStaticDynamic can't catch it — yet its git-status lines
682
+ * change across sessions, and baking them into the slab PNG busts the cross-
683
+ * session cache (system_sha8 717f1fce → 5efaa4bb for a one-file edit). Parallel
684
+ * to stripBillingLine: `kept` re-enters the system tail as plain text. */
685
+ function stripMarkdownEnvSection(text) {
686
+ const m = /(?:^|\n)(# Environment\b[\s\S]*?)(?=\n#{1,6}\s|$)/.exec(text);
687
+ if (!m)
688
+ return { kept: '', body: text };
689
+ return {
690
+ kept: m[1].trimEnd(),
691
+ body: text.slice(0, m.index) + text.slice(m.index + m[0].length),
692
+ };
693
+ }
694
+ /** Build the "## Tool: name\n<desc>\n```json …```" block for one tool. Docs are
695
+ * imaged on this path (mirrors openai.ts renderToolDoc): the image carries the
696
+ * full schema — annotations included — at image token rates, while tools[]
697
+ * carries the annotation-stripped structure for Anthropic's tool-use validator.
698
+ * (The earlier text-reference design kept this prose-only because schema JSON
699
+ * at text rates would have duplicated what tools[] already pays for; at image
700
+ * rates the duplicate structure is cheap and the stripped annotations are the
701
+ * compression.) */
702
+ function renderToolDoc(t) {
703
+ const parts = [`## Tool: ${t.name ?? '?'}`];
704
+ if (t.description)
705
+ parts.push(t.description);
706
+ if (t.input_schema !== undefined) {
707
+ parts.push('```json\n' + JSON.stringify(t.input_schema) + '\n```');
708
+ }
709
+ return parts.join('\n');
710
+ }
711
+ function makeImageBlock(pngB64, _ephemeral = false) {
712
+ // pxpipe never adds its own cache_control — only moves existing caller markers
713
+ // across the text→image flip. `_ephemeral` is preserved for call-site compat.
714
+ return {
715
+ type: 'image',
716
+ source: { type: 'base64', media_type: 'image/png', data: pngB64 },
717
+ };
718
+ }
719
+ // --- paging / truncation ---------------------------------------------------
720
+ // Anthropic caps requests at 100 images. Huge tool_results (find trees,
721
+ // log dumps) are truncated with a paging marker before render.
722
+ /** Visual rows a single input line will consume after soft-wrap at `cols`. */
723
+ function lineRows(line, cols) {
724
+ return Math.max(1, Math.ceil(line.length / cols));
725
+ }
726
+ /** Visual row count after soft-wrap at `cols`. Both `\n` and the ↵ sentinel
727
+ * end a row; ↵ occupies a cell on the line it terminates. */
728
+ function countVisualRows(text, cols) {
729
+ let rows = 0;
730
+ let lineStart = 0;
731
+ const len = text.length;
732
+ for (let i = 0; i <= len; i++) {
733
+ const cc = i < len ? text.charCodeAt(i) : -1;
734
+ const isSentinel = cc === 0x21b5 /* ↵ */;
735
+ if (i === len || cc === 10 /* \n */ || isSentinel) {
736
+ // ↵ renders as a glyph on the line it ends — count it in the length.
737
+ const lineLen = (isSentinel ? i + 1 : i) - lineStart;
738
+ rows += Math.max(1, Math.ceil(lineLen / cols));
739
+ lineStart = i + 1;
740
+ }
741
+ }
742
+ return rows;
743
+ }
744
+ /** Estimate how many images `text` will render to at the given column width.
745
+ * Counts soft-wrapped visual rows, which is what render.ts actually budgets
746
+ * against. Exported for tests + the paging gate.
747
+ *
748
+ * `numCols` (default 1) packs that many text columns side-by-side per
749
+ * image — must match the `multiCol` setting wired through to the renderer
750
+ * for the math to predict the actual image count. */
751
+ export function estimateImageCount(textOrLen, cols, numCols = 1, maxCharsPerImage = READABLE_CHARS_PER_IMAGE) {
752
+ const n = Math.max(1, numCols | 0);
753
+ const readableLinesPerCol = Math.max(1, Math.floor(maxCharsPerImage / Math.max(1, cols)));
754
+ const linesPerImage = Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n;
755
+ const charBudget = Math.max(1, maxCharsPerImage * n);
756
+ if (typeof textOrLen === 'number') {
757
+ // Back-compat shim — numeric arg gets the looser chars-based estimate.
758
+ return Math.max(1, Math.ceil(textOrLen / charBudget));
759
+ }
760
+ const rows = countVisualRows(textOrLen, cols);
761
+ return Math.max(1, Math.ceil(rows / linesPerImage), Math.ceil(textOrLen.length / charBudget));
762
+ }
763
+ /** Classify content so we can pick a truncation strategy. Cheap heuristics on
764
+ * the first ~4 KiB. Returns:
765
+ * - `'structured'`: JSON/YAML/diff markers at the top. Truncate tail.
766
+ * - `'log'`: ≥30% of lines start with a log level or timestamp. Truncate middle.
767
+ * - `'other'`: prose, file dumps, etc. Truncate middle.
768
+ * Exported for tests. */
769
+ export function classifyContent(text) {
770
+ const head = text.slice(0, 4096);
771
+ const trimmed = head.trimStart();
772
+ if (trimmed.startsWith('{') && /^\{\s*("|\})/.test(trimmed))
773
+ return 'structured';
774
+ if (trimmed.startsWith('[') && /^\[\s*("|\{|\[|-?\d|true\b|false\b|null\b|\])/.test(trimmed))
775
+ return 'structured';
776
+ if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n'))
777
+ return 'structured';
778
+ if (trimmed.startsWith('diff --git ') || /^---\s+\S/.test(trimmed))
779
+ return 'structured';
780
+ const lines = head.split('\n').slice(0, 40).filter((l) => l.length > 0);
781
+ if (lines.length < 4)
782
+ return 'other';
783
+ const LOG_LINE = /^(\[?(DEBUG|INFO|WARN|WARNING|ERROR|TRACE|FATAL)\]?\b|\d{4}-\d{2}-\d{2}[T ]?|\d{2}:\d{2}:\d{2}\b)/;
784
+ let logHits = 0;
785
+ for (const line of lines)
786
+ if (LOG_LINE.test(line))
787
+ logHits++;
788
+ if (logHits / lines.length >= 0.3)
789
+ return 'log';
790
+ return 'other';
791
+ }
792
+ /** Build the paging marker text. The model sees this verbatim INSIDE the
793
+ * rendered image so it can reason about what was elided. */
794
+ function buildPagingMarker(args) {
795
+ const tailNote = args.shownTailLines > 0
796
+ ? ` Showing first ${args.shownHeadLines} lines and last ${args.shownTailLines} lines.`
797
+ : ` Showing first ${args.shownHeadLines} lines (tail elided).`;
798
+ return (`\n\n[ pxpipe paging: omitted ${args.omittedLines.toLocaleString('en-US')} lines ` +
799
+ `(${args.omittedChars.toLocaleString('en-US')} chars) of content here. ` +
800
+ `Original length: ${args.originalChars.toLocaleString('en-US')} chars ` +
801
+ `(${args.originalLines.toLocaleString('en-US')} lines, ~${args.originalEstImages} images).` +
802
+ `${tailNote} ]\n\n`);
803
+ }
804
+ /** Truncate `text` so it renders to roughly `maxImages` images at the given
805
+ * `cols`. Picks head/tail split based on `classifyContent`. Budget measured
806
+ * in visual rows (what render.ts actually slices on). Returns the truncated
807
+ * text (with paging marker embedded) and the count of chars omitted. If
808
+ * `text` already fits, returns unchanged with `omittedChars: 0`. Exported
809
+ * for tests. */
810
+ export function truncateForBudget(text, maxImages, cols, numCols = 1, maxCharsPerImage = DENSE_CONTENT_CHARS_PER_IMAGE) {
811
+ const n = Math.max(1, numCols | 0);
812
+ const estImages = estimateImageCount(text, cols, n, maxCharsPerImage);
813
+ if (estImages <= maxImages)
814
+ return { text, omittedChars: 0, truncated: false };
815
+ const readableLinesPerCol = Math.max(1, Math.floor(maxCharsPerImage / Math.max(1, cols)));
816
+ const totalRowBudget = Math.max(8, maxImages * Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n - 6);
817
+ const totalCharBudget = Math.max(128, maxImages * maxCharsPerImage * n - 512);
818
+ const shape = classifyContent(text);
819
+ // Reflowed text uses NL_SENTINEL (↵ U+21B5) as line separator instead of \n.
820
+ // Split on whichever delimiter the text uses so we can truncate at logical
821
+ // line boundaries rather than treating the entire reflowed blob as one line.
822
+ const nlChar = text.indexOf('\n') >= 0 ? '\n' : NL_SENTINEL;
823
+ const lines = text.split(nlChar);
824
+ const originalLines = lines.length;
825
+ const originalChars = text.length;
826
+ if (shape === 'structured') {
827
+ let rows = 0;
828
+ let chars = 0;
829
+ let cut = 0;
830
+ for (let i = 0; i < lines.length; i++) {
831
+ const r = lineRows(lines[i], cols);
832
+ const c = lines[i].length + (i > 0 ? 1 : 0);
833
+ if (rows + r > totalRowBudget || chars + c > totalCharBudget)
834
+ break;
835
+ rows += r;
836
+ chars += c;
837
+ cut = i + 1;
838
+ }
839
+ if (cut === 0)
840
+ cut = 1;
841
+ const head = lines.slice(0, cut).join(nlChar);
842
+ const omitted = originalChars - head.length;
843
+ return {
844
+ text: head +
845
+ buildPagingMarker({
846
+ originalChars,
847
+ originalLines,
848
+ originalEstImages: estImages,
849
+ shownHeadLines: cut,
850
+ shownTailLines: 0,
851
+ omittedLines: originalLines - cut,
852
+ omittedChars: omitted,
853
+ }),
854
+ omittedChars: omitted,
855
+ truncated: true,
856
+ };
857
+ }
858
+ // log / other: 60% head, 40% tail.
859
+ const headRowBudget = Math.floor(totalRowBudget * 0.6);
860
+ const tailRowBudget = totalRowBudget - headRowBudget;
861
+ const headCharBudget = Math.floor(totalCharBudget * 0.6);
862
+ const tailCharBudget = totalCharBudget - headCharBudget;
863
+ let headRows = 0;
864
+ let headChars = 0;
865
+ let headCut = 0;
866
+ for (let i = 0; i < lines.length; i++) {
867
+ const r = lineRows(lines[i], cols);
868
+ const c = lines[i].length + (i > 0 ? 1 : 0);
869
+ if (headRows + r > headRowBudget || headChars + c > headCharBudget)
870
+ break;
871
+ headRows += r;
872
+ headChars += c;
873
+ headCut = i + 1;
874
+ }
875
+ if (headCut === 0)
876
+ headCut = 1;
877
+ let tailRows = 0;
878
+ let tailChars = 0;
879
+ let tailStart = lines.length;
880
+ for (let i = lines.length - 1; i >= headCut; i--) {
881
+ const r = lineRows(lines[i], cols);
882
+ const c = lines[i].length + (i < lines.length - 1 ? 1 : 0);
883
+ if (tailRows + r > tailRowBudget || tailChars + c > tailCharBudget)
884
+ break;
885
+ tailRows += r;
886
+ tailChars += c;
887
+ tailStart = i;
888
+ }
889
+ if (tailStart <= headCut || tailStart >= lines.length) {
890
+ const head = lines.slice(0, headCut).join(nlChar);
891
+ const omitted = originalChars - head.length;
892
+ return {
893
+ text: head +
894
+ buildPagingMarker({
895
+ originalChars,
896
+ originalLines,
897
+ originalEstImages: estImages,
898
+ shownHeadLines: headCut,
899
+ shownTailLines: 0,
900
+ omittedLines: originalLines - headCut,
901
+ omittedChars: omitted,
902
+ }),
903
+ omittedChars: omitted,
904
+ truncated: true,
905
+ };
906
+ }
907
+ const headText = lines.slice(0, headCut).join(nlChar);
908
+ const tailText = lines.slice(tailStart).join(nlChar);
909
+ const shownChars = headText.length + tailText.length;
910
+ const omitted = originalChars - shownChars;
911
+ return {
912
+ text: headText +
913
+ buildPagingMarker({
914
+ originalChars,
915
+ originalLines,
916
+ originalEstImages: estImages,
917
+ shownHeadLines: headCut,
918
+ shownTailLines: lines.length - tailStart,
919
+ omittedLines: originalLines - headCut - (lines.length - tailStart),
920
+ omittedChars: omitted,
921
+ }) +
922
+ tailText,
923
+ omittedChars: omitted,
924
+ truncated: true,
925
+ };
926
+ }
927
+ /**
928
+ * Render text → Anthropic image blocks for the proxy. The column-selection rule below
929
+ * (shrink, then single-col unless the content fills the width) is mirrored exactly by
930
+ * the public SDK primitive `renderTextToImages` (library.ts), so the proxy and the
931
+ * `pxpipe export` CLI emit byte-identical PNGs for the same text. Exported so
932
+ * export-proxy-align.test.ts can pin that invariant against the real proxy code.
933
+ */
934
+ export async function textToImageBlocks(text, cols, numCols = 1,
935
+ /** Shrink canvas to the longest wrapped line. `false` for the slab path
936
+ * (fills full `cols` for multi-col packing). Default `true`. */
937
+ shrinkWidth = true) {
938
+ // Shrink before the numCols branch so gate and renderer see the same canvas width.
939
+ // If shrinkage drops below the full width, stay single-col (avoid wasting a divider column).
940
+ const effectiveCols = shrinkWidth ? shrinkColsToContent(text, cols) : cols;
941
+ const effectiveNumCols = effectiveCols < cols ? 1 : numCols;
942
+ const imgs = effectiveNumCols > 1
943
+ ? await renderTextToPngsMultiCol(text, effectiveCols, effectiveNumCols)
944
+ // Single-col dense: shrink the 384-col base to content so the renderer matches the
945
+ // gate (denseGateGeometry uses DENSE_CONTENT_COLS, priced via shrinkColsToContent).
946
+ // Was hard-coded to DENSE_CONTENT_COLS, which threw away the shrink the gate assumed.
947
+ : await renderTextToPngsWithCharLimit(text, shrinkColsToContent(text, DENSE_CONTENT_COLS), DENSE_CONTENT_CHARS_PER_IMAGE, DENSE_RENDER_STYLE);
948
+ let droppedChars = 0;
949
+ let pixels = 0;
950
+ const droppedCodepoints = new Map();
951
+ const blocks = [];
952
+ for (const img of imgs) {
953
+ blocks.push(makeImageBlock(bytesToBase64(img.png), false));
954
+ droppedChars += img.droppedChars;
955
+ pixels += img.width * img.height;
956
+ for (const [cp, n] of img.droppedCodepoints) {
957
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
958
+ }
959
+ }
960
+ return {
961
+ blocks,
962
+ pngs: imgs.map((i) => i.png),
963
+ dims: imgs.map((i) => ({ width: i.width, height: i.height })),
964
+ droppedChars,
965
+ droppedCodepoints,
966
+ pixels,
967
+ };
968
+ }
969
+ /** Best-effort byte-count of an image block's PNG payload (decoded from b64).
970
+ * Used only for the imageBytes telemetry; an exact value isn't worth a
971
+ * second base64 round-trip. */
972
+ function approxBlockBytes(blk) {
973
+ const b64 = blk.source.data;
974
+ // base64 → bytes: every 4 chars decode to 3 bytes, minus padding.
975
+ const pad = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
976
+ return Math.floor((b64.length * 3) / 4) - pad;
977
+ }
978
+ // --- main transform --------------------------------------------------------
979
+ /**
980
+ * Run history-image compression on `req.messages` and finalize the body.
981
+ * Called from both the main path AND early-exit paths (below_min_chars,
982
+ * not_profitable) — history collapse must run even when the slab skips.
983
+ * Tolerant to missing/short message arrays (collapseHistory short-circuits). */
984
+ async function runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints) {
985
+ let collapsedFlag = false;
986
+ if (Array.isArray(req.messages) && req.messages.length > 0) {
987
+ const historyCpt = opts.charsPerToken !== undefined
988
+ ? o.charsPerToken
989
+ : HISTORY_CHARS_PER_TOKEN;
990
+ const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon));
991
+ // Pass the symmetric warm-cache burn through to the history-collapse
992
+ // gate as well. The slab gate alone got the symmetric treatment, which
993
+ // let the history gate flip a session out of image mode even when
994
+ // symmetric burn would have kept the slab gate in. Production data
995
+ // 2026-05-23 showed three-turn sessions paying cache_create every
996
+ // turn because the history gate ignored priorWarmImageTokens.
997
+ const historyProfitable = (text, cols) => {
998
+ // History always renders single-col at the dense 384-col / 240-row page
999
+ // (history.ts → renderTextToPngsWithCharLimit with DENSE_CONTENT_COLS /
1000
+ // DENSE_CONTENT_CHARS_PER_IMAGE), so gate at THAT geometry, not o.cols.
1001
+ const g = denseGateGeometry(cols, 1);
1002
+ return isCompressionProfitableAmortized(text, g.cols, undefined, 1, historyCpt, horizon, o.priorWarmTokens, o.priorWarmImageTokens, true, g.maxChars);
1003
+ };
1004
+ // No protectedPrefix here: this path runs only when the slab did NOT image
1005
+ // (it stays as text in req.system), so there is no slab message to shield —
1006
+ // collapsing from the head is correct.
1007
+ const { messages: newMessages, info: histInfo } = await collapseHistory(req.messages, historyProfitable, { cols: o.cols, protectedPrefix: 0, reflow: o.reflow });
1008
+ if (histInfo.collapsedTurns > 0) {
1009
+ req.messages = newMessages;
1010
+ info.collapsedTurns = histInfo.collapsedTurns;
1011
+ info.collapsedChars = histInfo.collapsedChars;
1012
+ info.collapsedImages = histInfo.collapsedImages;
1013
+ info.imageCount += histInfo.collapsedImages;
1014
+ info.imageBytes += histInfo.collapsedImageBytes;
1015
+ info.imagePixels = (info.imagePixels ?? 0) + histInfo.collapsedImagePixels;
1016
+ // Register the rendered (colored) history PNGs into the dashboard image ring
1017
+ // so they are visible, not merely counted. Every other image path feeds this.
1018
+ // imagePngs + imageDims must be pushed in lockstep (ring reads them parallel).
1019
+ (info.imagePngs ??= []).push(...histInfo.collapsedPngs);
1020
+ (info.imageDims ??= []).push(...histInfo.collapsedImageDims);
1021
+ info.droppedChars = (info.droppedChars ?? 0) + histInfo.droppedChars;
1022
+ for (const [cp, n] of histInfo.droppedCodepoints) {
1023
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
1024
+ }
1025
+ info.historyReason = 'collapsed';
1026
+ info.historyTextChars = histInfo.collapsedChars;
1027
+ info.historyImageSha = await historyImageSha8(newMessages);
1028
+ bumpBucket(info, 'history', histInfo.collapsedChars);
1029
+ collapsedFlag = true;
1030
+ }
1031
+ else if (histInfo.reason) {
1032
+ info.historyReason = histInfo.reason;
1033
+ }
1034
+ }
1035
+ info.outgoingTextChars = countOutgoingTextChars(req);
1036
+ const outBody = new TextEncoder().encode(JSON.stringify(req));
1037
+ return { body: outBody, info, collapsed: collapsedFlag };
1038
+ }
1039
+ /**
1040
+ * Rewrite a Messages API request body. Returns the new body (still JSON
1041
+ * bytes) plus diagnostic info. On any error, returns the original bytes
1042
+ * unchanged.
1043
+ */
1044
+ export async function transformRequest(body, opts = {}) {
1045
+ // Merge caller opts over DEFAULTS, but treat explicit `undefined` as "not
1046
+ // provided" so it falls through to the default. Without this, a caller that
1047
+ // passes `{ minToolResultChars: undefined }` (common when forwarding partial
1048
+ // options from upstream — e.g. ocproxy's handler) would silently disable the
1049
+ // tool_result text-passthrough gate and route everything through the
1050
+ // renderer.
1051
+ const merged = { ...DEFAULTS, ...opts };
1052
+ for (const k of Object.keys(merged)) {
1053
+ if (merged[k] === undefined) {
1054
+ merged[k] = DEFAULTS[k];
1055
+ }
1056
+ }
1057
+ const o = merged;
1058
+ const info = {
1059
+ compressed: false,
1060
+ origChars: 0,
1061
+ compressedChars: 0,
1062
+ imageCount: 0,
1063
+ imageBytes: 0,
1064
+ staticChars: 0,
1065
+ dynamicChars: 0,
1066
+ dynamicBlockCount: 0,
1067
+ droppedChars: 0,
1068
+ };
1069
+ // Per-request codepoint drop histogram. Merged from every render call
1070
+ // (static slab + reminder + tool_result compressions). Serialized to
1071
+ // `info.droppedCodepointsTop` at the end of transformRequest IF non-empty.
1072
+ const droppedCodepoints = new Map();
1073
+ if (!o.compress) {
1074
+ info.reason = 'compress=false';
1075
+ return { body, info };
1076
+ }
1077
+ let req;
1078
+ try {
1079
+ req = JSON.parse(new TextDecoder().decode(body));
1080
+ }
1081
+ catch (e) {
1082
+ info.reason = `parse_error: ${e.message}`;
1083
+ return { body, info };
1084
+ }
1085
+ // 1. Pull system text out. Split into:
1086
+ // - billingLine: Claude Code's per-turn random header (must NOT be cached).
1087
+ // - dynamicText: <env>/<context>/... blocks (per-turn, kept as text).
1088
+ // - staticText: everything else (cacheable, goes into the image).
1089
+ const systemStaticCacheControl = lastStaticSystemCacheControl(req.system);
1090
+ const { text: rawSysText, kept: sysRemainder } = extractSystemText(req.system);
1091
+ const { kept: billingLine, body: sysBodyWithEnv } = stripBillingLine(rawSysText);
1092
+ // Pull the volatile `# Environment` markdown section out BEFORE the
1093
+ // static/dynamic split so per-session git state never reaches the slab image.
1094
+ const { kept: envMarkdown, body: sysBody } = stripMarkdownEnvSection(sysBodyWithEnv);
1095
+ const { staticText, dynamicText, blockCount: dynBlocks, unknownTags, staticTagContents, } = splitStaticDynamic(sysBody);
1096
+ info.staticChars = staticText.length;
1097
+ info.dynamicChars = dynamicText.length + envMarkdown.length;
1098
+ info.dynamicBlockCount = dynBlocks;
1099
+ if (unknownTags.length > 0)
1100
+ info.unknownStaticTags = unknownTags;
1101
+ // Parse env fields out of the dynamic slab — telemetry only, never mutates.
1102
+ const env = extractEnvFields(dynamicText);
1103
+ if (Object.keys(env).length > 0)
1104
+ info.env = env;
1105
+ // Privacy-safe fingerprints that don't depend on tool docs (computed
1106
+ // here so they're available even if we below_min_chars out below).
1107
+ // systemSha8 is set later, after we know the combined image-bound text.
1108
+ const claudeMdSlab = extractClaudeMdSlab(staticText);
1109
+ const firstUser = firstUserText(req);
1110
+ const [claudeMdSha, firstUserSha] = await Promise.all([
1111
+ claudeMdSlab ? sha8(claudeMdSlab) : Promise.resolve(undefined),
1112
+ firstUser ? sha8(firstUser) : Promise.resolve(undefined),
1113
+ ]);
1114
+ if (claudeMdSha)
1115
+ info.claudeMdSha8 = claudeMdSha;
1116
+ if (firstUserSha)
1117
+ info.firstUserSha8 = firstUserSha;
1118
+ // Canary: slab tags whose content churns within a session bust the image
1119
+ // cache every turn — report them regardless of the hardcoded lists.
1120
+ if (staticTagContents.size > 0) {
1121
+ const churning = observeStaticTagChurn(firstUserSha ?? claudeMdSha ?? 'global', staticTagContents);
1122
+ if (churning.length > 0)
1123
+ info.churningStaticTags = churning;
1124
+ }
1125
+ // 2. Move tool docs into the imaged "Tool Reference", stubbing originals.
1126
+ // Imaged (not text) because that IS the compression — descriptions and
1127
+ // schema annotations ride at image token rates, mirroring the GPT path.
1128
+ // Tool docs are static per session, so the slab image stays byte-stable
1129
+ // and cache-friendly. Each stub description cites its own heading
1130
+ // ("## Tool: <name>") so the model can link stub → full doc
1131
+ // deterministically.
1132
+ let toolDocsText = '';
1133
+ let toolsRewritten;
1134
+ if (o.compressTools && Array.isArray(req.tools) && req.tools.length > 0) {
1135
+ const docs = [];
1136
+ toolsRewritten = req.tools.map((t) => {
1137
+ docs.push(renderToolDoc(t));
1138
+ // tools[] keeps the annotation-STRIPPED schema: structure (type/properties/
1139
+ // required/enum/items) stays for Anthropic's tool-use validator — a bare
1140
+ // {type:'object'} stub caused 400s on non-interactive turns where Anthropic
1141
+ // deep-validates with no prior tool_use history to short-circuit. The
1142
+ // stripped annotations (description/title/examples/default) ride in the
1143
+ // imaged reference instead, at image rates. If stripping yields no
1144
+ // structural keys, keep the ORIGINAL schema untouched: it's tiny without
1145
+ // properties, and a bare stub is the riskier trade.
1146
+ let schema = t.input_schema;
1147
+ if (schema && typeof schema === 'object') {
1148
+ const stripped = stripSchemaDescriptions(schema, 0);
1149
+ if (stripped && typeof stripped === 'object' && schemaHasStructure(stripped)) {
1150
+ schema = stripped;
1151
+ }
1152
+ }
1153
+ // Read-before-Edit precondition rides as LIVE TEXT, not imaged: the CLI
1154
+ // rejects Edit/Write on any existing file not Read in THIS session's
1155
+ // process, and the rule lost salience once full tool docs moved into the
1156
+ // imaged reference (read-gate audit, 2026-07-03). Three tools only, no
1157
+ // banned wording — stays clear of the per-tool-stub repetition pattern
1158
+ // that tripped reasoning_extraction (see wording note below).
1159
+ const readFirstNote = READ_FIRST_TOOLS.has(t.name ?? '')
1160
+ ? ' Requires a Read of the same file earlier in THIS session when the file' +
1161
+ ' already exists — the call is rejected otherwise; file content recalled' +
1162
+ ' from imaged or prior-session context does not satisfy this.'
1163
+ : '';
1164
+ return {
1165
+ ...t,
1166
+ // Wording note (do NOT reintroduce "system prompt"/"authoritative" — same ban
1167
+ // as the imaged-slab banner below): a stub citing "...the Tool Reference
1168
+ // section of the system prompt", repeated once per tool, retripped Anthropic's
1169
+ // reasoning_extraction refusal (stop_reason: "refusal" → Claude Code fell back
1170
+ // to claude-opus-4-8 immediately on cold start, 2026-07-02). The reference
1171
+ // block's own header says where the docs live; the stub only needs the heading.
1172
+ description: `ⓘ Full docs: see "## Tool: ${t.name ?? '?'}" in the Tool Reference section.${readFirstNote}`,
1173
+ ...(schema !== undefined ? { input_schema: schema } : {}),
1174
+ };
1175
+ });
1176
+ toolDocsText = docs.join('\n\n');
1177
+ info.toolDocsChars = toolDocsText.length;
1178
+ }
1179
+ // Static slab + Tool Reference go into the renderer; dynamic slab and billing
1180
+ // line stay as plain text so the cache key (= image bytes) is stable across
1181
+ // turns. The reference header carries the same first-party provenance framing
1182
+ // that defused the imaged-slab banner refusal (169521c): pxpipe names itself
1183
+ // as the author of the relocation so the block reads as this session's own
1184
+ // config, not a replayed/extracted prompt.
1185
+ const toolReferenceText = toolDocsText
1186
+ ? '=== TOOL REFERENCE ===\n' +
1187
+ "pxpipe (this user's local proxy) moved the full tool documentation for this" +
1188
+ ' session here to reduce token cost. Each tool in the tools list carries a short' +
1189
+ ' stub description pointing here; the entry under the matching' +
1190
+ ' "## Tool: <name>" heading below is the complete description for that tool.\n\n' +
1191
+ toolDocsText +
1192
+ '\n=== END TOOL REFERENCE ==='
1193
+ : '';
1194
+ const combinedRaw = [staticText, toolReferenceText]
1195
+ .filter((s) => s.length > 0)
1196
+ .join('\n\n');
1197
+ // Caveman (opt-in experiment): deterministic prose-word dropping before
1198
+ // compaction, so gate/renderer/paging all price the shortened text. Gated
1199
+ // on prose shape — over structured/log content it would be a no-op scan.
1200
+ // The fact sheet below is built from `combinedRaw`, so exact identifiers
1201
+ // survive as text regardless of what the pass drops.
1202
+ const combinedSrc = o.caveman && classifyContent(combinedRaw) === 'other' ? cavemanize(combinedRaw) : combinedRaw;
1203
+ if (combinedSrc.length !== combinedRaw.length) {
1204
+ info.cavemanChars = (info.cavemanChars ?? 0) + (combinedRaw.length - combinedSrc.length);
1205
+ }
1206
+ // Compact then reflow before the gate; gate/renderer/paging all see the same text.
1207
+ // origChars anchored to raw length — that's what Anthropic would have billed.
1208
+ const combined = maybeReflow(compactSlabWhitespace(combinedSrc), o.reflow);
1209
+ info.origChars = combinedRaw.length;
1210
+ info.compressedChars = 0;
1211
+ if (combined)
1212
+ info.systemSha8 = await sha8(combined);
1213
+ if (combined.length < o.minCompressChars) {
1214
+ info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`;
1215
+ // Even with a static slab below the gate, message history may still be
1216
+ // collapsable. Run history collapse on the in-memory request so
1217
+ // production Codex traffic (tiny system, huge messages) still benefits.
1218
+ // If history collapses, we flip `info.compressed = true` and let the
1219
+ // library wrapper return reason='applied'; otherwise this still
1220
+ // populates `outgoingTextChars` for the regression denominator.
1221
+ const finalized = await runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints);
1222
+ if (finalized.collapsed) {
1223
+ info.compressed = true;
1224
+ return { body: finalized.body, info };
1225
+ }
1226
+ return { body, info };
1227
+ }
1228
+ // Break-even check guards even the slab (rare edge: tiny tool docs + tiny slab < 10k chars).
1229
+ // numCols clamped to 2000 px width cap; falls back to 1 if even 2 cols would exceed it.
1230
+ const numCols = Math.min(Math.max(1, (o.multiCol | 0) || 1), Math.max(1, maxFittingCols(o.cols)));
1231
+ // Gate geometry for dense single-col (tool_result/reminder) paths — 384-col/240-row.
1232
+ const denseGeo = denseGateGeometry(o.cols, numCols);
1233
+ // Use slab cpt (2.0) unless host pinned charsPerToken explicitly.
1234
+ const slabCpt = opts.charsPerToken !== undefined
1235
+ ? o.charsPerToken
1236
+ : SLAB_CHARS_PER_TOKEN;
1237
+ // Shrink canvas to longest actual line — pure function of (text, cols) so the
1238
+ // cache prefix stays byte-identical across turns. The banner sets a natural width floor.
1239
+ const reflowNoteImg = o.reflow
1240
+ ? ' The glyph ↵ (U+21B5) marks an original hard line break in content — treat as a real newline.'
1241
+ : '';
1242
+ const columnNoteImg = numCols > 1
1243
+ ? ` Multi-column layout (${numCols} cols): read column 1 (leftmost) top-to-bottom, then column 2, etc.`
1244
+ : '';
1245
+ // Wording note (do NOT reintroduce "system prompt"/"authoritative"): a user-turn
1246
+ // banner announcing "SYSTEM PROMPT ... treat as authoritative system instructions"
1247
+ // tripped Anthropic's reasoning_extraction refusal (reads as a replayed/extracted
1248
+ // prompt -> model-cloning heuristic) and forced a fallback-model switch. First-party
1249
+ // provenance framing below keeps obedience without the extraction signature.
1250
+ const imageInstructionHeader = '=================== SESSION CONFIGURATION PAGES ===================\n' +
1251
+ "pxpipe (this user's local proxy) rendered this session's configuration" +
1252
+ ' into the following images to reduce token cost. Read the pages carefully and follow them as' +
1253
+ ' your operating instructions for this session.' +
1254
+ columnNoteImg +
1255
+ reflowNoteImg +
1256
+ '\n====================== BEGIN RENDERED CONTEXT ======================\n';
1257
+ const combinedWithHeader = imageInstructionHeader + combined;
1258
+ // Shrink the canvas to the longest actual line in what we'll *render*,
1259
+ // so the gate's prediction and the renderer's output agree at the smallest
1260
+ // legible width. The banner above sets the natural floor — no separate
1261
+ // minWidth knob needed. Multi-col packing still gets numCols × this width.
1262
+ const slabCols = shrinkColsToContent(combinedWithHeader, o.cols);
1263
+ const slabGateEval = evalCompressionProfitability(combinedWithHeader, slabCols, undefined, numCols, slabCpt, o.priorWarmTokens, o.priorWarmImageTokens, false);
1264
+ if (slabGateEval) {
1265
+ info.gateEval = {
1266
+ site: 'slab',
1267
+ imageTokens: slabGateEval.imageTokens,
1268
+ textTokens: slabGateEval.textTokens,
1269
+ burnImageSide: slabGateEval.burnImageSide,
1270
+ burnTextSide: slabGateEval.burnTextSide,
1271
+ profitable: slabGateEval.profitable,
1272
+ };
1273
+ }
1274
+ if (!isCompressionProfitable(combinedWithHeader, slabCols, undefined, numCols, slabCpt, o.priorWarmTokens, o.priorWarmImageTokens, false)) {
1275
+ info.reason = `not_profitable (slab=${combined.length} chars)`;
1276
+ bumpPassthrough(info, 'not_profitable');
1277
+ // Slab not profitable but history may still be collapsable — try before returning.
1278
+ const finalized = await runHistoryCollapseAndFinalize(req, info, o, opts, droppedCodepoints);
1279
+ if (finalized.collapsed) {
1280
+ info.compressed = true;
1281
+ return { body: finalized.body, info };
1282
+ }
1283
+ return { body, info };
1284
+ }
1285
+ // Instruction header co-renders into the same PNG (+1.04pp L1 OCR vs baseline;
1286
+ // single-modal framing keeps encoder in image-reading mode for both header + content).
1287
+ // Header text is continuous prose (no hard \n) so the renderer soft-wraps densely.
1288
+ // 3. Render to PNGs at slabCols width (banner sets natural floor).
1289
+ const images = numCols > 1
1290
+ ? await renderTextToPngsMultiCol(combinedWithHeader, slabCols, numCols)
1291
+ : await renderTextToPngs(combinedWithHeader, slabCols);
1292
+ const imageBlocks = [];
1293
+ for (let i = 0; i < images.length; i++) {
1294
+ const img = images[i];
1295
+ const b64 = bytesToBase64(img.png);
1296
+ info.imageBytes += img.png.length;
1297
+ info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height;
1298
+ info.droppedChars = (info.droppedChars ?? 0) + img.droppedChars;
1299
+ for (const [cp, n] of img.droppedCodepoints) {
1300
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
1301
+ }
1302
+ const imageBlock = makeImageBlock(b64, i === images.length - 1);
1303
+ imageBlocks.push(i === images.length - 1 && systemStaticCacheControl !== undefined
1304
+ ? { ...imageBlock, cache_control: systemStaticCacheControl }
1305
+ : imageBlock);
1306
+ }
1307
+ info.imageCount = imageBlocks.length;
1308
+ // Credit raw (pre-compaction) length — what Anthropic would have billed.
1309
+ info.compressedChars += combinedRaw.length;
1310
+ bumpBucket(info, 'static_slab', combinedRaw.length);
1311
+ if (images.length > 0) {
1312
+ info.firstImagePng = images[0].png;
1313
+ info.firstImageWidth = images[0].width;
1314
+ info.firstImageHeight = images[0].height;
1315
+ (info.imagePngs ??= []).push(...images.map((i) => i.png));
1316
+ (info.imageDims ??= []).push(...images.map((i) => ({ width: i.width, height: i.height })));
1317
+ info.imageSourceText = combinedWithHeader.slice(0, 65_536);
1318
+ }
1319
+ // 4. Splice images back into the request. OCR framing is baked into the image.
1320
+ //
1321
+ // Volatile env/context text (git status, cwd, date) must NOT ride in
1322
+ // req.system: Anthropic's cache prefix order is tools → system → messages,
1323
+ // so system bytes sit BEFORE the slab anchor and any git-state change
1324
+ // cold-restarted the whole anchored prefix (48.8% of cold-create waste,
1325
+ // events.jsonl 2026-06-26..07-02). It is carried instead at the END of the
1326
+ // last user message — the per-turn live tail that re-caches incrementally
1327
+ // anyway — appended late in this function, AFTER history collapse, so it can
1328
+ // never be baked into a frozen history chunk. Fallback: if no user message
1329
+ // exists to carry it, keep it in system rather than drop content.
1330
+ const hasUserMsg = (req.messages ?? []).some((m) => m.role === 'user');
1331
+ const volatileEnvParts = [];
1332
+ if (dynamicText)
1333
+ volatileEnvParts.push(dynamicText);
1334
+ if (envMarkdown)
1335
+ volatileEnvParts.push(envMarkdown);
1336
+ const volatileEnvText = hasUserMsg ? volatileEnvParts.join('\n\n') : '';
1337
+ // Images go into first user message — system field rejects images (400 system.N.type).
1338
+ {
1339
+ const sysTail = [];
1340
+ // billingLine is session-stable (warm reads through the anchored prefix
1341
+ // confirm it; a per-turn value here would zero every cache read).
1342
+ if (billingLine)
1343
+ sysTail.push({ type: 'text', text: billingLine });
1344
+ if (!hasUserMsg) {
1345
+ if (dynamicText)
1346
+ sysTail.push({ type: 'text', text: dynamicText });
1347
+ if (envMarkdown)
1348
+ sysTail.push({ type: 'text', text: envMarkdown });
1349
+ }
1350
+ if (Array.isArray(sysRemainder))
1351
+ sysTail.push(...sysRemainder);
1352
+ // Tool Reference now rides INSIDE the imaged slab (combinedRaw above) — no
1353
+ // text splice here. Stubbed tools[] descriptions cite the "## Tool: <name>"
1354
+ // headings inside the image; stub ↔ reference invariant holds because both
1355
+ // are applied on this same path (gate-fail paths return earlier with
1356
+ // original tools untouched).
1357
+ req.system = sysTail.length > 0 ? sysTail : undefined;
1358
+ const firstUserIdx = (req.messages ?? []).findIndex((m) => m.role === 'user');
1359
+ if (firstUserIdx >= 0) {
1360
+ const m = req.messages[firstUserIdx];
1361
+ const existing = Array.isArray(m.content)
1362
+ ? m.content
1363
+ : [{ type: 'text', text: m.content }];
1364
+ // 5a. Compress <system-reminder> text blocks. cache_control on source text
1365
+ // moves to the LAST produced image (pxpipe never adds its own markers).
1366
+ const processedExisting = [];
1367
+ if (o.compressReminders) {
1368
+ for (const blk of existing) {
1369
+ const isReminderText = blk &&
1370
+ blk.type === 'text' &&
1371
+ typeof blk.text === 'string' &&
1372
+ blk.text.trimStart().startsWith('<system-reminder>');
1373
+ if (!isReminderText) {
1374
+ processedExisting.push(blk);
1375
+ continue;
1376
+ }
1377
+ // Caller fidelity override: pin this block as text, skip imaging.
1378
+ if (callerKeepsSharp(o.keepSharp, { kind: 'reminder', text: blk.text })) {
1379
+ bumpPassthrough(info, 'kept_sharp');
1380
+ info.keptSharpBlocks = (info.keptSharpBlocks ?? 0) + 1;
1381
+ processedExisting.push(blk);
1382
+ continue;
1383
+ }
1384
+ const textLen = blk.text.length;
1385
+ if (textLen < o.minReminderChars) {
1386
+ // Below coarse threshold; can't possibly be profitable. Skip.
1387
+ bumpPassthrough(info, 'below_threshold');
1388
+ processedExisting.push(blk);
1389
+ continue;
1390
+ }
1391
+ // Lossless whitespace compaction — same dynamics as the system
1392
+ // slab: every newline costs ≥1 visual row regardless of column
1393
+ // width, so stripped trailing whitespace + collapsed blank-line
1394
+ // runs reduce real renderer cost without changing what the
1395
+ // model reads.
1396
+ const reminderRaw = blk.text;
1397
+ // Caveman (opt-in): same placement as the slab path — before
1398
+ // compaction so profitability prices the shortened text. Fact sheet
1399
+ // and recoverable entry below keep using `reminderRaw`.
1400
+ const reminderSrc = o.caveman && classifyContent(reminderRaw) === 'other'
1401
+ ? cavemanize(reminderRaw)
1402
+ : reminderRaw;
1403
+ if (reminderSrc.length !== reminderRaw.length) {
1404
+ info.cavemanChars =
1405
+ (info.cavemanChars ?? 0) + (reminderRaw.length - reminderSrc.length);
1406
+ }
1407
+ const reminderText = maybeReflow(compactSlabWhitespace(reminderSrc), o.reflow);
1408
+ if (!isCompressionProfitable(reminderText, denseGeo.cols, undefined, numCols, o.charsPerToken, 0, 0, true, denseGeo.maxChars)) {
1409
+ bumpPassthrough(info, 'not_profitable');
1410
+ processedExisting.push(blk);
1411
+ continue;
1412
+ }
1413
+ const { blocks: imgs, pngs: rawPngs, dims: rawDims, droppedChars, droppedCodepoints: dcp, pixels } = await textToImageBlocks(reminderText, o.cols, numCols);
1414
+ (info.imagePngs ??= []).push(...rawPngs);
1415
+ (info.imageDims ??= []).push(...rawDims);
1416
+ const srcCacheControl = blk.cache_control;
1417
+ for (let i = 0; i < imgs.length; i++) {
1418
+ const img = imgs[i];
1419
+ const out = i === imgs.length - 1 && srcCacheControl !== undefined
1420
+ ? { ...img, cache_control: srcCacheControl }
1421
+ : img;
1422
+ processedExisting.push(out);
1423
+ info.imageBytes += approxBlockBytes(img);
1424
+ }
1425
+ const reminderFactSheet = factSheetText(reminderRaw);
1426
+ if (reminderFactSheet)
1427
+ processedExisting.push({ type: 'text', text: reminderFactSheet });
1428
+ info.imagePixels = (info.imagePixels ?? 0) + pixels;
1429
+ info.reminderImgs = (info.reminderImgs ?? 0) + imgs.length;
1430
+ await recordRecoverable(info, o.emitRecoverable, {
1431
+ kind: 'reminder',
1432
+ text: reminderRaw,
1433
+ imageCount: imgs.length,
1434
+ });
1435
+ info.compressedChars += reminderRaw.length;
1436
+ bumpBucket(info, 'reminder', reminderRaw.length);
1437
+ info.imageCount += imgs.length;
1438
+ info.droppedChars = (info.droppedChars ?? 0) + droppedChars;
1439
+ for (const [cp, n] of dcp) {
1440
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
1441
+ }
1442
+ }
1443
+ }
1444
+ else {
1445
+ processedExisting.push(...existing);
1446
+ }
1447
+ const slabFactSheet = factSheetText(combinedRaw);
1448
+ m.content = [
1449
+ ...imageBlocks,
1450
+ ...(slabFactSheet ? [{ type: 'text', text: slabFactSheet }] : []),
1451
+ { type: 'text', text: '[End of rendered context.]' },
1452
+ ...processedExisting,
1453
+ ];
1454
+ }
1455
+ // 5b. Compress tool_result content across ALL user messages.
1456
+ if (o.compressToolResults) {
1457
+ for (const msg of req.messages ?? []) {
1458
+ if (msg.role !== 'user' || !Array.isArray(msg.content))
1459
+ continue;
1460
+ const rewritten = [];
1461
+ let changed = false;
1462
+ for (const blk of msg.content) {
1463
+ if (blk && blk.type === 'tool_result') {
1464
+ const tr = blk;
1465
+ // Anthropic rejects images inside is_error tool_results — leave alone.
1466
+ if (tr.is_error === true) {
1467
+ rewritten.push(blk);
1468
+ continue;
1469
+ }
1470
+ const innerRaw = tr.content;
1471
+ if (typeof innerRaw === 'string') {
1472
+ // Caller fidelity override: pin this tool_result as text.
1473
+ if (callerKeepsSharp(o.keepSharp, { kind: 'tool_result', text: innerRaw, toolUseId: tr.tool_use_id })) {
1474
+ bumpPassthrough(info, 'kept_sharp');
1475
+ info.keptSharpBlocks = (info.keptSharpBlocks ?? 0) + 1;
1476
+ rewritten.push(blk);
1477
+ continue;
1478
+ }
1479
+ const inner = compactSlabWhitespace(innerRaw);
1480
+ // classifyContent sees pre-reflow `inner` so shape bucketing reflects real structure.
1481
+ const innerR = maybeReflow(inner, o.reflow);
1482
+ if (innerR.length < o.minToolResultChars) {
1483
+ bumpPassthrough(info, 'below_threshold');
1484
+ rewritten.push(blk);
1485
+ }
1486
+ else if (!isCompressionProfitable(innerR, denseGeo.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken, 0, 0, true, denseGeo.maxChars)) {
1487
+ bumpPassthrough(info, 'not_profitable');
1488
+ rewritten.push(blk);
1489
+ }
1490
+ else {
1491
+ // Paging: truncate before render if it would blow the image cap.
1492
+ const paged = truncateForBudget(innerR, o.maxImagesPerToolResult, denseGeo.cols, numCols, denseGeo.maxChars);
1493
+ if (paged.truncated) {
1494
+ info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
1495
+ info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
1496
+ }
1497
+ const { blocks: imgs, pngs: rawPngs, dims: rawDims, droppedChars, droppedCodepoints: dcp, pixels } = await textToImageBlocks(paged.text, o.cols, numCols);
1498
+ (info.imagePngs ??= []).push(...rawPngs);
1499
+ (info.imageDims ??= []).push(...rawDims);
1500
+ for (const img of imgs)
1501
+ info.imageBytes += approxBlockBytes(img);
1502
+ info.imagePixels = (info.imagePixels ?? 0) + pixels;
1503
+ info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length;
1504
+ info.imageCount += imgs.length;
1505
+ await recordRecoverable(info, o.emitRecoverable, {
1506
+ kind: 'tool_result',
1507
+ toolUseId: tr.tool_use_id,
1508
+ text: innerRaw,
1509
+ imageCount: imgs.length,
1510
+ });
1511
+ info.compressedChars += innerRaw.length; // original length = what text billing would be
1512
+ info.droppedChars = (info.droppedChars ?? 0) + droppedChars;
1513
+ for (const [cp, n] of dcp) {
1514
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
1515
+ }
1516
+ const trFactSheet = factSheetText(innerRaw);
1517
+ rewritten.push({
1518
+ ...tr,
1519
+ content: trFactSheet ? [...imgs, { type: 'text', text: trFactSheet }] : imgs,
1520
+ });
1521
+ changed = true;
1522
+ bumpBucket(info, toolResultBucket(classifyContent(inner)), innerRaw.length);
1523
+ }
1524
+ }
1525
+ else if (Array.isArray(innerRaw)) {
1526
+ const newInner = [];
1527
+ let innerChanged = false;
1528
+ for (const ib of innerRaw) {
1529
+ const isTextBlock = ib &&
1530
+ ib.type === 'text' &&
1531
+ typeof ib.text === 'string';
1532
+ if (!isTextBlock) {
1533
+ newInner.push(ib);
1534
+ continue;
1535
+ }
1536
+ const innerTextRaw = ib.text;
1537
+ // Caller fidelity override: pin this tool_result part as text.
1538
+ if (callerKeepsSharp(o.keepSharp, { kind: 'tool_result_part', text: innerTextRaw, toolUseId: tr.tool_use_id })) {
1539
+ bumpPassthrough(info, 'kept_sharp');
1540
+ info.keptSharpBlocks = (info.keptSharpBlocks ?? 0) + 1;
1541
+ newInner.push(ib);
1542
+ continue;
1543
+ }
1544
+ // Lossless whitespace compaction before gate + render.
1545
+ const innerText = compactSlabWhitespace(innerTextRaw);
1546
+ // R3: gate/page/render on reflowed text; classify pre-reflow.
1547
+ const innerTextR = maybeReflow(innerText, o.reflow);
1548
+ if (innerTextR.length < o.minToolResultChars) {
1549
+ bumpPassthrough(info, 'below_threshold');
1550
+ newInner.push(ib);
1551
+ continue;
1552
+ }
1553
+ if (!isCompressionProfitable(innerTextR, denseGeo.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken, 0, 0, true, denseGeo.maxChars)) {
1554
+ bumpPassthrough(info, 'not_profitable');
1555
+ newInner.push(ib);
1556
+ continue;
1557
+ }
1558
+ const paged = truncateForBudget(innerTextR, o.maxImagesPerToolResult, denseGeo.cols, numCols, denseGeo.maxChars);
1559
+ if (paged.truncated) {
1560
+ info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
1561
+ info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
1562
+ }
1563
+ const { blocks: imgs, pngs: rawPngs, dims: rawDims, droppedChars, droppedCodepoints: dcp, pixels } = await textToImageBlocks(paged.text, o.cols, numCols);
1564
+ (info.imagePngs ??= []).push(...rawPngs);
1565
+ (info.imageDims ??= []).push(...rawDims);
1566
+ const srcCacheControl = ib.cache_control;
1567
+ for (let i = 0; i < imgs.length; i++) {
1568
+ const img = imgs[i];
1569
+ const out = i === imgs.length - 1 && srcCacheControl !== undefined
1570
+ ? { ...img, cache_control: srcCacheControl }
1571
+ : img;
1572
+ newInner.push(out);
1573
+ info.imageBytes += approxBlockBytes(img);
1574
+ }
1575
+ const partFactSheet = factSheetText(innerTextRaw);
1576
+ if (partFactSheet)
1577
+ newInner.push({ type: 'text', text: partFactSheet });
1578
+ info.imagePixels = (info.imagePixels ?? 0) + pixels;
1579
+ info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length;
1580
+ info.imageCount += imgs.length;
1581
+ await recordRecoverable(info, o.emitRecoverable, {
1582
+ kind: 'tool_result_part',
1583
+ toolUseId: tr.tool_use_id,
1584
+ text: innerTextRaw,
1585
+ imageCount: imgs.length,
1586
+ });
1587
+ info.compressedChars += innerTextRaw.length;
1588
+ info.droppedChars = (info.droppedChars ?? 0) + droppedChars;
1589
+ for (const [cp, n] of dcp) {
1590
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
1591
+ }
1592
+ bumpBucket(info, toolResultBucket(classifyContent(innerText)), innerTextRaw.length);
1593
+ innerChanged = true;
1594
+ }
1595
+ if (innerChanged) {
1596
+ rewritten.push({ ...tr, content: newInner });
1597
+ changed = true;
1598
+ }
1599
+ else {
1600
+ rewritten.push(blk);
1601
+ }
1602
+ }
1603
+ else {
1604
+ rewritten.push(blk);
1605
+ }
1606
+ }
1607
+ else {
1608
+ rewritten.push(blk);
1609
+ }
1610
+ }
1611
+ if (changed)
1612
+ msg.content = rewritten;
1613
+ }
1614
+ }
1615
+ }
1616
+ if (toolsRewritten)
1617
+ req.tools = toolsRewritten;
1618
+ // 6. History-image compression (always runs after per-message rewrites).
1619
+ // History is single-col dense; use slab cpt unless host pinned charsPerToken.
1620
+ // protectedPrefix excludes the slab-bearing first user message — collapsing it
1621
+ // would reduce slab images to [image] placeholders and destroy the cache anchor.
1622
+ if (Array.isArray(req.messages) && req.messages.length > 0) {
1623
+ const historyCpt = opts.charsPerToken !== undefined
1624
+ ? o.charsPerToken
1625
+ : HISTORY_CHARS_PER_TOKEN;
1626
+ const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon));
1627
+ const historyProfitable = (text, cols) => {
1628
+ // Gate at dense 384-col/240-row geometry (matches history.ts renderer).
1629
+ const g = denseGateGeometry(cols, 1);
1630
+ return isCompressionProfitableAmortized(text, g.cols, undefined, 1, historyCpt, horizon, o.priorWarmTokens, o.priorWarmImageTokens, true, g.maxChars);
1631
+ };
1632
+ const slabAnchorIdx = (req.messages ?? []).findIndex((m) => m.role === 'user');
1633
+ const { messages: newMessages, info: histInfo } = await collapseHistory(req.messages, historyProfitable, { cols: o.cols, protectedPrefix: slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, reflow: o.reflow });
1634
+ if (histInfo.collapsedTurns > 0) {
1635
+ req.messages = newMessages;
1636
+ info.collapsedTurns = histInfo.collapsedTurns;
1637
+ info.collapsedChars = histInfo.collapsedChars;
1638
+ info.collapsedImages = histInfo.collapsedImages;
1639
+ info.imageCount += histInfo.collapsedImages;
1640
+ info.imageBytes += histInfo.collapsedImageBytes;
1641
+ info.imagePixels = (info.imagePixels ?? 0) + histInfo.collapsedImagePixels;
1642
+ // Register the rendered (colored) history PNGs into the dashboard image ring
1643
+ // so they are visible, not merely counted. Every other image path feeds this.
1644
+ // imagePngs + imageDims must be pushed in lockstep (ring reads them parallel).
1645
+ (info.imagePngs ??= []).push(...histInfo.collapsedPngs);
1646
+ (info.imageDims ??= []).push(...histInfo.collapsedImageDims);
1647
+ info.droppedChars = (info.droppedChars ?? 0) + histInfo.droppedChars;
1648
+ for (const [cp, n] of histInfo.droppedCodepoints) {
1649
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
1650
+ }
1651
+ info.historyReason = 'collapsed';
1652
+ info.historyTextChars = histInfo.collapsedChars;
1653
+ info.historyImageSha = await historyImageSha8(newMessages);
1654
+ bumpBucket(info, 'history', histInfo.collapsedChars);
1655
+ // Move the single cache anchor onto the history image so slab + history
1656
+ // cache as one stable prefix (created once, then read), instead of the
1657
+ // history image re-creating whenever the caller's downstream marker moves.
1658
+ //
1659
+ // ONLY when collapseHistory pinned a byte-frozen carry-over chunk. On the
1660
+ // session's FIRST collapse the range fits in one freeze window, no carry-over
1661
+ // exists (ordinal undefined), and relocating would land the anchor on the
1662
+ // newest still-growing history image — a volatile breakpoint that forced a
1663
+ // one-time full-prefix rewrite (~53k tokens/session). Leave the anchor on
1664
+ // the byte-stable slab image until a frozen chunk exists to pin to.
1665
+ if (histInfo.carryOverImageOrdinal !== undefined) {
1666
+ relocateAnchorToHistoryImage(req.messages, histInfo.carryOverImageOrdinal);
1667
+ }
1668
+ }
1669
+ else if (histInfo.reason) {
1670
+ info.historyReason = histInfo.reason;
1671
+ }
1672
+ }
1673
+ // Volatile env/context text lands at the END of the last user message (see
1674
+ // the block above image splice for why). Runs AFTER history collapse so the
1675
+ // env bytes stay in the live tail — never imaged into a frozen chunk — and
1676
+ // AFTER 5b so they are never run through tool_result compression. Note
1677
+ // tool_result blocks legally precede trailing text blocks in a user message
1678
+ // (Claude Code appends its own system-reminders the same way).
1679
+ //
1680
+ // The block is wrapped in <system-reminder> tags so the model (and any
1681
+ // human reading a transcript) attributes it as injected context, NOT user
1682
+ // prose. Without the wrapper the relocated "# Environment" section blends
1683
+ // seamlessly into the user's message — on an empty/short user turn it can
1684
+ // BECOME the entire visible message (observed live, 2026-07). The wrapper
1685
+ // rides in the volatile tail behind the slab anchor, so it costs ~60 chars
1686
+ // per request and cannot perturb the cached prefix. Same-pass safety: 5a
1687
+ // (compressReminders) runs earlier and only scans the first user message,
1688
+ // so this block is never self-imaged; and pxpipe is stateless per request,
1689
+ // so the wrapper never appears in inbound client history (no compounding).
1690
+ if (volatileEnvText) {
1691
+ const wrappedEnvText = `<system-reminder>\nContext relocated by pxpipe from the system prompt (volatile per-turn environment state — not written by the user):\n\n${volatileEnvText}\n</system-reminder>`;
1692
+ const msgs = req.messages ?? [];
1693
+ for (let i = msgs.length - 1; i >= 0; i--) {
1694
+ const m = msgs[i];
1695
+ if (m.role !== 'user')
1696
+ continue;
1697
+ const content = Array.isArray(m.content)
1698
+ ? m.content
1699
+ : [{ type: 'text', text: m.content }];
1700
+ msgs[i] = { ...m, content: [...content, { type: 'text', text: wrappedEnvText }] };
1701
+ info.envRelocatedChars = wrappedEnvText.length;
1702
+ break;
1703
+ }
1704
+ }
1705
+ info.compressed = true;
1706
+ // Attribution signal for prompt-cache busts (#11): digest the exact pinned
1707
+ // prefix we send (history/slab boundary; live tail excluded) AFTER all marker
1708
+ // placement — incl. relocateAnchorToHistoryImage — is final. Read-only.
1709
+ {
1710
+ const pfx = await cachePrefixDigest(req);
1711
+ if (pfx) {
1712
+ info.cachePrefixSha8 = pfx.sha8;
1713
+ info.cachePrefixBytes = pfx.bytes;
1714
+ }
1715
+ }
1716
+ // Top dropped codepoints, capped at 20 entries to bound JSONL row size.
1717
+ if (droppedCodepoints.size > 0) {
1718
+ const TOP_N = 20;
1719
+ const sorted = [...droppedCodepoints.entries()]
1720
+ .sort((a, b) => b[1] - a[1])
1721
+ .slice(0, TOP_N);
1722
+ const out = {};
1723
+ for (const [cp, count] of sorted) {
1724
+ const hex = cp.toString(16).toUpperCase().padStart(4, '0');
1725
+ out[`U+${hex}`] = count;
1726
+ }
1727
+ info.droppedCodepointsTop = out;
1728
+ }
1729
+ info.outgoingTextChars = countOutgoingTextChars(req);
1730
+ const outBody = new TextEncoder().encode(JSON.stringify(req));
1731
+ return { body: outBody, info };
1732
+ }
1733
+ /** Sum every TEXT char the upstream tokenizer will see (system, tools, messages).
1734
+ * Excludes image base64 and redacted_thinking. Denominator for the
1735
+ * `tokens ≈ α·outgoingTextChars + β·imagePixels` regression. */
1736
+ function countOutgoingTextChars(req) {
1737
+ let n = 0;
1738
+ // 1. system field
1739
+ const sys = req.system;
1740
+ if (typeof sys === 'string') {
1741
+ n += sys.length;
1742
+ }
1743
+ else if (Array.isArray(sys)) {
1744
+ for (const b of sys) {
1745
+ if (b && b.type === 'text' && typeof b.text === 'string') {
1746
+ n += b.text.length;
1747
+ }
1748
+ }
1749
+ }
1750
+ // 2. tool definitions
1751
+ if (Array.isArray(req.tools)) {
1752
+ for (const tool of req.tools) {
1753
+ if (!tool || typeof tool !== 'object')
1754
+ continue;
1755
+ if (typeof tool.name === 'string')
1756
+ n += tool.name.length;
1757
+ if (typeof tool.description === 'string')
1758
+ n += tool.description.length;
1759
+ if (tool.input_schema !== undefined) {
1760
+ n += safeStringifyLen(tool.input_schema);
1761
+ }
1762
+ }
1763
+ }
1764
+ // 3. per-message content
1765
+ for (const msg of req.messages ?? []) {
1766
+ const c = msg.content;
1767
+ if (typeof c === 'string') {
1768
+ n += c.length;
1769
+ continue;
1770
+ }
1771
+ if (!Array.isArray(c))
1772
+ continue;
1773
+ for (const b of c) {
1774
+ if (!b || typeof b !== 'object')
1775
+ continue;
1776
+ const type = b.type;
1777
+ if (type === 'text') {
1778
+ const tb = b;
1779
+ if (typeof tb.text === 'string')
1780
+ n += tb.text.length;
1781
+ continue;
1782
+ }
1783
+ if (type === 'tool_use') {
1784
+ const tu = b;
1785
+ if (typeof tu.name === 'string')
1786
+ n += tu.name.length;
1787
+ if (tu.input !== undefined)
1788
+ n += safeStringifyLen(tu.input);
1789
+ continue;
1790
+ }
1791
+ if (type === 'tool_result') {
1792
+ const tr = b;
1793
+ if (typeof tr.tool_use_id === 'string')
1794
+ n += tr.tool_use_id.length;
1795
+ const inner = tr.content;
1796
+ if (typeof inner === 'string') {
1797
+ n += inner.length;
1798
+ }
1799
+ else if (Array.isArray(inner)) {
1800
+ for (const ib of inner) {
1801
+ if (ib && ib.type === 'text' && typeof ib.text === 'string') {
1802
+ n += ib.text.length;
1803
+ }
1804
+ }
1805
+ }
1806
+ continue;
1807
+ }
1808
+ if (type === 'thinking') {
1809
+ const th = b;
1810
+ if (typeof th.thinking === 'string')
1811
+ n += th.thinking.length;
1812
+ continue;
1813
+ }
1814
+ // image, redacted_thinking, server_tool_use, etc. — skip.
1815
+ }
1816
+ }
1817
+ return n;
1818
+ }
1819
+ /** JSON.stringify length, tolerant of cycles. Returns 0 on error. */
1820
+ function safeStringifyLen(v) {
1821
+ try {
1822
+ return JSON.stringify(v)?.length ?? 0;
1823
+ }
1824
+ catch {
1825
+ return 0;
1826
+ }
1827
+ }
1828
+ //# sourceMappingURL=transform.js.map