claude-code-cache-fix 3.2.1 → 3.4.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.
@@ -1,5 +1,26 @@
1
+ // image-strip extension — back-compat for v3.2.1 KEEP_LAST/MAX_DIM behavior
2
+ // PLUS the v3.3.0 image-guard pipeline.
3
+ //
4
+ // Activation pattern: `enabled: true` in extensions.json (the extension is
5
+ // always loaded), runtime gates per-feature via env vars. Three independent
6
+ // trigger surfaces:
7
+ //
8
+ // - CACHE_FIX_IMAGE_KEEP_LAST=N → legacy Pass 0 (count cap on
9
+ // tool_result images in user msgs)
10
+ // - CACHE_FIX_IMAGE_MAX_DIM=N → legacy Pass 1 strip-only (back-compat)
11
+ // - CACHE_FIX_IMAGE_GUARD=1 → v3.3.0 pipeline (Pass 1 + 2 + count cap)
12
+ // - CACHE_FIX_IMAGE_GUARD=1 +
13
+ // CACHE_FIX_IMAGE_PRESERVE_DETAIL=1 → adds Pass 3 (Lanczos resize via sharp)
14
+ //
15
+ // Execution order: Pass 0 → Pass 3 → Pass 1 → Pass 2 → image-count cap.
16
+ //
17
+ // See `docs/directives/proxy-image-guard-pipeline.md` for full spec, including
18
+ // the precedence matrix and per-pass trigger/action contracts.
19
+
1
20
  import { parseImageDimensions } from "../image-dimensions.mjs";
21
+ import { resizeImageToCap } from "../image-resize.mjs";
2
22
 
23
+ // --- Legacy v3.2.1 constants (back-compat) ---
3
24
  const KEEP_LAST = parseInt(process.env.CACHE_FIX_IMAGE_KEEP_LAST || "0", 10);
4
25
  const MAX_DIM = parseInt(process.env.CACHE_FIX_IMAGE_MAX_DIM || "0", 10);
5
26
  const PLACEHOLDER = "[image stripped from history — file may still be on disk]";
@@ -8,6 +29,37 @@ function oversizedPlaceholder(maxDim, w, h) {
8
29
  return `[image stripped — exceeded ${maxDim}px max dimension (was ${w}x${h}px)]`;
9
30
  }
10
31
 
32
+ // --- v3.3.0 pipeline env helpers (read at call time, not at module load,
33
+ // so per-test isolation works without re-importing the module) ---
34
+ function isImageGuardEnabled() {
35
+ return process.env.CACHE_FIX_IMAGE_GUARD === "1";
36
+ }
37
+ function isPreserveDetailEnabled() {
38
+ return process.env.CACHE_FIX_IMAGE_PRESERVE_DETAIL === "1";
39
+ }
40
+ function getMaxDim() {
41
+ return parseInt(process.env.CACHE_FIX_IMAGE_MAX_DIM || "0", 10);
42
+ }
43
+ function getKeepLast() {
44
+ return parseInt(process.env.CACHE_FIX_IMAGE_KEEP_LAST || "0", 10);
45
+ }
46
+ function getRequestSizeMax() {
47
+ // Default 30 MB (31457280 bytes). 2 MB headroom from Anthropic's 32 MB body limit.
48
+ const v = parseInt(process.env.CACHE_FIX_IMAGE_REQUEST_SIZE_MAX || "31457280", 10);
49
+ return v > 0 ? v : 31457280;
50
+ }
51
+ function isDebug() {
52
+ return process.env.CACHE_FIX_DEBUG === "1";
53
+ }
54
+ function getImageCountMax() {
55
+ // Default 100 — single cap covering the only model family in active CC use.
56
+ // Users on legacy Claude 1/2.x/Instant who genuinely need 600 can override.
57
+ const v = parseInt(process.env.CACHE_FIX_IMAGE_COUNT_MAX || "100", 10);
58
+ return v > 0 ? v : 100;
59
+ }
60
+
61
+ // --- Legacy Pass 0: KEEP_LAST tool_result image strip ---
62
+ // (Unchanged from v3.2.1 — only formatting tightened.)
11
63
  function stripOldToolResultImages(messages, keepLast) {
12
64
  if (!keepLast || keepLast <= 0 || !Array.isArray(messages)) {
13
65
  return { messages, stats: null };
@@ -65,15 +117,9 @@ function stripOldToolResultImages(messages, keepLast) {
65
117
  return { messages: strippedCount > 0 ? result : messages, stats };
66
118
  }
67
119
 
68
- // Strip oversized images from BOTH user-message direct content and
69
- // tool_result-nested content. Orthogonal to KEEP_LAST: scans every image
70
- // remaining in the message list and replaces any whose width or height
71
- // exceeds maxDim. Fail-open: images we can't measure (unsupported format,
72
- // truncated header) are kept rather than stripped.
73
- //
74
- // Stripping by oversize prevents the Anthropic API error:
75
- // "An image in the conversation exceeds the dimension limit for many-image
76
- // requests (2000px). Start a new session with fewer images."
120
+ // --- Legacy Pass 1 (strip-only by max dim) ---
121
+ // (Unchanged from v3.2.1.) The new pipeline's Pass 1 is a separate function
122
+ // (`runPass1RejectionCapStrip`) that uses the conditional 2000/8000 logic.
77
123
  function stripOversizedImages(messages, maxDim) {
78
124
  if (!maxDim || maxDim <= 0 || !Array.isArray(messages)) {
79
125
  return { messages, stats: null };
@@ -87,7 +133,7 @@ function stripOversizedImages(messages, maxDim) {
87
133
  const src = item.source;
88
134
  if (!src || !src.data || !src.media_type) return item;
89
135
  const dims = parseImageDimensions(src.media_type, src.data);
90
- if (!dims) return item; // can't measure → keep
136
+ if (!dims) return item;
91
137
  if (dims.width <= maxDim && dims.height <= maxDim) return item;
92
138
  strippedCount++;
93
139
  strippedBytes += src.data.length;
@@ -98,7 +144,6 @@ function stripOversizedImages(messages, maxDim) {
98
144
  if (!Array.isArray(msg.content)) return msg;
99
145
  let mutated = false;
100
146
  const newContent = msg.content.map((block) => {
101
- // Direct image block on a user message
102
147
  if (block && block.type === "image") {
103
148
  const replaced = maybeStrip(block);
104
149
  if (replaced !== block) {
@@ -107,7 +152,6 @@ function stripOversizedImages(messages, maxDim) {
107
152
  }
108
153
  return block;
109
154
  }
110
- // Image nested inside a tool_result.content array
111
155
  if (block && block.type === "tool_result" && Array.isArray(block.content)) {
112
156
  let toolMutated = false;
113
157
  const newToolContent = block.content.map((item) => {
@@ -132,49 +176,532 @@ function stripOversizedImages(messages, maxDim) {
132
176
  return { messages: strippedCount > 0 ? result : messages, stats };
133
177
  }
134
178
 
135
- export { stripOldToolResultImages, stripOversizedImages, PLACEHOLDER, oversizedPlaceholder };
179
+ // =============================================================================
180
+ // v3.3.0 pipeline
181
+ // =============================================================================
182
+
183
+ // --- Pure helpers (exported for tests) ---
184
+
185
+ // Pass 1 cap selector. `maxDimOverride > 0` always wins over the conditional
186
+ // rejection cap. Otherwise: count > 20 → 2000 px, else 8000 px.
187
+ function pickPass1Cap(imageCount, maxDimOverride) {
188
+ if (maxDimOverride && maxDimOverride > 0) return maxDimOverride;
189
+ return imageCount > 20 ? 2000 : 8000;
190
+ }
191
+
192
+ // Pass 3 native-cap selector. Only Opus 4.7 has the higher cap.
193
+ function pickPass3NativeCap(modelString) {
194
+ if (typeof modelString === "string" && modelString.startsWith("claude-opus-4-7")) {
195
+ return 2576;
196
+ }
197
+ return 1568;
198
+ }
199
+
200
+ // Anthropic's documented per-image token formula: `width * height / 750`,
201
+ // capped at the model's native token cap. Diagnostic only (no enforcement).
202
+ function estimateImageTokens(width, height, modelTokenCap) {
203
+ if (!width || !height) return 0;
204
+ const raw = Math.ceil((width * height) / 750);
205
+ if (modelTokenCap && raw > modelTokenCap) return modelTokenCap;
206
+ return raw;
207
+ }
208
+
209
+ function nativeTokenCap(modelString) {
210
+ if (typeof modelString === "string" && modelString.startsWith("claude-opus-4-7")) {
211
+ return 4784;
212
+ }
213
+ return 1568;
214
+ }
215
+
216
+ // Walker: enumerate every image in `body.messages`, both user-msg direct content
217
+ // and tool_result.content. Returns a list of `{ msgIdx, blockIdx, itemIdx | null,
218
+ // item }` references. `itemIdx === null` means the image is a direct user-msg
219
+ // content block (not nested in tool_result).
220
+ function walkImages(messages) {
221
+ const out = [];
222
+ if (!Array.isArray(messages)) return out;
223
+ for (let m = 0; m < messages.length; m++) {
224
+ const msg = messages[m];
225
+ if (!Array.isArray(msg.content)) continue;
226
+ for (let b = 0; b < msg.content.length; b++) {
227
+ const block = msg.content[b];
228
+ if (!block) continue;
229
+ if (block.type === "image") {
230
+ out.push({ msgIdx: m, blockIdx: b, itemIdx: null, item: block });
231
+ } else if (block.type === "tool_result" && Array.isArray(block.content)) {
232
+ for (let i = 0; i < block.content.length; i++) {
233
+ const item = block.content[i];
234
+ if (item && item.type === "image") {
235
+ out.push({ msgIdx: m, blockIdx: b, itemIdx: i, item });
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ // Mutate a single image at the given (msgIdx, blockIdx, itemIdx) reference,
245
+ // either replacing it with a placeholder text block (strip) or updating its
246
+ // source.data + dims (resize).
247
+ function replaceImageInPlace(messages, ref, replacement) {
248
+ const msg = messages[ref.msgIdx];
249
+ if (!msg || !Array.isArray(msg.content)) return;
250
+ const block = msg.content[ref.blockIdx];
251
+ if (!block) return;
252
+ if (ref.itemIdx === null) {
253
+ msg.content[ref.blockIdx] = replacement;
254
+ } else {
255
+ if (!Array.isArray(block.content)) return;
256
+ block.content[ref.itemIdx] = replacement;
257
+ }
258
+ }
259
+
260
+ // Pure walker for Pass 1: returns the list of image refs whose long edge
261
+ // exceeds `capPx`, alongside their measured dims. Unparseable images are not
262
+ // included (fail-open). Exposed for tests.
263
+ function walkImagesForPass1(messages, capPx) {
264
+ const refs = walkImages(messages);
265
+ const plan = [];
266
+ for (const ref of refs) {
267
+ const src = ref.item.source;
268
+ if (!src || !src.data || !src.media_type) continue;
269
+ const dims = parseImageDimensions(src.media_type, src.data);
270
+ if (!dims) {
271
+ plan.push({ ref, dims: null, action: "skip_unmeasurable" });
272
+ continue;
273
+ }
274
+ const longEdge = Math.max(dims.width, dims.height);
275
+ if (longEdge > capPx) {
276
+ plan.push({ ref, dims, action: "strip" });
277
+ }
278
+ }
279
+ return plan;
280
+ }
281
+
282
+ // Pure walker for Pass 3: returns refs whose long edge exceeds `nativeCapPx`,
283
+ // plus the dims/cap so the caller can do the actual resize.
284
+ function walkImagesForPass3(messages, nativeCapPx) {
285
+ const refs = walkImages(messages);
286
+ const plan = [];
287
+ for (const ref of refs) {
288
+ const src = ref.item.source;
289
+ if (!src || !src.data || !src.media_type) continue;
290
+ const dims = parseImageDimensions(src.media_type, src.data);
291
+ if (!dims) {
292
+ plan.push({ ref, dims: null, action: "skip_unmeasurable" });
293
+ continue;
294
+ }
295
+ const longEdge = Math.max(dims.width, dims.height);
296
+ if (longEdge > nativeCapPx) {
297
+ plan.push({ ref, dims, action: "resize", capPx: nativeCapPx });
298
+ }
299
+ }
300
+ return plan;
301
+ }
302
+
303
+ // Eviction order for Pass 2 / count cap: oldest first (low msgIdx wins),
304
+ // within a message tool_result images are preferred over direct images at the
305
+ // same age. Returns an ordered list of refs to drop.
306
+ function pickEvictionTargets(messages) {
307
+ const refs = walkImages(messages);
308
+ // Stable sort: by msgIdx ascending, then prefer tool_result (itemIdx !== null)
309
+ // over direct (itemIdx === null) at the same msgIdx.
310
+ refs.sort((a, b) => {
311
+ if (a.msgIdx !== b.msgIdx) return a.msgIdx - b.msgIdx;
312
+ const aTool = a.itemIdx !== null ? 0 : 1;
313
+ const bTool = b.itemIdx !== null ? 0 : 1;
314
+ if (aTool !== bTool) return aTool - bTool;
315
+ if (a.blockIdx !== b.blockIdx) return a.blockIdx - b.blockIdx;
316
+ return (a.itemIdx ?? 0) - (b.itemIdx ?? 0);
317
+ });
318
+ return refs;
319
+ }
320
+
321
+ // --- Stats initializer (matches the directive's telemetry surface verbatim) ---
322
+ function initStats() {
323
+ return {
324
+ total_images: 0,
325
+ count_axis_path: "few",
326
+ unsupported_format_count: 0,
327
+ dimension_probe_fail_count: 0,
328
+ resize_attempted: 0,
329
+ resize_succeeded: 0,
330
+ resize_failed: 0,
331
+ library_missing: false,
332
+ images_stripped_pass1: 0,
333
+ images_dropped_for_size: 0,
334
+ images_dropped_for_count_cap: 0,
335
+
336
+ request_bytes_before: 0,
337
+ request_bytes_after: 0,
338
+ request_bytes_headroom: 0,
339
+ image_bytes_total: 0,
340
+ image_bytes_dropped: 0,
341
+
342
+ estimated_image_tokens_total: 0,
343
+ };
344
+ }
345
+
346
+ // --- Pass 3 runtime: native-cap resize via sharp ---
347
+ async function runPass3NativeCapResize(reqCtx, stats) {
348
+ if (stats.library_missing) return;
349
+
350
+ const messages = reqCtx.body.messages;
351
+ const model = reqCtx.body.model;
352
+ const nativeCap = pickPass3NativeCap(model);
353
+ const tokenCap = nativeTokenCap(model);
354
+
355
+ const plan = walkImagesForPass3(messages, nativeCap);
356
+ for (const step of plan) {
357
+ if (step.action === "skip_unmeasurable") {
358
+ // Tracked separately via the unsupported/probe counters at the top-level
359
+ // walker (we double-count if we touch them here too — leave it to the
360
+ // central counter pass).
361
+ continue;
362
+ }
363
+ if (step.action !== "resize") continue;
364
+
365
+ const src = step.ref.item.source;
366
+ stats.resize_attempted++;
367
+ const result = await resizeImageToCap(src.data, src.media_type, step.capPx);
368
+ if (result.ok) {
369
+ stats.resize_succeeded++;
370
+ // Mutate in place: keep the same content block shape, swap data + record dims.
371
+ const newImage = {
372
+ ...step.ref.item,
373
+ source: { ...src, data: result.base64 },
374
+ };
375
+ replaceImageInPlace(messages, step.ref, newImage);
376
+ const tokensBefore = estimateImageTokens(step.dims.width, step.dims.height, tokenCap);
377
+ const tokensAfter = estimateImageTokens(result.dims.width, result.dims.height, tokenCap);
378
+ stats.estimated_image_tokens_total += tokensAfter - tokensBefore;
379
+ } else if (result.reason === "library_missing") {
380
+ stats.library_missing = true;
381
+ // Sticky: stop attempting Pass 3 for the remainder of this request.
382
+ // (loadSharp() inside image-resize.mjs is also sticky for the process.)
383
+ return;
384
+ } else {
385
+ stats.resize_failed++;
386
+ // Leave image untouched; Pass 1 will evaluate it against its own cap.
387
+ }
388
+ }
389
+ }
390
+
391
+ // --- Pass 1 runtime: conditional rejection-cap strip ---
392
+ function runPass1RejectionCapStrip(reqCtx, stats, opts) {
393
+ const { maxDimOverride } = opts || {};
394
+ const messages = reqCtx.body.messages;
395
+
396
+ const refs = walkImages(messages);
397
+ const imageCount = refs.length;
398
+ stats.total_images = Math.max(stats.total_images, imageCount);
399
+ stats.count_axis_path = imageCount > 20 ? "many" : "few";
400
+ const cap = pickPass1Cap(imageCount, maxDimOverride);
401
+
402
+ for (const ref of refs) {
403
+ const src = ref.item.source;
404
+ if (!src || !src.data || !src.media_type) continue;
405
+ const dims = parseImageDimensions(src.media_type, src.data);
406
+ if (!dims) {
407
+ // Distinguish unsupported format from probe failure on a known type.
408
+ const mt = (src.media_type || "").toLowerCase();
409
+ if (mt === "image/png" || mt === "image/jpeg" || mt === "image/jpg") {
410
+ stats.dimension_probe_fail_count++;
411
+ } else {
412
+ stats.unsupported_format_count++;
413
+ }
414
+ continue;
415
+ }
416
+ const longEdge = Math.max(dims.width, dims.height);
417
+ if (longEdge > cap) {
418
+ replaceImageInPlace(messages, ref, {
419
+ type: "text",
420
+ text: oversizedPlaceholder(cap, dims.width, dims.height),
421
+ });
422
+ stats.images_stripped_pass1++;
423
+ }
424
+ }
425
+ }
426
+
427
+ // --- Pass 2 runtime: request-size guard ---
428
+ function runPass2RequestSizeGuard(reqCtx, stats) {
429
+ const budget = getRequestSizeMax();
430
+ const before = Buffer.byteLength(JSON.stringify(reqCtx.body));
431
+ if (stats.request_bytes_before === 0) stats.request_bytes_before = before;
432
+
433
+ if (before <= budget) {
434
+ stats.request_bytes_after = before;
435
+ stats.request_bytes_headroom = budget - before;
436
+ return;
437
+ }
438
+
439
+ // Build eviction queue once; drop one at a time, re-measuring after each drop.
440
+ const queue = pickEvictionTargets(reqCtx.body.messages);
441
+ let bytes = before;
442
+ for (const ref of queue) {
443
+ if (bytes <= budget) break;
444
+ const src = ref.item.source;
445
+ const droppedBytes = src && src.data ? src.data.length : 0;
446
+ replaceImageInPlace(reqCtx.body.messages, ref, {
447
+ type: "text",
448
+ text: "[image dropped to fit request-size budget]",
449
+ });
450
+ stats.images_dropped_for_size++;
451
+ stats.image_bytes_dropped += droppedBytes;
452
+ bytes = Buffer.byteLength(JSON.stringify(reqCtx.body));
453
+ }
454
+ stats.request_bytes_after = bytes;
455
+ stats.request_bytes_headroom = budget - bytes;
456
+ // If we exhausted the queue and bytes still exceed budget, the body is
457
+ // over-budget for non-image reasons; the request will fail upstream and we
458
+ // don't address that here. Telemetry already records the final bytes.
459
+ }
460
+
461
+ // --- Hard image-count cap ---
462
+ function runImageCountCap(reqCtx, stats) {
463
+ const cap = getImageCountMax();
464
+ const queue = pickEvictionTargets(reqCtx.body.messages);
465
+ if (queue.length <= cap) return;
466
+ const toDrop = queue.length - cap;
467
+ for (let i = 0; i < toDrop; i++) {
468
+ const ref = queue[i];
469
+ const src = ref.item.source;
470
+ const droppedBytes = src && src.data ? src.data.length : 0;
471
+ replaceImageInPlace(reqCtx.body.messages, ref, {
472
+ type: "text",
473
+ text: "[image dropped — exceeded image-count cap]",
474
+ });
475
+ stats.images_dropped_for_count_cap++;
476
+ stats.image_bytes_dropped += droppedBytes;
477
+ }
478
+ // Recompute request_bytes_after after count-cap evictions so the final
479
+ // telemetry reflects the post-pipeline body. Without this, count-cap-only
480
+ // requests would report unchanged byte totals (Codex review note).
481
+ if (toDrop > 0) {
482
+ const budget = getRequestSizeMax();
483
+ const after = Buffer.byteLength(JSON.stringify(reqCtx.body));
484
+ stats.request_bytes_after = after;
485
+ stats.request_bytes_headroom = budget - after;
486
+ }
487
+ }
488
+
489
+ // --- Telemetry: walk surviving images for byte/token totals ---
490
+ function finalizeTelemetry(reqCtx, stats) {
491
+ const refs = walkImages(reqCtx.body.messages);
492
+ let totalBytes = 0;
493
+ let totalTokens = 0;
494
+ const tokenCap = nativeTokenCap(reqCtx.body.model);
495
+ for (const ref of refs) {
496
+ const src = ref.item.source;
497
+ if (!src || !src.data) continue;
498
+ totalBytes += src.data.length;
499
+ const dims = parseImageDimensions(src.media_type, src.data);
500
+ if (dims) {
501
+ totalTokens += estimateImageTokens(dims.width, dims.height, tokenCap);
502
+ }
503
+ }
504
+ stats.image_bytes_total = totalBytes;
505
+ // estimated_image_tokens_total was decremented by Pass 3 deltas; for the
506
+ // baseline (Pass 3 disabled) recompute from surviving images.
507
+ if (stats.resize_attempted === 0) {
508
+ stats.estimated_image_tokens_total = totalTokens;
509
+ } else {
510
+ // Pass 3 mutated in place; re-measure surviving population to ground-truth.
511
+ stats.estimated_image_tokens_total = totalTokens;
512
+ }
513
+ }
514
+
515
+ // --- Top-level pipeline orchestrator ---
516
+ async function runImageGuard(reqCtx) {
517
+ const stats = initStats();
518
+ const messages = reqCtx.body.messages;
519
+ if (!Array.isArray(messages)) return stats;
520
+
521
+ // Capture initial population count for the summary line.
522
+ stats.total_images = walkImages(messages).length;
523
+ stats.count_axis_path = stats.total_images > 20 ? "many" : "few";
524
+
525
+ const guardOn = isImageGuardEnabled();
526
+ const preserveOn = isPreserveDetailEnabled();
527
+ const maxDimOverride = getMaxDim();
528
+
529
+ // Warn if PRESERVE_DETAIL is set without IMAGE_GUARD (one-time per process).
530
+ if (!guardOn && preserveOn && !_preserveDetailWarned) {
531
+ process.stderr.write(
532
+ "[image-guard] CACHE_FIX_IMAGE_PRESERVE_DETAIL=1 has no effect without CACHE_FIX_IMAGE_GUARD=1\n"
533
+ );
534
+ _preserveDetailWarned = true;
535
+ }
536
+
537
+ // Pass 3: native-cap resize (only when both gates are on)
538
+ if (guardOn && preserveOn) {
539
+ await runPass3NativeCapResize(reqCtx, stats);
540
+ }
541
+
542
+ // Pass 1: rejection-cap strip — runs if IMAGE_GUARD=1 OR legacy MAX_DIM > 0
543
+ if (guardOn || maxDimOverride > 0) {
544
+ runPass1RejectionCapStrip(reqCtx, stats, { maxDimOverride });
545
+ }
546
+
547
+ // Pass 2: request-size guard — IMAGE_GUARD only
548
+ if (guardOn) {
549
+ runPass2RequestSizeGuard(reqCtx, stats);
550
+ }
551
+
552
+ // Hard image-count cap — IMAGE_GUARD only
553
+ if (guardOn) {
554
+ runImageCountCap(reqCtx, stats);
555
+ }
556
+
557
+ // Final telemetry sweep over surviving images
558
+ finalizeTelemetry(reqCtx, stats);
559
+
560
+ return stats;
561
+ }
562
+
563
+ // One-time warning state for PRESERVE_DETAIL-without-GUARD.
564
+ let _preserveDetailWarned = false;
565
+
566
+ // Test hook to reset warning flag.
567
+ function _resetWarningStateForTests() {
568
+ _preserveDetailWarned = false;
569
+ }
570
+
571
+ export {
572
+ // Legacy v3.2.1 exports (kept stable for back-compat tests)
573
+ stripOldToolResultImages,
574
+ stripOversizedImages,
575
+ PLACEHOLDER,
576
+ oversizedPlaceholder,
577
+ // v3.3.0 pipeline pure functions (test seams)
578
+ pickPass1Cap,
579
+ pickPass3NativeCap,
580
+ estimateImageTokens,
581
+ walkImagesForPass1,
582
+ walkImagesForPass3,
583
+ pickEvictionTargets,
584
+ // Orchestrator (used by the extension default export and direct test calls)
585
+ runImageGuard,
586
+ // Test utilities
587
+ _resetWarningStateForTests,
588
+ };
136
589
 
137
590
  export default {
138
591
  name: "image-strip",
139
592
  description:
140
- "Strip base64 images from old tool results AND optionally strip oversized images that would trigger Anthropic's many-image dimension limit",
141
- enabled: false,
593
+ "v3.2.1 KEEP_LAST/MAX_DIM legacy paths PLUS v3.3.0 image-guard pipeline " +
594
+ "(conditional rejection cap + request-size guard + optional Lanczos resize)",
595
+ enabled: false, // overridden by extensions.json
142
596
  order: 150,
143
597
 
144
598
  async onRequest(ctx) {
145
- const keepLast = parseInt(ctx.meta.imageKeepLast ?? KEEP_LAST, 10);
146
- const maxDim = parseInt(ctx.meta.imageMaxDim ?? MAX_DIM, 10);
147
- if ((!keepLast || keepLast <= 0) && (!maxDim || maxDim <= 0)) return;
148
- if (!ctx.body.messages) return;
599
+ const guardOn = isImageGuardEnabled();
600
+ const preserveOn = isPreserveDetailEnabled();
601
+ // ctx.meta overrides allow tests to drive the legacy paths without env vars.
602
+ // Pipeline gates (IMAGE_GUARD, PRESERVE_DETAIL) remain env-only — tests that
603
+ // need to flip them set process.env directly.
604
+ const keepLast = parseInt(ctx.meta?.imageKeepLast ?? getKeepLast(), 10);
605
+ const maxDim = parseInt(ctx.meta?.imageMaxDim ?? getMaxDim(), 10);
149
606
 
150
- let messages = ctx.body.messages;
151
- const logParts = [];
607
+ // Short-circuit: nothing to do.
608
+ if (!guardOn && keepLast <= 0 && maxDim <= 0) {
609
+ // Surface the PRESERVE_DETAIL-without-GUARD warning even when the
610
+ // pipeline doesn't run otherwise.
611
+ if (preserveOn && !_preserveDetailWarned) {
612
+ process.stderr.write(
613
+ "[image-guard] CACHE_FIX_IMAGE_PRESERVE_DETAIL=1 has no effect without CACHE_FIX_IMAGE_GUARD=1\n"
614
+ );
615
+ _preserveDetailWarned = true;
616
+ }
617
+ return;
618
+ }
152
619
 
153
- // Pass 1: existing keep_last behavior. Sets ctx.meta.imageStripStats with
154
- // the same shape as before this PR — back-compat preserved.
155
- if (keepLast > 0) {
156
- const r = stripOldToolResultImages(messages, keepLast);
157
- if (r.stats) {
158
- messages = r.messages;
159
- ctx.meta.imageStripStats = r.stats;
160
- logParts.push(`keep_last: ${r.stats.strippedCount} stripped (~${r.stats.estimatedTokens} tokens saved)`);
620
+ if (!ctx.body || !ctx.body.messages) return;
621
+
622
+ // ========== Legacy path (v3.2.1 back-compat) ==========
623
+ // When IMAGE_GUARD=1 is OFF but legacy env vars are set, run the v3.2.1
624
+ // pipeline exactly as before — preserves bug-for-bug compatibility for
625
+ // existing users.
626
+ if (!guardOn) {
627
+ let messages = ctx.body.messages;
628
+ const logParts = [];
629
+
630
+ if (keepLast > 0) {
631
+ const r = stripOldToolResultImages(messages, keepLast);
632
+ if (r.stats) {
633
+ messages = r.messages;
634
+ ctx.meta.imageStripStats = r.stats;
635
+ logParts.push(
636
+ `keep_last: ${r.stats.strippedCount} stripped (~${r.stats.estimatedTokens} tokens saved)`
637
+ );
638
+ }
639
+ }
640
+
641
+ if (maxDim > 0) {
642
+ const r = stripOversizedImages(messages, maxDim);
643
+ if (r.stats) {
644
+ messages = r.messages;
645
+ ctx.meta.imageStripOversizedStats = r.stats;
646
+ logParts.push(
647
+ `max_dim: ${r.stats.strippedCount} oversized stripped ` +
648
+ `(~${r.stats.estimatedTokens} tokens saved)`
649
+ );
650
+ }
161
651
  }
652
+
653
+ if (logParts.length > 0) {
654
+ ctx.body.messages = messages;
655
+ if (isDebug()) {
656
+ process.stderr.write(`[image-strip] ${logParts.join("; ")}\n`);
657
+ }
658
+ }
659
+ return;
162
660
  }
163
661
 
164
- // Pass 2: new max_dim behavior. Stats land on a new field so consumers
165
- // already reading imageStripStats don't see a shape change.
166
- if (maxDim > 0) {
167
- const r = stripOversizedImages(messages, maxDim);
662
+ // ========== v3.3.0 pipeline path ==========
663
+ // KEEP_LAST runs first as Pass 0 (back-compat behavior preserved).
664
+ if (keepLast > 0) {
665
+ const r = stripOldToolResultImages(ctx.body.messages, keepLast);
168
666
  if (r.stats) {
169
- messages = r.messages;
170
- ctx.meta.imageStripOversizedStats = r.stats;
171
- logParts.push(`max_dim: ${r.stats.strippedCount} oversized stripped (~${r.stats.estimatedTokens} tokens saved)`);
667
+ ctx.body.messages = r.messages;
668
+ ctx.meta.imageStripStats = r.stats;
172
669
  }
173
670
  }
174
671
 
175
- if (logParts.length > 0) {
176
- ctx.body.messages = messages;
177
- process.stderr.write(`[image-strip] ${logParts.join("; ")}\n`);
672
+ const stats = await runImageGuard(ctx);
673
+ ctx.meta.imageGuardStats = stats;
674
+
675
+ // Emit summary only if the pipeline actually did anything observable.
676
+ const didSomething =
677
+ stats.images_stripped_pass1 > 0 ||
678
+ stats.images_dropped_for_size > 0 ||
679
+ stats.images_dropped_for_count_cap > 0 ||
680
+ stats.resize_attempted > 0 ||
681
+ stats.resize_succeeded > 0 ||
682
+ stats.unsupported_format_count > 0 ||
683
+ stats.dimension_probe_fail_count > 0;
684
+ if (didSomething && isDebug()) {
685
+ const parts = [];
686
+ if (stats.resize_succeeded > 0) parts.push(`resized=${stats.resize_succeeded}`);
687
+ if (stats.resize_failed > 0) parts.push(`resize_failed=${stats.resize_failed}`);
688
+ if (stats.library_missing) parts.push("sharp=missing");
689
+ if (stats.images_stripped_pass1 > 0) parts.push(`stripped=${stats.images_stripped_pass1}`);
690
+ if (stats.images_dropped_for_size > 0) parts.push(`evicted=${stats.images_dropped_for_size}`);
691
+ if (stats.images_dropped_for_count_cap > 0) {
692
+ parts.push(`count_capped=${stats.images_dropped_for_count_cap}`);
693
+ }
694
+ if (stats.unsupported_format_count > 0) parts.push(`unsupported=${stats.unsupported_format_count}`);
695
+ const summary = parts.join(" ") || "ran";
696
+ const finalImages = stats.total_images
697
+ - stats.images_stripped_pass1
698
+ - stats.images_dropped_for_size
699
+ - stats.images_dropped_for_count_cap;
700
+ process.stderr.write(
701
+ `[image-guard] ${summary} req_bytes=${stats.request_bytes_before}->${stats.request_bytes_after} ` +
702
+ `(headroom=${stats.request_bytes_headroom}) images=${stats.total_images}->${finalImages}\n`
703
+ );
178
704
  }
179
705
  },
180
706
  };
707
+