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.
@@ -0,0 +1,428 @@
1
+ // microcompact-stability — detect, optionally dump, and optionally normalize
2
+ // CC's `time_based_microcompact` sentinel string in tool_result content.
3
+ //
4
+ // Order 350: between `tool-input-normalize` (340) and `cache-control-normalize`
5
+ // (400). Runs BEFORE cache-control-normalize so the latter sees post-normalized
6
+ // content when computing sticky-marker hashes.
7
+ //
8
+ // Two independent runtime gates:
9
+ // - CACHE_FIX_DUMP_MICROCOMPACT=<path> → diagnostic JSONL dump (read-only).
10
+ // - CACHE_FIX_NORMALIZE_MICROCOMPACT=1 → mutate matched sentinels to a
11
+ // canonical byte-stable form.
12
+ //
13
+ // Two detection modes:
14
+ // - Mode A (exact match against confirmed patterns) → eligible for
15
+ // normalization. `sentinel_text` captured in full in dump records.
16
+ // - Mode B (prefix-only match) → diagnostic-only, NEVER normalized. Records
17
+ // redact to a configurable prefix length (default 64).
18
+ //
19
+ // The diagnostic dump always captures the **raw pre-normalization** bytes —
20
+ // this is the rule. Setting CACHE_FIX_DUMP_MICROCOMPACT_INCLUDE_NORMALIZED=1
21
+ // additionally records the post-normalized form alongside the raw text.
22
+ //
23
+ // See `docs/directives/proxy-microcompact-cache-stability.md` for the full
24
+ // design (Mode A/B contract, privacy guarantees, Phase 2 deferral).
25
+
26
+ import { appendFile, mkdir } from "node:fs/promises";
27
+ import { dirname } from "node:path";
28
+ import { createHash } from "node:crypto";
29
+
30
+ // --- Env gates (read per-call so tests can flip without re-importing) ---
31
+
32
+ function getDumpPath() {
33
+ const v = process.env.CACHE_FIX_DUMP_MICROCOMPACT;
34
+ return v && v.length > 0 ? v : null;
35
+ }
36
+ function isNormalizeEnabled() {
37
+ return process.env.CACHE_FIX_NORMALIZE_MICROCOMPACT === "1";
38
+ }
39
+ function isIncludeNormalizedEnabled() {
40
+ return process.env.CACHE_FIX_DUMP_MICROCOMPACT_INCLUDE_NORMALIZED === "1";
41
+ }
42
+ function getCanonicalText() {
43
+ const v = process.env.CACHE_FIX_MICROCOMPACT_NORMALIZED;
44
+ return typeof v === "string" && v.length > 0 ? v : DEFAULT_CANONICAL_TEXT;
45
+ }
46
+ function getRedactLen() {
47
+ const v = process.env.CACHE_FIX_MICROCOMPACT_REDACT_LEN;
48
+ if (v === undefined || v === null || v === "") return DEFAULT_REDACT_LEN;
49
+ const n = parseInt(v, 10);
50
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_REDACT_LEN;
51
+ }
52
+ function getCustomPatterns() {
53
+ // CACHE_FIX_MICROCOMPACT_SENTINEL_PATTERN_<N>=<regex> (1-indexed, sparse OK)
54
+ const out = [];
55
+ for (const [k, v] of Object.entries(process.env)) {
56
+ if (!k.startsWith("CACHE_FIX_MICROCOMPACT_SENTINEL_PATTERN_")) continue;
57
+ if (typeof v !== "string" || v.length === 0) continue;
58
+ try {
59
+ out.push({ source: v, re: new RegExp(v) });
60
+ } catch {
61
+ process.stderr.write(`[microcompact] invalid regex in ${k}: ${v}\n`);
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // Custom Mode B literal prefixes, paired with custom Mode A regex patterns.
68
+ // A user who configures CACHE_FIX_MICROCOMPACT_SENTINEL_PATTERN_<N> for a
69
+ // non-default sentinel family should also set CACHE_FIX_MICROCOMPACT_SENTINEL_PREFIX_<N>
70
+ // to the LITERAL string the family begins with — that's what enables Mode B
71
+ // (redacted prefix capture) for variants that don't exact-match the regex.
72
+ //
73
+ // We can't safely derive a prefix from an arbitrary regex, so we accept the
74
+ // prefix as a separate input. The two env-var families don't have to agree
75
+ // on numeric suffixes; we collect all prefixes regardless of index.
76
+ function getCustomPrefixes() {
77
+ // CACHE_FIX_MICROCOMPACT_SENTINEL_PREFIX_<N>=<literal> (1-indexed, sparse OK)
78
+ const out = [];
79
+ for (const [k, v] of Object.entries(process.env)) {
80
+ if (!k.startsWith("CACHE_FIX_MICROCOMPACT_SENTINEL_PREFIX_")) continue;
81
+ if (typeof v !== "string" || v.length === 0) continue;
82
+ out.push(v);
83
+ }
84
+ return out;
85
+ }
86
+ function isDebug() {
87
+ return process.env.CACHE_FIX_DEBUG === "1";
88
+ }
89
+ function debug(msg) {
90
+ if (isDebug()) process.stderr.write(`[microcompact] DEBUG: ${msg}\n`);
91
+ }
92
+
93
+ // --- Constants ---
94
+
95
+ const DEFAULT_CANONICAL_TEXT = "[Old tool result content cleared]";
96
+ const DEFAULT_REDACT_LEN = 64;
97
+
98
+ // Default Mode A patterns (confirmed sentinel forms eligible for normalization).
99
+ // Adding a new exact form here promotes it from Mode B prefix capture to
100
+ // Mode A normalization-eligibility. Keep the list narrow.
101
+ const DEFAULT_EXACT_PATTERNS = [
102
+ {
103
+ source: "^\\[Old tool result content cleared\\]\\s*$",
104
+ re: /^\[Old tool result content cleared\]\s*$/,
105
+ },
106
+ {
107
+ source:
108
+ "^\\[Old tool result content cleared at \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z\\]\\s*$",
109
+ re: /^\[Old tool result content cleared at \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z\]\s*$/,
110
+ },
111
+ ];
112
+
113
+ // Mode B prefix — anything beginning with this is a candidate for redacted
114
+ // diagnostic capture, even if it doesn't match an exact pattern.
115
+ const SENTINEL_PREFIX = "[Old tool result content cleared";
116
+
117
+ // --- Pattern matching (pure) ---
118
+
119
+ // Returns the source string of the first matching exact pattern, or null.
120
+ // `extraPatterns` are user-supplied patterns from env vars; they're appended
121
+ // to the defaults so a custom regex doesn't silently disable a default.
122
+ export function matchesSentinelPattern(text, extraPatterns = []) {
123
+ if (typeof text !== "string") return null;
124
+ const all = DEFAULT_EXACT_PATTERNS.concat(extraPatterns);
125
+ for (const p of all) {
126
+ if (p.re.test(text)) return p.source;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function isPartialMatch(text, extraPrefixes = []) {
132
+ if (typeof text !== "string") return false;
133
+ if (text.startsWith(SENTINEL_PREFIX)) return true;
134
+ for (const p of extraPrefixes) {
135
+ if (text.startsWith(p)) return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ // --- Walking tool_result content ---
141
+ //
142
+ // Returns { exact_matches, partial_matches, total_tool_results }.
143
+ //
144
+ // Match record shape (exact_matches[]):
145
+ // { msg_idx, block_idx, content_kind: "string"|"array_item",
146
+ // item_idx?, text, matched_pattern }
147
+ // Match record shape (partial_matches[]):
148
+ // { msg_idx, block_idx, content_kind: "string"|"array_item",
149
+ // item_idx?, text, byte_length }
150
+ //
151
+ // `text` on partial_matches is kept on the in-memory record for redaction at
152
+ // serialize time (the dump never persists the full text).
153
+
154
+ export function walkToolResultsForSentinels(messages, extraPatterns = [], extraPrefixes = []) {
155
+ const exact_matches = [];
156
+ const partial_matches = [];
157
+ let total_tool_results = 0;
158
+ if (!Array.isArray(messages)) {
159
+ return { exact_matches, partial_matches, total_tool_results };
160
+ }
161
+
162
+ for (let mi = 0; mi < messages.length; mi++) {
163
+ const msg = messages[mi];
164
+ if (!msg || !Array.isArray(msg.content)) continue;
165
+ for (let bi = 0; bi < msg.content.length; bi++) {
166
+ const block = msg.content[bi];
167
+ if (!block || block.type !== "tool_result") continue;
168
+ total_tool_results++;
169
+
170
+ const content = block.content;
171
+ if (typeof content === "string") {
172
+ classify(mi, bi, "string", undefined, content);
173
+ } else if (Array.isArray(content)) {
174
+ for (let ii = 0; ii < content.length; ii++) {
175
+ const item = content[ii];
176
+ if (!item || item.type !== "text" || typeof item.text !== "string") continue;
177
+ classify(mi, bi, "array_item", ii, item.text);
178
+ }
179
+ }
180
+ }
181
+ }
182
+ return { exact_matches, partial_matches, total_tool_results };
183
+
184
+ function classify(msg_idx, block_idx, content_kind, item_idx, text) {
185
+ const matched = matchesSentinelPattern(text, extraPatterns);
186
+ if (matched !== null) {
187
+ exact_matches.push({
188
+ msg_idx,
189
+ block_idx,
190
+ content_kind,
191
+ ...(item_idx !== undefined ? { item_idx } : {}),
192
+ text,
193
+ matched_pattern: matched,
194
+ });
195
+ return;
196
+ }
197
+ if (isPartialMatch(text, extraPrefixes)) {
198
+ partial_matches.push({
199
+ msg_idx,
200
+ block_idx,
201
+ content_kind,
202
+ ...(item_idx !== undefined ? { item_idx } : {}),
203
+ text,
204
+ byte_length: Buffer.byteLength(text, "utf8"),
205
+ });
206
+ }
207
+ }
208
+ }
209
+
210
+ // --- Normalization (mutates the message block in place) ---
211
+ //
212
+ // `match` is an entry from `exact_matches` (Mode A). We use its msg_idx /
213
+ // block_idx / content_kind / item_idx to find the exact place to rewrite.
214
+ // Mode B matches are NEVER passed to this function.
215
+
216
+ export function normalizeToolResultContent(messages, match, canonicalText) {
217
+ const block = messages?.[match.msg_idx]?.content?.[match.block_idx];
218
+ if (!block || block.type !== "tool_result") return false;
219
+ if (match.content_kind === "string") {
220
+ block.content = canonicalText;
221
+ return true;
222
+ }
223
+ if (match.content_kind === "array_item" && Array.isArray(block.content)) {
224
+ const item = block.content[match.item_idx];
225
+ if (!item || item.type !== "text") return false;
226
+ item.text = canonicalText;
227
+ return true;
228
+ }
229
+ return false;
230
+ }
231
+
232
+ // --- Session ID hashing ---
233
+
234
+ function hashSessionId(reqCtx) {
235
+ const sid =
236
+ reqCtx?.meta?.session_id ||
237
+ reqCtx?.headers?.["x-session-id"] ||
238
+ reqCtx?.headers?.["x-anthropic-session-id"] ||
239
+ null;
240
+ if (!sid) return null;
241
+ return createHash("sha256").update(String(sid)).digest("hex").slice(0, 8);
242
+ }
243
+
244
+ // --- Diagnostic record build (pure) ---
245
+
246
+ function serializeExactMatch(m, includeNormalizedText) {
247
+ const rec = {
248
+ msg_idx: m.msg_idx,
249
+ block_idx: m.block_idx,
250
+ content_kind: m.content_kind,
251
+ matched_pattern: m.matched_pattern,
252
+ sentinel_text: m.text,
253
+ byte_length: Buffer.byteLength(m.text, "utf8"),
254
+ };
255
+ if (m.item_idx !== undefined) rec.item_idx = m.item_idx;
256
+ if (typeof includeNormalizedText === "string") {
257
+ rec.normalized_text = includeNormalizedText;
258
+ }
259
+ return rec;
260
+ }
261
+
262
+ function serializePartialMatch(m, redactLen) {
263
+ const rec = {
264
+ msg_idx: m.msg_idx,
265
+ block_idx: m.block_idx,
266
+ content_kind: m.content_kind,
267
+ byte_length: m.byte_length,
268
+ };
269
+ if (m.item_idx !== undefined) rec.item_idx = m.item_idx;
270
+ if (redactLen > 0) {
271
+ rec.prefix_64 = m.text.slice(0, redactLen);
272
+ }
273
+ return rec;
274
+ }
275
+
276
+ export function buildDiagnosticRecord(reqCtx, exact_matches, partial_matches, totalToolResults, opts = {}) {
277
+ const includeNormalized = opts.includeNormalized === true;
278
+ const canonicalText = opts.canonicalText;
279
+ const redactLen = typeof opts.redactLen === "number" ? opts.redactLen : DEFAULT_REDACT_LEN;
280
+ return {
281
+ ts: opts.ts || new Date().toISOString(),
282
+ session_id_hash: hashSessionId(reqCtx),
283
+ exact_matches: exact_matches.map((m) =>
284
+ serializeExactMatch(m, includeNormalized && typeof canonicalText === "string" ? canonicalText : null),
285
+ ),
286
+ partial_matches: partial_matches.map((m) => serializePartialMatch(m, redactLen)),
287
+ total_messages: Array.isArray(reqCtx?.body?.messages) ? reqCtx.body.messages.length : 0,
288
+ total_tool_results: totalToolResults,
289
+ model: reqCtx?.body?.model ?? null,
290
+ };
291
+ }
292
+
293
+ // --- I/O ---
294
+
295
+ export async function appendDiagnosticRecord(path, record) {
296
+ await mkdir(dirname(path), { recursive: true });
297
+ await appendFile(path, JSON.stringify(record) + "\n");
298
+ }
299
+
300
+ // --- Stats shape ---
301
+
302
+ function initStats() {
303
+ return {
304
+ diagnostic_enabled: false,
305
+ normalization_enabled: false,
306
+ sentinel_pattern_used: null, // first matched pattern source (Mode A only)
307
+ total_tool_results_scanned: 0,
308
+ exact_matches_count: 0,
309
+ partial_matches_count: 0,
310
+ sentinels_matched: 0, // exact + partial
311
+ sentinels_normalized: 0,
312
+ bytes_original: 0,
313
+ bytes_normalized: 0,
314
+ bytes_saved: 0,
315
+ diagnostic_records_written: 0,
316
+ };
317
+ }
318
+
319
+ // --- Stderr summary ---
320
+
321
+ function emitStderrSummary(stats, dumpPath) {
322
+ const parts = [`matched=${stats.sentinels_matched}`];
323
+ if (stats.normalization_enabled) {
324
+ parts.push(`normalized=${stats.sentinels_normalized}`);
325
+ parts.push(`bytes=${stats.bytes_original}->${stats.bytes_normalized}`);
326
+ if (stats.sentinel_pattern_used) {
327
+ parts.push(`sentinel_pattern=${stats.sentinel_pattern_used === DEFAULT_EXACT_PATTERNS[0].source || stats.sentinel_pattern_used === DEFAULT_EXACT_PATTERNS[1].source ? "default" : "custom"}`);
328
+ }
329
+ }
330
+ if (stats.diagnostic_enabled) {
331
+ parts.push(`dump=${dumpPath}`);
332
+ if (!stats.normalization_enabled) parts.push("(normalize disabled)");
333
+ }
334
+ process.stderr.write(`[microcompact] ${parts.join(" ")}\n`);
335
+ }
336
+
337
+ // --- Orchestrator ---
338
+
339
+ export async function runMicrocompactStability(reqCtx) {
340
+ const stats = initStats();
341
+ const dumpPath = getDumpPath();
342
+ const normalize = isNormalizeEnabled();
343
+ stats.diagnostic_enabled = !!dumpPath;
344
+ stats.normalization_enabled = normalize;
345
+
346
+ if (!dumpPath && !normalize) return stats;
347
+ if (!reqCtx || !reqCtx.body || !Array.isArray(reqCtx.body.messages)) return stats;
348
+
349
+ const extraPatterns = getCustomPatterns();
350
+ const extraPrefixes = getCustomPrefixes();
351
+ const { exact_matches, partial_matches, total_tool_results } = walkToolResultsForSentinels(
352
+ reqCtx.body.messages,
353
+ extraPatterns,
354
+ extraPrefixes,
355
+ );
356
+ stats.total_tool_results_scanned = total_tool_results;
357
+ stats.exact_matches_count = exact_matches.length;
358
+ stats.partial_matches_count = partial_matches.length;
359
+ stats.sentinels_matched = exact_matches.length + partial_matches.length;
360
+ if (exact_matches.length > 0) {
361
+ stats.sentinel_pattern_used = exact_matches[0].matched_pattern;
362
+ }
363
+
364
+ // Diagnostic dump runs FIRST (raw pre-normalization bytes). Mode B is
365
+ // redacted to prefix_64 by the serializer; Mode A captures full text.
366
+ if (dumpPath && (exact_matches.length > 0 || partial_matches.length > 0)) {
367
+ try {
368
+ const canonicalText = normalize ? getCanonicalText() : null;
369
+ const record = buildDiagnosticRecord(reqCtx, exact_matches, partial_matches, total_tool_results, {
370
+ includeNormalized: isIncludeNormalizedEnabled(),
371
+ canonicalText,
372
+ redactLen: getRedactLen(),
373
+ });
374
+ await appendDiagnosticRecord(dumpPath, record);
375
+ stats.diagnostic_records_written = 1;
376
+ } catch (err) {
377
+ debug(`dump write failed: ${err?.message ?? err}`);
378
+ }
379
+ }
380
+
381
+ // Normalization runs AFTER dump. Only Mode A matches are eligible.
382
+ if (normalize && exact_matches.length > 0) {
383
+ const canonicalText = getCanonicalText();
384
+ for (const m of exact_matches) {
385
+ stats.bytes_original += Buffer.byteLength(m.text, "utf8");
386
+ const ok = normalizeToolResultContent(reqCtx.body.messages, m, canonicalText);
387
+ if (ok) {
388
+ stats.bytes_normalized += Buffer.byteLength(canonicalText, "utf8");
389
+ stats.sentinels_normalized++;
390
+ }
391
+ }
392
+ stats.bytes_saved = stats.bytes_original - stats.bytes_normalized;
393
+ }
394
+
395
+ return stats;
396
+ }
397
+
398
+ // --- Extension contract ---
399
+
400
+ export default {
401
+ name: "microcompact-stability",
402
+ description:
403
+ "Phase 1 microcompact cache stability — diagnostic capture of CC's " +
404
+ "time_based_microcompact sentinel + opt-in normalization to a canonical " +
405
+ "byte-stable form. Phase 2 (snapshot/restore) deferred to v3.5.0+.",
406
+ enabled: false, // overridden by extensions.json
407
+ order: 350,
408
+
409
+ async onRequest(ctx) {
410
+ try {
411
+ const stats = await runMicrocompactStability(ctx);
412
+ // Only attach telemetry / emit summary if we did something observable.
413
+ if (stats.diagnostic_enabled || stats.normalization_enabled) {
414
+ ctx.meta = ctx.meta || {};
415
+ ctx.meta.microcompactStats = stats;
416
+ if (stats.sentinels_matched > 0 || stats.diagnostic_enabled) {
417
+ // Summary on enabled invocations: always when we matched, or when
418
+ // diagnostic is on (so users can verify it's running with no matches).
419
+ if (stats.sentinels_matched > 0) {
420
+ emitStderrSummary(stats, getDumpPath());
421
+ }
422
+ }
423
+ }
424
+ } catch (err) {
425
+ debug(`onRequest unexpected: ${err?.message ?? err}`);
426
+ }
427
+ },
428
+ };
@@ -33,7 +33,8 @@ export default {
33
33
 
34
34
  if (ttlValue === "none") return;
35
35
 
36
- const ttlParam = ttlValue === "5m" ? "5m" : "1h";
36
+ const detectedTier = ctx.meta?._ttlTier || "1h";
37
+ const ttlParam = ttlValue === "5m" || detectedTier === "5m" ? "5m" : "1h";
37
38
 
38
39
  if (Array.isArray(body.system)) {
39
40
  body.system = body.system.map((block) => injectTtl(block, ttlParam));
@@ -0,0 +1,33 @@
1
+ // ttl-tier-detect — port of preload.mjs:1815-1828 in-payload tier detection.
2
+ //
3
+ // Runs at order 75 (between read-only upstream-change-detection at 50 and
4
+ // every cache_control mutator) so that downstream strips by fresh-session-sort
5
+ // (250) and cache-control-normalize (400) cannot hide a ttl="5m" signal from
6
+ // ttl-management at order 500.
7
+ //
8
+ // Pure detection. Sets ctx.meta._ttlTier. Does not mutate ctx.body.
9
+
10
+ function detectExistingTier(body) {
11
+ const blocks = [
12
+ ...(Array.isArray(body?.system) ? body.system : []),
13
+ ...(Array.isArray(body?.messages)
14
+ ? body.messages.flatMap((m) => (Array.isArray(m?.content) ? m.content : []))
15
+ : []),
16
+ ];
17
+ for (const block of blocks) {
18
+ if (block?.cache_control?.ttl === "5m") return "5m";
19
+ }
20
+ return "1h";
21
+ }
22
+
23
+ export { detectExistingTier };
24
+
25
+ export default {
26
+ name: "ttl-tier-detect",
27
+ description: "Detect existing TTL tier from incoming payload before cache_control normalization",
28
+ order: 75,
29
+
30
+ async onRequest(ctx) {
31
+ ctx.meta._ttlTier = detectExistingTier(ctx.body);
32
+ },
33
+ };
@@ -1,12 +1,16 @@
1
1
  {
2
+ "ttl-tier-detect": { "enabled": true, "order": 75 },
2
3
  "fingerprint-strip": { "enabled": true, "order": 100 },
4
+ "image-strip": { "enabled": true, "order": 150 },
3
5
  "sort-stabilization": { "enabled": true, "order": 200 },
4
6
  "fresh-session-sort": { "enabled": true, "order": 250 },
5
7
  "identity-normalization": { "enabled": true, "order": 300 },
6
8
  "smoosh-split": { "enabled": true, "order": 320 },
7
9
  "content-strip": { "enabled": true, "order": 330 },
8
10
  "tool-input-normalize": { "enabled": true, "order": 340 },
11
+ "microcompact-stability": { "enabled": true, "order": 350 },
9
12
  "cache-control-normalize": { "enabled": true, "order": 400 },
13
+ "messages-cache-breakpoint": { "enabled": true, "order": 410 },
10
14
  "ttl-management": { "enabled": true, "order": 500 },
11
15
  "cache-telemetry": { "enabled": true, "order": 600 },
12
16
  "overage-warning": { "enabled": true, "order": 610 },
@@ -0,0 +1,133 @@
1
+ // Lazy `sharp` wrapper for the image-guard pipeline's Pass 3 (native-cap resize).
2
+ //
3
+ // `sharp` is declared as an OPTIONAL peer dependency in package.json. The proxy
4
+ // must run without it. This module:
5
+ //
6
+ // 1. Lazy-imports `sharp` only when first needed (no module-load cost when
7
+ // Pass 3 is disabled or sharp is absent).
8
+ // 2. Caches the import result (success or library-missing failure) so we
9
+ // don't pay the import cost or re-throw repeatedly.
10
+ // 3. Returns a stable `{ ok, reason }` shape the caller can branch on
11
+ // without try/catch around every call.
12
+ //
13
+ // The actual resize uses Lanczos resampling (sharp's default kernel for
14
+ // downscales), preserves aspect ratio, and re-encodes using the SAME media
15
+ // type as the input. No transcoding in v1 — JPEG stays JPEG, PNG stays PNG.
16
+
17
+ let _sharpModule = null; // resolved sharp module (or null if missing)
18
+ let _sharpResolved = false; // have we attempted the import?
19
+ let _sharpMissing = false; // sticky flag — if first import fails, never retry
20
+
21
+ // Reset hook for tests. Not exported in the default surface; tests import by name.
22
+ export function _resetSharpCacheForTests() {
23
+ _sharpModule = null;
24
+ _sharpResolved = false;
25
+ _sharpMissing = false;
26
+ }
27
+
28
+ // Override hook for tests: inject a fake sharp without going through real import.
29
+ // The fake should be callable as `fake(buffer)` returning an object with
30
+ // `.resize()` and `.toBuffer()`/`.toFormat()` methods like real sharp.
31
+ export function _setSharpForTests(fakeSharp) {
32
+ _sharpModule = fakeSharp;
33
+ _sharpResolved = true;
34
+ _sharpMissing = !fakeSharp;
35
+ }
36
+
37
+ async function loadSharp() {
38
+ if (_sharpResolved) {
39
+ return { ok: !_sharpMissing, sharp: _sharpModule };
40
+ }
41
+ try {
42
+ const mod = await import("sharp");
43
+ _sharpModule = mod.default || mod;
44
+ _sharpResolved = true;
45
+ _sharpMissing = false;
46
+ return { ok: true, sharp: _sharpModule };
47
+ } catch (err) {
48
+ _sharpResolved = true;
49
+ _sharpMissing = true;
50
+ _sharpModule = null;
51
+ return { ok: false, sharp: null, err };
52
+ }
53
+ }
54
+
55
+ // Re-encode media type → sharp output format name. Keep symmetric with the
56
+ // dimension probe (PNG + JPEG only in v1).
57
+ function mediaTypeToSharpFormat(mediaType) {
58
+ switch ((mediaType || "").toLowerCase()) {
59
+ case "image/png":
60
+ return "png";
61
+ case "image/jpeg":
62
+ case "image/jpg":
63
+ return "jpeg";
64
+ default:
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // Resize a base64-encoded image to `capPx` on the long edge using Lanczos.
70
+ // Returns:
71
+ // { ok: true, base64, dims: { width, height }, bytes } on success
72
+ // { ok: false, reason: "library_missing" | "unsupported_media_type" | "decode_failed" | "resize_failed" }
73
+ //
74
+ // Caller is expected to:
75
+ // - skip Pass 3 entirely on `library_missing` (sticky for the process)
76
+ // - increment per-image telemetry counters on the other failure modes and
77
+ // leave the original image untouched for Pass 1 to evaluate.
78
+ export async function resizeImageToCap(base64Data, mediaType, capPx) {
79
+ if (!base64Data || typeof base64Data !== "string") {
80
+ return { ok: false, reason: "decode_failed" };
81
+ }
82
+ const format = mediaTypeToSharpFormat(mediaType);
83
+ if (!format) {
84
+ return { ok: false, reason: "unsupported_media_type" };
85
+ }
86
+
87
+ const loaded = await loadSharp();
88
+ if (!loaded.ok) {
89
+ return { ok: false, reason: "library_missing" };
90
+ }
91
+ const sharp = loaded.sharp;
92
+
93
+ let inputBuffer;
94
+ try {
95
+ inputBuffer = Buffer.from(base64Data, "base64");
96
+ } catch {
97
+ return { ok: false, reason: "decode_failed" };
98
+ }
99
+ if (!inputBuffer || inputBuffer.length === 0) {
100
+ return { ok: false, reason: "decode_failed" };
101
+ }
102
+
103
+ try {
104
+ const pipeline = sharp(inputBuffer).resize({
105
+ width: capPx,
106
+ height: capPx,
107
+ fit: "inside", // preserve aspect ratio, neither edge exceeds capPx
108
+ withoutEnlargement: true, // never upscale
109
+ kernel: "lanczos3",
110
+ });
111
+
112
+ // Re-encode using the SAME format as the input. No transcoding in v1.
113
+ const encoded = format === "png"
114
+ ? await pipeline.png().toBuffer({ resolveWithObject: true })
115
+ : await pipeline.jpeg().toBuffer({ resolveWithObject: true });
116
+
117
+ const newBase64 = encoded.data.toString("base64");
118
+ return {
119
+ ok: true,
120
+ base64: newBase64,
121
+ dims: { width: encoded.info.width, height: encoded.info.height },
122
+ bytes: encoded.data.length,
123
+ };
124
+ } catch {
125
+ return { ok: false, reason: "resize_failed" };
126
+ }
127
+ }
128
+
129
+ // Tiny helper for tests: returns whether sharp was successfully imported once.
130
+ // Doesn't trigger an import — caller must have invoked resizeImageToCap first.
131
+ export function _sharpStatusForTests() {
132
+ return { resolved: _sharpResolved, missing: _sharpMissing };
133
+ }