claude-code-cache-fix 1.2.0 → 1.3.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.
- package/README.md +1 -24
- package/package.json +1 -1
- package/preload.mjs +31 -164
package/README.md
CHANGED
|
@@ -92,27 +92,6 @@ This keeps images in the last 3 user messages and replaces older ones with a tex
|
|
|
92
92
|
|
|
93
93
|
Set to `0` (default) to disable.
|
|
94
94
|
|
|
95
|
-
## Prefix lock (resume cache hit)
|
|
96
|
-
|
|
97
|
-
Even with the block relocation fix, the first API call after `--resume` triggers a full cache rebuild because CC reassembles messages with different system-reminder blocks, changing the prefix bytes. On a 300k token context at Opus rates, that's ~$2.80 per resume.
|
|
98
|
-
|
|
99
|
-
The prefix lock eliminates this by saving the exact `messages[0]` content after all fixes are applied, then replaying it on the next resume to produce a byte-identical prefix.
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
export CACHE_FIX_PREFIX_LOCK=1
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Safety guards — the lock only fires when ALL of these match:
|
|
106
|
-
- System prompt hash (same project, no CLAUDE.md changes)
|
|
107
|
-
- Tools hash (no MCP/plugin changes)
|
|
108
|
-
- User message text (same conversation)
|
|
109
|
-
- User content hash (no substantive context changes)
|
|
110
|
-
- Not a post-compaction conversation
|
|
111
|
-
|
|
112
|
-
If any guard fails, the lock skips and falls back to normal behavior. The worst case is a skip — the lock cannot increase costs or cause context loss.
|
|
113
|
-
|
|
114
|
-
Set to `0` (default) to disable.
|
|
115
|
-
|
|
116
95
|
## Monitoring
|
|
117
96
|
|
|
118
97
|
The interceptor includes monitoring for several additional issues identified by the community:
|
|
@@ -152,8 +131,7 @@ Logs are written to `~/.claude/cache-fix-debug.log`. Look for:
|
|
|
152
131
|
- `BUDGET WARNING: tool result chars at N / 200,000 threshold` — approaching budget cap
|
|
153
132
|
- `FALSE RATE LIMIT: synthetic model detected` — client-side false rate limit
|
|
154
133
|
- `GROWTHBOOK FLAGS: {...}` — server-controlled feature flags on first call
|
|
155
|
-
- `
|
|
156
|
-
- `PREFIX LOCK: skipped — <reason>` — guard prevented lock (expected, safe)
|
|
134
|
+
- `PROMPT SIZE: system=N tools=N injected=N (skills=N mcp=N ...)` — per-call prompt size breakdown
|
|
157
135
|
- `SKIPPED: resume relocation (not a resume or already correct)` — no fix needed
|
|
158
136
|
|
|
159
137
|
### Prefix diff mode
|
|
@@ -173,7 +151,6 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
|
|
|
173
151
|
| `CACHE_FIX_DEBUG` | `0` | Enable debug logging to `~/.claude/cache-fix-debug.log` |
|
|
174
152
|
| `CACHE_FIX_PREFIXDIFF` | `0` | Enable prefix snapshot diffing |
|
|
175
153
|
| `CACHE_FIX_IMAGE_KEEP_LAST` | `0` | Keep images in last N user messages (0 = disabled) |
|
|
176
|
-
| `CACHE_FIX_PREFIX_LOCK` | `0` | Replay saved messages[0] on resume for cache hit (0 = disabled) |
|
|
177
154
|
|
|
178
155
|
## Limitations
|
|
179
156
|
|
package/package.json
CHANGED
package/preload.mjs
CHANGED
|
@@ -365,156 +365,6 @@ function stripOldToolResultImages(messages, keepLast) {
|
|
|
365
365
|
return { messages: strippedCount > 0 ? result : messages, stats };
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
// --------------------------------------------------------------------------
|
|
369
|
-
// Prefix lock — replay saved messages[0] on resume for cache hit
|
|
370
|
-
// --------------------------------------------------------------------------
|
|
371
|
-
|
|
372
|
-
// CACHE_FIX_PREFIX_LOCK=1 — save messages[0] on every call and replay it on
|
|
373
|
-
// resume to avoid a cache rebuild. Disabled by default.
|
|
374
|
-
//
|
|
375
|
-
// On resume, CC reassembles messages with blocks in different positions and
|
|
376
|
-
// injects fresh system-reminders, changing the prefix bytes. Even after our
|
|
377
|
-
// relocation fix corrects the blocks, the prefix differs from what the server
|
|
378
|
-
// cached on the last pre-exit call, causing a full cache rebuild.
|
|
379
|
-
//
|
|
380
|
-
// This feature saves the exact messages[0] content after all fixes are applied.
|
|
381
|
-
// On the first call of a new process (resume), if system prompt hash and tools
|
|
382
|
-
// hash match the saved snapshot, and the real user message text matches, we
|
|
383
|
-
// replay the saved messages[0] to produce a byte-identical prefix → cache hit.
|
|
384
|
-
|
|
385
|
-
const PREFIX_LOCK = process.env.CACHE_FIX_PREFIX_LOCK === "1";
|
|
386
|
-
const PREFIX_LOCK_FILE = join(homedir(), ".claude", "cache-fix-prefix-lock.json");
|
|
387
|
-
|
|
388
|
-
let _prefixLockFirstCall = true;
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Compute hashes for prefix lock comparison.
|
|
392
|
-
*/
|
|
393
|
-
function computePrefixHashes(system, tools) {
|
|
394
|
-
const sysHash = system
|
|
395
|
-
? createHash("sha256").update(JSON.stringify(system)).digest("hex").slice(0, 16)
|
|
396
|
-
: "none";
|
|
397
|
-
const toolHash = tools
|
|
398
|
-
? createHash("sha256").update(JSON.stringify(tools.map(t => t.name).sort())).digest("hex").slice(0, 16)
|
|
399
|
-
: "none";
|
|
400
|
-
return { sysHash, toolHash };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Extract the real user message text from messages[0] (skipping system-reminders).
|
|
405
|
-
*/
|
|
406
|
-
function extractUserTextFromFirstMsg(msg) {
|
|
407
|
-
if (!msg || !Array.isArray(msg.content)) return "";
|
|
408
|
-
for (const block of msg.content) {
|
|
409
|
-
if (block.type === "text" && typeof block.text === "string" &&
|
|
410
|
-
!block.text.startsWith("<system-reminder>") &&
|
|
411
|
-
!block.text.startsWith("<local-command")) {
|
|
412
|
-
return block.text.slice(0, 200); // enough to identify, not too much to compare
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return "";
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Hash all non-system-reminder user content in messages[0] to detect
|
|
420
|
-
* substantive changes that the userText check (first 200 chars) might miss.
|
|
421
|
-
*/
|
|
422
|
-
function hashUserContent(msg) {
|
|
423
|
-
if (!msg || !Array.isArray(msg.content)) return "empty";
|
|
424
|
-
const userBlocks = msg.content.filter(b =>
|
|
425
|
-
b.type === "text" && typeof b.text === "string" &&
|
|
426
|
-
!b.text.startsWith("<system-reminder>") &&
|
|
427
|
-
!b.text.startsWith("<local-command")
|
|
428
|
-
);
|
|
429
|
-
if (userBlocks.length === 0) return "empty";
|
|
430
|
-
return createHash("sha256")
|
|
431
|
-
.update(userBlocks.map(b => b.text).join("\n"))
|
|
432
|
-
.digest("hex").slice(0, 16);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* On resume: try to replay saved messages[0] for cache hit.
|
|
437
|
-
* Returns the locked messages array or the original if lock doesn't apply.
|
|
438
|
-
*/
|
|
439
|
-
function applyPrefixLock(messages, system, tools) {
|
|
440
|
-
if (!PREFIX_LOCK || !Array.isArray(messages) || messages.length < 2) return messages;
|
|
441
|
-
|
|
442
|
-
const firstUserIdx = messages.findIndex(m => m.role === "user");
|
|
443
|
-
if (firstUserIdx === -1) return messages;
|
|
444
|
-
|
|
445
|
-
const { sysHash, toolHash } = computePrefixHashes(system, tools);
|
|
446
|
-
const currentUserText = extractUserTextFromFirstMsg(messages[firstUserIdx]);
|
|
447
|
-
const currentContentHash = hashUserContent(messages[firstUserIdx]);
|
|
448
|
-
|
|
449
|
-
// Skip if this looks like a compacted conversation (system-reminder as first block
|
|
450
|
-
// with compaction summary markers)
|
|
451
|
-
const firstBlock = messages[firstUserIdx]?.content?.[0];
|
|
452
|
-
if (firstBlock?.text?.includes("CompactBoundary") || firstBlock?.text?.includes("compacted")) {
|
|
453
|
-
debugLog("PREFIX LOCK: skipped — compacted conversation detected");
|
|
454
|
-
return messages;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (_prefixLockFirstCall) {
|
|
458
|
-
_prefixLockFirstCall = false;
|
|
459
|
-
|
|
460
|
-
// Try to load and apply saved prefix
|
|
461
|
-
try {
|
|
462
|
-
const saved = JSON.parse(readFileSync(PREFIX_LOCK_FILE, "utf8"));
|
|
463
|
-
|
|
464
|
-
if (saved.sysHash !== sysHash) {
|
|
465
|
-
debugLog("PREFIX LOCK: skipped — system prompt changed");
|
|
466
|
-
} else if (saved.toolHash !== toolHash) {
|
|
467
|
-
debugLog("PREFIX LOCK: skipped — tools changed");
|
|
468
|
-
} else if (saved.userText !== currentUserText) {
|
|
469
|
-
debugLog("PREFIX LOCK: skipped — user message text changed");
|
|
470
|
-
} else if (saved.contentHash && saved.contentHash !== currentContentHash) {
|
|
471
|
-
debugLog("PREFIX LOCK: skipped — user content hash changed (substantive context change)");
|
|
472
|
-
} else if (!saved.content || !Array.isArray(saved.content)) {
|
|
473
|
-
debugLog("PREFIX LOCK: skipped — saved content invalid");
|
|
474
|
-
} else {
|
|
475
|
-
// Apply the saved messages[0] content
|
|
476
|
-
const result = [...messages];
|
|
477
|
-
result[firstUserIdx] = { ...result[firstUserIdx], content: saved.content };
|
|
478
|
-
debugLog(`PREFIX LOCK: APPLIED — replayed saved messages[0] (${saved.content.length} blocks)`);
|
|
479
|
-
return result;
|
|
480
|
-
}
|
|
481
|
-
} catch {
|
|
482
|
-
debugLog("PREFIX LOCK: no saved prefix found (first run or file missing)");
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return messages;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Save current messages[0] content for future resume replay.
|
|
491
|
-
* Called after all fixes are applied, before the request is sent.
|
|
492
|
-
*/
|
|
493
|
-
function savePrefixLock(messages, system, tools) {
|
|
494
|
-
if (!PREFIX_LOCK || !Array.isArray(messages)) return;
|
|
495
|
-
|
|
496
|
-
const firstUserIdx = messages.findIndex(m => m.role === "user");
|
|
497
|
-
if (firstUserIdx === -1) return;
|
|
498
|
-
|
|
499
|
-
const { sysHash, toolHash } = computePrefixHashes(system, tools);
|
|
500
|
-
const userText = extractUserTextFromFirstMsg(messages[firstUserIdx]);
|
|
501
|
-
const contentHash = hashUserContent(messages[firstUserIdx]);
|
|
502
|
-
const content = messages[firstUserIdx].content;
|
|
503
|
-
|
|
504
|
-
try {
|
|
505
|
-
writeFileSync(PREFIX_LOCK_FILE, JSON.stringify({
|
|
506
|
-
timestamp: new Date().toISOString(),
|
|
507
|
-
sysHash,
|
|
508
|
-
toolHash,
|
|
509
|
-
userText,
|
|
510
|
-
contentHash,
|
|
511
|
-
content,
|
|
512
|
-
}));
|
|
513
|
-
} catch (e) {
|
|
514
|
-
debugLog("PREFIX LOCK: failed to save:", e?.message);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
368
|
// --------------------------------------------------------------------------
|
|
519
369
|
// Tool schema stabilization (Bug 2 secondary cause)
|
|
520
370
|
// --------------------------------------------------------------------------
|
|
@@ -844,15 +694,6 @@ globalThis.fetch = async function (url, options) {
|
|
|
844
694
|
}
|
|
845
695
|
}
|
|
846
696
|
|
|
847
|
-
// Prefix lock: replay saved messages[0] on resume for cache hit
|
|
848
|
-
if (payload.messages && payload.system) {
|
|
849
|
-
const locked = applyPrefixLock(payload.messages, payload.system, payload.tools);
|
|
850
|
-
if (locked !== payload.messages) {
|
|
851
|
-
payload.messages = locked;
|
|
852
|
-
modified = true;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
697
|
// Bug 2a: Stabilize tool ordering
|
|
857
698
|
if (payload.tools) {
|
|
858
699
|
const sorted = stabilizeToolOrder(payload.tools);
|
|
@@ -885,16 +726,42 @@ globalThis.fetch = async function (url, options) {
|
|
|
885
726
|
debugLog("Request body rewritten");
|
|
886
727
|
}
|
|
887
728
|
|
|
888
|
-
// Save prefix lock after all fixes applied
|
|
889
|
-
if (payload.messages && payload.system) {
|
|
890
|
-
savePrefixLock(payload.messages, payload.system, payload.tools);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
729
|
// Monitor for microcompact / budget enforcement degradation
|
|
894
730
|
if (payload.messages) {
|
|
895
731
|
monitorContextDegradation(payload.messages);
|
|
896
732
|
}
|
|
897
733
|
|
|
734
|
+
// Prompt size measurement — log system prompt, tools, and injected block sizes
|
|
735
|
+
if (DEBUG && payload.system && payload.tools && payload.messages) {
|
|
736
|
+
const sysChars = JSON.stringify(payload.system).length;
|
|
737
|
+
const toolsChars = JSON.stringify(payload.tools).length;
|
|
738
|
+
const firstUserIdx = payload.messages.findIndex(m => m.role === "user");
|
|
739
|
+
if (firstUserIdx !== -1) {
|
|
740
|
+
const msg0 = payload.messages[firstUserIdx];
|
|
741
|
+
if (Array.isArray(msg0.content)) {
|
|
742
|
+
let skillsChars = 0;
|
|
743
|
+
let mcpChars = 0;
|
|
744
|
+
let deferredChars = 0;
|
|
745
|
+
let hooksChars = 0;
|
|
746
|
+
for (const block of msg0.content) {
|
|
747
|
+
const text = block.text || "";
|
|
748
|
+
if (isSkillsBlock(text)) skillsChars += text.length;
|
|
749
|
+
else if (isMcpBlock(text)) mcpChars += text.length;
|
|
750
|
+
else if (isDeferredToolsBlock(text)) deferredChars += text.length;
|
|
751
|
+
else if (isHooksBlock(text)) hooksChars += text.length;
|
|
752
|
+
}
|
|
753
|
+
const injectedTotal = skillsChars + mcpChars + deferredChars + hooksChars;
|
|
754
|
+
if (injectedTotal > 0) {
|
|
755
|
+
debugLog(
|
|
756
|
+
`PROMPT SIZE: system=${sysChars} tools=${toolsChars}`,
|
|
757
|
+
`injected=${injectedTotal} (skills=${skillsChars} mcp=${mcpChars}`,
|
|
758
|
+
`deferred=${deferredChars} hooks=${hooksChars})`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
898
765
|
// Capture prefix snapshot for cross-process diff analysis
|
|
899
766
|
snapshotPrefix(payload);
|
|
900
767
|
|