claude-code-cache-fix 3.0.5 → 3.1.1
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.ko.md +1 -1
- package/README.md +27 -21
- package/README.zh.md +1 -1
- package/bin/claude-via-proxy.mjs +57 -0
- package/bin/install-service.mjs +476 -0
- package/package.json +3 -2
- package/proxy/extensions/cache-control-normalize.mjs +2 -0
- package/proxy/extensions/content-strip.mjs +89 -0
- package/proxy/extensions/deferred-tools-restore.mjs +361 -0
- package/proxy/extensions/fingerprint-strip.mjs +2 -0
- package/proxy/extensions/fresh-session-sort.mjs +2 -0
- package/proxy/extensions/identity-normalization.mjs +2 -0
- package/proxy/extensions/image-strip.mjs +83 -0
- package/proxy/extensions/output-efficiency-rewrite.mjs +64 -0
- package/proxy/extensions/prefix-diff.mjs +277 -0
- package/proxy/extensions/smoosh-split.mjs +68 -0
- package/proxy/extensions/sort-stabilization.mjs +2 -0
- package/proxy/extensions/tool-input-normalize.mjs +73 -0
- package/proxy/extensions/ttl-management.mjs +2 -0
- package/proxy/extensions/usage-log.mjs +46 -0
- package/proxy/extensions.json +3 -0
- package/templates/cache-fix-proxy-healthcheck.service.template +7 -0
- package/templates/cache-fix-proxy-healthcheck.timer.template +14 -0
- package/templates/cache-fix-proxy.service.template +17 -0
- package/templates/com.cnighswonger.cache-fix-proxy.plist.template +33 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const CONTINUE_TRAILER_TEXT = "Continue from where you left off.";
|
|
2
|
+
|
|
3
|
+
const REMINDER_WRAP_REGEX = /^<system-reminder>\n([\s\S]*?)\n<\/system-reminder>\s*$/;
|
|
4
|
+
const BOOKKEEPING_PATTERNS = [
|
|
5
|
+
/^Token usage: \d+\/\d+; \d+ remaining\s*$/,
|
|
6
|
+
/^Output tokens — turn: [^\n]+ · session: [^\n]+\s*$/,
|
|
7
|
+
/^USD budget: \$[\d.]+\/\$[\d.]+; \$[\d.]+ remaining\s*$/,
|
|
8
|
+
/^The task tools haven't been used recently\./,
|
|
9
|
+
/^The TodoWrite tool hasn't been used recently\./,
|
|
10
|
+
/^Remaining conversation turns: /,
|
|
11
|
+
/^Messages? until auto-compact: /,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function isContinueTrailerBlock(block) {
|
|
15
|
+
return (
|
|
16
|
+
!!block &&
|
|
17
|
+
typeof block === "object" &&
|
|
18
|
+
block.type === "text" &&
|
|
19
|
+
block.text === CONTINUE_TRAILER_TEXT
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isBookkeepingReminder(text) {
|
|
24
|
+
if (typeof text !== "string") return false;
|
|
25
|
+
const m = text.match(REMINDER_WRAP_REGEX);
|
|
26
|
+
if (!m) return false;
|
|
27
|
+
const inner = m[1];
|
|
28
|
+
for (const rx of BOOKKEEPING_PATTERNS) {
|
|
29
|
+
if (rx.test(inner)) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripContentBlocks(messages) {
|
|
35
|
+
if (!Array.isArray(messages)) return { messages, stats: null };
|
|
36
|
+
|
|
37
|
+
let trailerCount = 0;
|
|
38
|
+
let reminderCount = 0;
|
|
39
|
+
|
|
40
|
+
const result = messages.map((msg) => {
|
|
41
|
+
if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
|
|
42
|
+
|
|
43
|
+
let msgTrailers = 0;
|
|
44
|
+
let msgReminders = 0;
|
|
45
|
+
|
|
46
|
+
const kept = msg.content.filter((block) => {
|
|
47
|
+
if (isContinueTrailerBlock(block)) {
|
|
48
|
+
msgTrailers++;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (block.type === "text" && isBookkeepingReminder(block.text)) {
|
|
52
|
+
msgReminders++;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (kept.length === 0 || kept.length === msg.content.length) return msg;
|
|
59
|
+
|
|
60
|
+
trailerCount += msgTrailers;
|
|
61
|
+
reminderCount += msgReminders;
|
|
62
|
+
return { ...msg, content: kept };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const total = trailerCount + reminderCount;
|
|
66
|
+
return {
|
|
67
|
+
messages: total > 0 ? result : messages,
|
|
68
|
+
stats: total > 0 ? { trailerCount, reminderCount } : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { isContinueTrailerBlock, isBookkeepingReminder, stripContentBlocks };
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
name: "content-strip",
|
|
76
|
+
description: "Strip continue trailers and bookkeeping system-reminders from user messages",
|
|
77
|
+
enabled: false,
|
|
78
|
+
order: 350,
|
|
79
|
+
|
|
80
|
+
async onRequest(ctx) {
|
|
81
|
+
if (!ctx.body.messages) return;
|
|
82
|
+
|
|
83
|
+
const { messages, stats } = stripContentBlocks(ctx.body.messages);
|
|
84
|
+
if (stats) {
|
|
85
|
+
ctx.body.messages = messages;
|
|
86
|
+
ctx.meta.contentStripStats = stats;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// deferred-tools-restore — preserve cache prefix across MCP reconnect race.
|
|
2
|
+
//
|
|
3
|
+
// PROBLEM (mirrored from preload.mjs ~429-518):
|
|
4
|
+
// On `claude --continue`, if MCP servers haven't finished reconnecting before
|
|
5
|
+
// CC fires the first post-resume request, the
|
|
6
|
+
// <system-reminder>The following deferred tools are now available via ToolSearch…</system-reminder>
|
|
7
|
+
// block at msg[0] (or wherever the attachment lands post-compaction) shrinks
|
|
8
|
+
// dramatically. ~40 tools collapse to a handful of CC built-ins, and CC
|
|
9
|
+
// injects a trailing
|
|
10
|
+
// "The following deferred tools are no longer available (their MCP server
|
|
11
|
+
// disconnected). Do not search for them — ToolSearch will return no match:"
|
|
12
|
+
// notice. That block change at the root of the message array busts the cache
|
|
13
|
+
// at the very top — the entire ~940K prompt re-caches.
|
|
14
|
+
//
|
|
15
|
+
// FIX: Persist the clean (no-UNAVAILABLE-marker) form of the block. On a
|
|
16
|
+
// subsequent request where the block has shrunk and contains the UNAVAILABLE
|
|
17
|
+
// marker, substitute the persisted full bytes. Restore only if the snapshot
|
|
18
|
+
// is STRICTLY LONGER than the current block — never downgrade to a stale
|
|
19
|
+
// shorter snapshot.
|
|
20
|
+
//
|
|
21
|
+
// SNAPSHOT KEY (proxy-specific adaptation):
|
|
22
|
+
// Preload uses process.cwd() because each preload runs in the CC process,
|
|
23
|
+
// where cwd identifies the project. The proxy is a long-lived daemon; its
|
|
24
|
+
// cwd is shared across all CC sessions on the host. To key per-project, the
|
|
25
|
+
// proxy parses the cwd OUT of the system prompt content (CC injects
|
|
26
|
+
// " - Primary working directory: <path>" in the # Environment section) and
|
|
27
|
+
// keys on sha1("cwd:" + that path). Verified empirically against CC v2.1.117.
|
|
28
|
+
//
|
|
29
|
+
// FAIL-OPEN: If the cwd marker is absent (CC format drift, missing system
|
|
30
|
+
// prompt), the extension no-ops the request entirely. Result: status quo
|
|
31
|
+
// cache-bust, never silently restores the wrong block.
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
mkdir as _mkdir,
|
|
35
|
+
readFile as _readFile,
|
|
36
|
+
writeFile as _writeFile,
|
|
37
|
+
rename as _rename,
|
|
38
|
+
} from "node:fs/promises";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { createHash } from "node:crypto";
|
|
42
|
+
|
|
43
|
+
const SKIP = process.env.CACHE_FIX_SKIP_DEFERRED_TOOLS_RESTORE === "1";
|
|
44
|
+
const DEBUG = process.env.CACHE_FIX_DEBUG === "1";
|
|
45
|
+
|
|
46
|
+
const AVAILABLE_MARKER =
|
|
47
|
+
"The following deferred tools are now available via ToolSearch";
|
|
48
|
+
const UNAVAILABLE_MARKER =
|
|
49
|
+
"The following deferred tools are no longer available";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_FS = {
|
|
52
|
+
mkdir: _mkdir,
|
|
53
|
+
readFile: _readFile,
|
|
54
|
+
writeFile: _writeFile,
|
|
55
|
+
rename: _rename,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function getSnapshotDir() {
|
|
59
|
+
return join(homedir(), ".claude", "cache-fix-state");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function debug(msg) {
|
|
63
|
+
if (DEBUG) process.stderr.write(`[deferred-tools-restore] ${msg}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract the working-directory path from CC's system prompt.
|
|
68
|
+
* Returns the parsed path string, or null if the marker is not found OR
|
|
69
|
+
* the prompt structure is ambiguous (multiple valid env sections).
|
|
70
|
+
*
|
|
71
|
+
* STRICT STRUCTURAL PARSER (line-based, not regex-window):
|
|
72
|
+
*
|
|
73
|
+
* Recognizes a valid # Environment section ONLY when ALL of these hold:
|
|
74
|
+
* 1. A line that is exactly `# Environment` (whitespace-trimmed)
|
|
75
|
+
* 2. The next non-blank line is exactly the CC intro line:
|
|
76
|
+
* `You have been invoked in the following environment:`
|
|
77
|
+
* 3. The marker appears in a bullet line (`- Primary working directory: ...`)
|
|
78
|
+
* within the bullet list immediately following the intro line — bounded
|
|
79
|
+
* by the first blank line or first non-bullet line.
|
|
80
|
+
*
|
|
81
|
+
* Only the conjunction of all three rejects the false positives Codex flagged:
|
|
82
|
+
* - bare `# Environment` mention in narrative/code (fails rule 2)
|
|
83
|
+
* - code-fenced `Primary working directory:` example without the env header
|
|
84
|
+
* (fails rule 1)
|
|
85
|
+
* - a fake marker line elsewhere in the same block but not in a bullet
|
|
86
|
+
* list immediately following the intro (fails rule 3)
|
|
87
|
+
*
|
|
88
|
+
* AMBIGUITY GUARD: if MULTIPLE distinct valid env sections produce different
|
|
89
|
+
* cwd values (across blocks or within one block), refuse to pick — return
|
|
90
|
+
* null. The fail-open path (extension no-ops the request) is strictly safer
|
|
91
|
+
* than restoring with the wrong snapshot key.
|
|
92
|
+
*
|
|
93
|
+
* Accepts:
|
|
94
|
+
* - array of content blocks (CC's normal shape): walks .text fields in order
|
|
95
|
+
* - a single string (rare; older clients): scans directly
|
|
96
|
+
* - anything else: returns null
|
|
97
|
+
*/
|
|
98
|
+
const ENV_HEADER_LINE = "# Environment";
|
|
99
|
+
const ENV_INTRO_LINE = "You have been invoked in the following environment:";
|
|
100
|
+
const CWD_BULLET_RE = /^-\s+Primary working directory:\s*(.+?)\s*$/;
|
|
101
|
+
|
|
102
|
+
function parseAllCwdsFromBlock(text) {
|
|
103
|
+
const found = [];
|
|
104
|
+
const lines = text.split("\n");
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
if (lines[i].trim() !== ENV_HEADER_LINE) continue;
|
|
107
|
+
// Skip blank lines after the header (CC emits exactly one intro line
|
|
108
|
+
// immediately following, but be tolerant of whitespace).
|
|
109
|
+
let j = i + 1;
|
|
110
|
+
while (j < lines.length && lines[j].trim() === "") j++;
|
|
111
|
+
if (j >= lines.length) continue;
|
|
112
|
+
if (lines[j].trim() !== ENV_INTRO_LINE) continue;
|
|
113
|
+
// Walk the bullet list following the intro line; first cwd bullet wins
|
|
114
|
+
// for this section. A blank line or non-bullet line ends the section.
|
|
115
|
+
for (let k = j + 1; k < lines.length; k++) {
|
|
116
|
+
const trimmed = lines[k].trimStart();
|
|
117
|
+
if (lines[k].trim() === "") break;
|
|
118
|
+
if (!trimmed.startsWith("-")) break;
|
|
119
|
+
const m = trimmed.match(CWD_BULLET_RE);
|
|
120
|
+
if (m && m[1]) {
|
|
121
|
+
found.push(m[1]);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function extractCwdFromSystem(system) {
|
|
130
|
+
if (!system) return null;
|
|
131
|
+
const texts = [];
|
|
132
|
+
if (typeof system === "string") {
|
|
133
|
+
texts.push(system);
|
|
134
|
+
} else if (Array.isArray(system)) {
|
|
135
|
+
for (const block of system) {
|
|
136
|
+
if (block && typeof block === "object" && typeof block.text === "string") {
|
|
137
|
+
texts.push(block.text);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (const t of texts) {
|
|
145
|
+
const matches = parseAllCwdsFromBlock(t);
|
|
146
|
+
for (const m of matches) {
|
|
147
|
+
seen.add(m);
|
|
148
|
+
if (seen.size > 1) return null; // ambiguous → no-op
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (seen.size === 1) return [...seen][0];
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deriveSnapshotKey(cwd) {
|
|
156
|
+
return createHash("sha1").update(`cwd:${cwd}`).digest("hex").slice(0, 16);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Locate the deferred-tools attachment block in body.messages.
|
|
161
|
+
* Only inspects user messages (skips assistant so the agent quoting the
|
|
162
|
+
* AVAILABLE marker verbatim doesn't trigger a false match).
|
|
163
|
+
* Returns { msgIdx, blockIdx, text } or null.
|
|
164
|
+
*/
|
|
165
|
+
function findDeferredToolsBlockInBody(body) {
|
|
166
|
+
if (!body || !Array.isArray(body.messages)) return null;
|
|
167
|
+
for (let m = 0; m < body.messages.length; m++) {
|
|
168
|
+
const msg = body.messages[m];
|
|
169
|
+
if (!msg || msg.role !== "user" || !Array.isArray(msg.content)) continue;
|
|
170
|
+
for (let i = 0; i < msg.content.length; i++) {
|
|
171
|
+
const b = msg.content[i];
|
|
172
|
+
if (
|
|
173
|
+
b &&
|
|
174
|
+
b.type === "text" &&
|
|
175
|
+
typeof b.text === "string" &&
|
|
176
|
+
b.text.includes(AVAILABLE_MARKER)
|
|
177
|
+
) {
|
|
178
|
+
return { msgIdx: m, blockIdx: i, text: b.text };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Atomic write (same lesson as prefix-diff): unique tmp per invocation so
|
|
186
|
+
// concurrent calls don't collide on a shared .tmp path.
|
|
187
|
+
async function atomicWriteText(finalPath, data, fs) {
|
|
188
|
+
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random()
|
|
189
|
+
.toString(36)
|
|
190
|
+
.slice(2, 10)}.tmp`;
|
|
191
|
+
await fs.writeFile(tmpPath, data);
|
|
192
|
+
await fs.rename(tmpPath, finalPath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Persist a snapshot of the clean deferred-tools block.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} text The full block text to persist.
|
|
199
|
+
* @param {object} options
|
|
200
|
+
* @param {string} options.dir Snapshot directory.
|
|
201
|
+
* @param {string} options.key Snapshot key (from deriveSnapshotKey).
|
|
202
|
+
* @param {object} [options.fs] fs/promises overrides for tests.
|
|
203
|
+
* @returns {Promise<{persisted: boolean, bytes: number, path: string}>}
|
|
204
|
+
*/
|
|
205
|
+
async function persistDeferredTools(text, options) {
|
|
206
|
+
const dir = options.dir;
|
|
207
|
+
const key = options.key;
|
|
208
|
+
const fs = { ...DEFAULT_FS, ...(options.fs || {}) };
|
|
209
|
+
const path = join(dir, `deferred-tools-${key}.txt`);
|
|
210
|
+
try {
|
|
211
|
+
await fs.mkdir(dir, { recursive: true });
|
|
212
|
+
await atomicWriteText(path, text, fs);
|
|
213
|
+
return { persisted: true, bytes: Buffer.byteLength(text, "utf-8"), path };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
debug(`persist failed at ${path}: ${err?.message ?? err}`);
|
|
216
|
+
return { persisted: false, bytes: 0, path };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read and validate a snapshot. Returns the snapshot text on success, null
|
|
222
|
+
* otherwise. Validation:
|
|
223
|
+
* - file exists and is readable
|
|
224
|
+
* - byte length >= AVAILABLE_MARKER length (sanity floor)
|
|
225
|
+
* - content contains the AVAILABLE marker (defense in depth against a
|
|
226
|
+
* truncated-but-readable file passing only the length check)
|
|
227
|
+
*/
|
|
228
|
+
async function restoreDeferredTools(options) {
|
|
229
|
+
const dir = options.dir;
|
|
230
|
+
const key = options.key;
|
|
231
|
+
const fs = { ...DEFAULT_FS, ...(options.fs || {}) };
|
|
232
|
+
const path = join(dir, `deferred-tools-${key}.txt`);
|
|
233
|
+
let snapshot;
|
|
234
|
+
try {
|
|
235
|
+
snapshot = await fs.readFile(path, "utf-8");
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err && err.code !== "ENOENT") {
|
|
238
|
+
debug(`snapshot read failed at ${path}: ${err?.message ?? err}`);
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
if (typeof snapshot !== "string") return null;
|
|
243
|
+
if (snapshot.length < AVAILABLE_MARKER.length) {
|
|
244
|
+
debug(`snapshot rejected (too short: ${snapshot.length} bytes) at ${path}`);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (!snapshot.includes(AVAILABLE_MARKER)) {
|
|
248
|
+
debug(`snapshot rejected (missing AVAILABLE marker) at ${path}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
// Defense in depth: persisted snapshots should be clean by construction
|
|
252
|
+
// (we only persist when !hasUnavail), but if a snapshot ever contains the
|
|
253
|
+
// UNAVAILABLE marker we refuse to restore it — restoring a "no longer
|
|
254
|
+
// available" block would be worse than no restore.
|
|
255
|
+
if (snapshot.includes(UNAVAILABLE_MARKER)) {
|
|
256
|
+
debug(`snapshot rejected (contains UNAVAILABLE marker, not a clean baseline) at ${path}`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return snapshot;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export {
|
|
263
|
+
extractCwdFromSystem,
|
|
264
|
+
deriveSnapshotKey,
|
|
265
|
+
findDeferredToolsBlockInBody,
|
|
266
|
+
persistDeferredTools,
|
|
267
|
+
restoreDeferredTools,
|
|
268
|
+
AVAILABLE_MARKER,
|
|
269
|
+
UNAVAILABLE_MARKER,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export default {
|
|
273
|
+
name: "deferred-tools-restore",
|
|
274
|
+
description:
|
|
275
|
+
"Persist and restore the deferred-tools attachment block across sessions to prevent MCP-reconnect-race cache busts at resume time",
|
|
276
|
+
enabled: true,
|
|
277
|
+
order: 350,
|
|
278
|
+
|
|
279
|
+
async onRequest(ctx) {
|
|
280
|
+
if (SKIP) return;
|
|
281
|
+
if (!ctx || !ctx.body) return;
|
|
282
|
+
const body = ctx.body;
|
|
283
|
+
|
|
284
|
+
// 1. Parse cwd from system. No marker → no-op (honest degradation).
|
|
285
|
+
const cwd = extractCwdFromSystem(body.system);
|
|
286
|
+
if (!cwd) {
|
|
287
|
+
ctx.meta = ctx.meta || {};
|
|
288
|
+
ctx.meta.deferredToolsRestoreStats = { action: "skipped", reason: "no-cwd" };
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const key = deriveSnapshotKey(cwd);
|
|
292
|
+
|
|
293
|
+
// 2-4. Locate the deferred-tools block.
|
|
294
|
+
const found = findDeferredToolsBlockInBody(body);
|
|
295
|
+
if (!found) {
|
|
296
|
+
ctx.meta = ctx.meta || {};
|
|
297
|
+
ctx.meta.deferredToolsRestoreStats = { action: "skipped", reason: "no-block", key };
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const dir = getSnapshotDir();
|
|
302
|
+
const hasUnavail = found.text.includes(UNAVAILABLE_MARKER);
|
|
303
|
+
|
|
304
|
+
if (!hasUnavail) {
|
|
305
|
+
// 5. Clean baseline → persist.
|
|
306
|
+
const result = await persistDeferredTools(found.text, { dir, key });
|
|
307
|
+
ctx.meta = ctx.meta || {};
|
|
308
|
+
ctx.meta.deferredToolsRestoreStats = {
|
|
309
|
+
action: result.persisted ? "persisted" : "skipped",
|
|
310
|
+
bytes: result.bytes,
|
|
311
|
+
key,
|
|
312
|
+
};
|
|
313
|
+
if (result.persisted) {
|
|
314
|
+
process.stderr.write(
|
|
315
|
+
`[deferred-tools-restore] persisted ${result.bytes} bytes (key=${key})\n`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 6. Block has UNAVAILABLE marker → attempt restore.
|
|
322
|
+
const snapshot = await restoreDeferredTools({ dir, key });
|
|
323
|
+
if (!snapshot) {
|
|
324
|
+
ctx.meta = ctx.meta || {};
|
|
325
|
+
ctx.meta.deferredToolsRestoreStats = { action: "skipped", reason: "no-snapshot", key };
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Strictly-longer guard. Equal-length snapshots are not restored.
|
|
330
|
+
if (snapshot.length <= found.text.length) {
|
|
331
|
+
ctx.meta = ctx.meta || {};
|
|
332
|
+
ctx.meta.deferredToolsRestoreStats = {
|
|
333
|
+
action: "skipped",
|
|
334
|
+
reason: "snapshot-not-longer",
|
|
335
|
+
key,
|
|
336
|
+
snapshotBytes: snapshot.length,
|
|
337
|
+
currentBytes: found.text.length,
|
|
338
|
+
};
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Substitute. Build new content array and new message; do not mutate
|
|
343
|
+
// the original arrays / objects.
|
|
344
|
+
const targetMsg = body.messages[found.msgIdx];
|
|
345
|
+
const newContent = targetMsg.content.slice();
|
|
346
|
+
newContent[found.blockIdx] = { ...newContent[found.blockIdx], text: snapshot };
|
|
347
|
+
body.messages[found.msgIdx] = { ...targetMsg, content: newContent };
|
|
348
|
+
|
|
349
|
+
ctx.meta = ctx.meta || {};
|
|
350
|
+
ctx.meta.deferredToolsRestoreStats = {
|
|
351
|
+
action: "restored",
|
|
352
|
+
bytes: snapshot.length,
|
|
353
|
+
previousBytes: found.text.length,
|
|
354
|
+
key,
|
|
355
|
+
};
|
|
356
|
+
process.stderr.write(
|
|
357
|
+
`[deferred-tools-restore] restored ${found.text.length}→${snapshot.length} bytes ` +
|
|
358
|
+
`at msg[${found.msgIdx}].content[${found.blockIdx}] (key=${key})\n`,
|
|
359
|
+
);
|
|
360
|
+
},
|
|
361
|
+
};
|
|
@@ -88,6 +88,8 @@ function stabilizeFingerprint(system, messages) {
|
|
|
88
88
|
return { attrIdx, newText, oldFingerprint, stableFingerprint };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export { computeFingerprint, extractRealUserMessageText, extractFirstMessageText, stabilizeFingerprint };
|
|
92
|
+
|
|
91
93
|
export default {
|
|
92
94
|
name: "fingerprint-strip",
|
|
93
95
|
description: "Stabilize cc_version fingerprint in system prompt for cache prefix consistency",
|
|
@@ -86,6 +86,8 @@ function fixBlockText(blockType, text) {
|
|
|
86
86
|
return pinBlockContent(blockType, fixed);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export { isSystemReminder, isHooksBlock, isSkillsBlock, isDeferredToolsBlock, isMcpBlock, isRelocatableBlock, isClearArtifact, sortSkillsBlock, sortDeferredToolsBlock, stripSessionKnowledge, pinBlockContent, getBlockType, fixBlockText };
|
|
90
|
+
|
|
89
91
|
export default {
|
|
90
92
|
name: "fresh-session-sort",
|
|
91
93
|
description: "Relocate scattered blocks to messages[0] in deterministic fresh-session order",
|
|
@@ -76,6 +76,8 @@ function isBookkeepingReminder(text) {
|
|
|
76
76
|
return false;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export { pinBlockContent, stripSessionKnowledge, normalizeSessionStartText, isContinueTrailerBlock, isBookkeepingReminder };
|
|
80
|
+
|
|
79
81
|
export default {
|
|
80
82
|
name: "identity-normalization",
|
|
81
83
|
description: "Normalize volatile identity fields (SessionStart, Continue trailers, bookkeeping) for cache stability",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const KEEP_LAST = parseInt(process.env.CACHE_FIX_IMAGE_KEEP_LAST || "0", 10);
|
|
2
|
+
const PLACEHOLDER = "[image stripped from history — file may still be on disk]";
|
|
3
|
+
|
|
4
|
+
function stripOldToolResultImages(messages, keepLast) {
|
|
5
|
+
if (!keepLast || keepLast <= 0 || !Array.isArray(messages)) {
|
|
6
|
+
return { messages, stats: null };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const userMsgIndices = [];
|
|
10
|
+
for (let i = 0; i < messages.length; i++) {
|
|
11
|
+
if (messages[i].role === "user") userMsgIndices.push(i);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (userMsgIndices.length <= keepLast) {
|
|
15
|
+
return { messages, stats: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cutoffIdx = userMsgIndices[userMsgIndices.length - keepLast];
|
|
19
|
+
|
|
20
|
+
let strippedCount = 0;
|
|
21
|
+
let strippedBytes = 0;
|
|
22
|
+
|
|
23
|
+
const result = messages.map((msg, msgIdx) => {
|
|
24
|
+
if (msg.role !== "user" || msgIdx >= cutoffIdx || !Array.isArray(msg.content)) {
|
|
25
|
+
return msg;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let msgModified = false;
|
|
29
|
+
const newContent = msg.content.map((block) => {
|
|
30
|
+
if (block.type === "tool_result" && Array.isArray(block.content)) {
|
|
31
|
+
let toolModified = false;
|
|
32
|
+
const newToolContent = block.content.map((item) => {
|
|
33
|
+
if (item.type === "image") {
|
|
34
|
+
strippedCount++;
|
|
35
|
+
if (item.source?.data) {
|
|
36
|
+
strippedBytes += item.source.data.length;
|
|
37
|
+
}
|
|
38
|
+
toolModified = true;
|
|
39
|
+
return { type: "text", text: PLACEHOLDER };
|
|
40
|
+
}
|
|
41
|
+
return item;
|
|
42
|
+
});
|
|
43
|
+
if (toolModified) {
|
|
44
|
+
msgModified = true;
|
|
45
|
+
return { ...block, content: newToolContent };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return block;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return msgModified ? { ...msg, content: newContent } : msg;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const stats = strippedCount > 0
|
|
55
|
+
? { strippedCount, strippedBytes, estimatedTokens: Math.ceil(strippedBytes * 0.125) }
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
return { messages: strippedCount > 0 ? result : messages, stats };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { stripOldToolResultImages, PLACEHOLDER };
|
|
62
|
+
|
|
63
|
+
export default {
|
|
64
|
+
name: "image-strip",
|
|
65
|
+
description: "Strip base64 images from old tool results to reduce token waste",
|
|
66
|
+
enabled: false,
|
|
67
|
+
order: 150,
|
|
68
|
+
|
|
69
|
+
async onRequest(ctx) {
|
|
70
|
+
const keepLast = parseInt(ctx.meta.imageKeepLast ?? KEEP_LAST, 10);
|
|
71
|
+
if (!keepLast || keepLast <= 0) return;
|
|
72
|
+
if (!ctx.body.messages) return;
|
|
73
|
+
|
|
74
|
+
const { messages, stats } = stripOldToolResultImages(ctx.body.messages, keepLast);
|
|
75
|
+
if (stats) {
|
|
76
|
+
ctx.body.messages = messages;
|
|
77
|
+
ctx.meta.imageStripStats = stats;
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
`[image-strip] stripped ${stats.strippedCount} images (~${stats.estimatedTokens} tokens saved)\n`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const SECTION_HEADER = "# Output efficiency";
|
|
2
|
+
const REPLACEMENT_RAW = process.env.CACHE_FIX_OUTPUT_EFFICIENCY_REPLACEMENT || "";
|
|
3
|
+
|
|
4
|
+
function normalizeReplacement(text) {
|
|
5
|
+
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
6
|
+
if (!trimmed) return "";
|
|
7
|
+
return trimmed.startsWith(SECTION_HEADER) ? trimmed : `${SECTION_HEADER}\n\n${trimmed}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function replaceSection(text, replacement) {
|
|
11
|
+
const start = text.indexOf(SECTION_HEADER);
|
|
12
|
+
if (start === -1) return null;
|
|
13
|
+
|
|
14
|
+
const afterHeader = start + SECTION_HEADER.length;
|
|
15
|
+
const remainder = text.slice(afterHeader);
|
|
16
|
+
const nextHeadingMatch = remainder.match(/\n# [^\n]+/);
|
|
17
|
+
|
|
18
|
+
if (!nextHeadingMatch || nextHeadingMatch.index == null) {
|
|
19
|
+
return text.slice(0, start) + replacement;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const nextHeadingStart = afterHeader + nextHeadingMatch.index + 1;
|
|
23
|
+
return text.slice(0, start) + replacement + "\n\n" + text.slice(nextHeadingStart);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rewriteOutputEfficiency(system, replacement) {
|
|
27
|
+
if (!Array.isArray(system) || !replacement) return null;
|
|
28
|
+
|
|
29
|
+
let changed = false;
|
|
30
|
+
const rewritten = system.map((block) => {
|
|
31
|
+
if (block?.type !== "text" || typeof block.text !== "string" || !block.text.includes(SECTION_HEADER)) {
|
|
32
|
+
return block;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const nextText = replaceSection(block.text, replacement);
|
|
36
|
+
if (!nextText || nextText === block.text) return block;
|
|
37
|
+
|
|
38
|
+
changed = true;
|
|
39
|
+
return { ...block, text: nextText };
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return changed ? rewritten : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { normalizeReplacement, replaceSection, rewriteOutputEfficiency, SECTION_HEADER };
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
name: "output-efficiency-rewrite",
|
|
49
|
+
description: "Replace Claude Code's # Output efficiency system prompt section with custom text",
|
|
50
|
+
enabled: false,
|
|
51
|
+
order: 90,
|
|
52
|
+
|
|
53
|
+
async onRequest(ctx) {
|
|
54
|
+
const raw = ctx.meta.outputEfficiencyReplacement ?? REPLACEMENT_RAW;
|
|
55
|
+
const replacement = normalizeReplacement(raw);
|
|
56
|
+
if (!replacement) return;
|
|
57
|
+
if (!ctx.body.system) return;
|
|
58
|
+
|
|
59
|
+
const result = rewriteOutputEfficiency(ctx.body.system, replacement);
|
|
60
|
+
if (result) {
|
|
61
|
+
ctx.body.system = result;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|