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,277 @@
|
|
|
1
|
+
// prefix-diff — diagnostic extension for hunting cache-bust sources.
|
|
2
|
+
//
|
|
3
|
+
// On every request, snapshots a small projection of the prefix (system
|
|
4
|
+
// prompt + tools + first 5 messages) and writes it to
|
|
5
|
+
// `~/.claude/cache-fix-snapshots/<key>-last.json`. If a prior snapshot
|
|
6
|
+
// exists and differs, also writes a `<key>-diff.json` and emits a
|
|
7
|
+
// one-line stderr summary.
|
|
8
|
+
//
|
|
9
|
+
// No request mutation. The diagnostic is fail-open: any I/O error is
|
|
10
|
+
// swallowed silently in production. Set CACHE_FIX_DEBUG=1 to also log
|
|
11
|
+
// swallowed errors so silent failures stay observable.
|
|
12
|
+
//
|
|
13
|
+
// Adaptation from preload's `snapshotPrefix(payload)` (preload.mjs ~1656):
|
|
14
|
+
// preload fired the diff once per process restart. The proxy is long-lived
|
|
15
|
+
// and supports hot-reload, so we drop the "first call" gate and run the
|
|
16
|
+
// diff per call. Trade-off: more disk writes, but each is tiny and the
|
|
17
|
+
// diagnostic value is higher (drift visible across every turn, not just
|
|
18
|
+
// at startup).
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
mkdir as _mkdir,
|
|
22
|
+
readFile as _readFile,
|
|
23
|
+
writeFile as _writeFile,
|
|
24
|
+
rename as _rename,
|
|
25
|
+
} from "node:fs/promises";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
|
|
30
|
+
const ENABLED = process.env.CACHE_FIX_PREFIXDIFF === "1";
|
|
31
|
+
const DEBUG = process.env.CACHE_FIX_DEBUG === "1";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_FS = {
|
|
34
|
+
mkdir: _mkdir,
|
|
35
|
+
readFile: _readFile,
|
|
36
|
+
writeFile: _writeFile,
|
|
37
|
+
rename: _rename,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function getSnapshotDir() {
|
|
41
|
+
return join(homedir(), ".claude", "cache-fix-snapshots");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function debug(msg) {
|
|
45
|
+
if (DEBUG) process.stderr.write(`[prefix-diff] ${msg}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function computeSessionKey(system) {
|
|
49
|
+
return createHash("sha256")
|
|
50
|
+
.update(JSON.stringify(system).slice(0, 2000))
|
|
51
|
+
.digest("hex")
|
|
52
|
+
.slice(0, 12);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function computeToolsHash(tools) {
|
|
56
|
+
if (!Array.isArray(tools) || tools.length === 0) return "none";
|
|
57
|
+
// Match preload behavior: hash unsorted tool names so order changes
|
|
58
|
+
// surface as hash mismatches (a real cache-bust signal).
|
|
59
|
+
return createHash("sha256")
|
|
60
|
+
.update(JSON.stringify(tools.map((t) => t?.name ?? "")))
|
|
61
|
+
.digest("hex")
|
|
62
|
+
.slice(0, 16);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function computeSystemHash(system) {
|
|
66
|
+
if (!system) return "none";
|
|
67
|
+
return createHash("sha256")
|
|
68
|
+
.update(JSON.stringify(system))
|
|
69
|
+
.digest("hex")
|
|
70
|
+
.slice(0, 16);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Project the first 5 user/assistant messages: strip cache_control,
|
|
74
|
+
// truncate text >500 chars with `...[N chars]` marker. Pure: returns
|
|
75
|
+
// new objects, never mutates input.
|
|
76
|
+
function truncatePrefixMessages(messages) {
|
|
77
|
+
if (!Array.isArray(messages)) return [];
|
|
78
|
+
return messages.slice(0, 5).map((msg) => {
|
|
79
|
+
if (!msg || !Array.isArray(msg.content)) {
|
|
80
|
+
return { role: msg?.role, content: msg?.content };
|
|
81
|
+
}
|
|
82
|
+
const cleanedContent = msg.content.map((block) => {
|
|
83
|
+
if (!block || typeof block !== "object") return block;
|
|
84
|
+
const { cache_control, ...rest } = block;
|
|
85
|
+
if (typeof rest.text === "string" && rest.text.length > 500) {
|
|
86
|
+
return {
|
|
87
|
+
...rest,
|
|
88
|
+
text: rest.text.slice(0, 500) + `...[${rest.text.length} chars]`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return rest;
|
|
92
|
+
});
|
|
93
|
+
return { role: msg.role, content: cleanedContent };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildSnapshot(payload) {
|
|
98
|
+
if (!payload || !payload.system) return null;
|
|
99
|
+
return {
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
messageCount: Array.isArray(payload.messages) ? payload.messages.length : 0,
|
|
102
|
+
toolsHash: computeToolsHash(payload.tools),
|
|
103
|
+
systemHash: computeSystemHash(payload.system),
|
|
104
|
+
prefixMessages: truncatePrefixMessages(payload.messages),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function computeDiff(prev, current) {
|
|
109
|
+
const diff = {
|
|
110
|
+
timestamp: current.timestamp,
|
|
111
|
+
prevTimestamp: prev.timestamp,
|
|
112
|
+
toolsMatch: prev.toolsHash === current.toolsHash,
|
|
113
|
+
systemMatch: prev.systemHash === current.systemHash,
|
|
114
|
+
messageCountPrev: prev.messageCount,
|
|
115
|
+
messageCountNow: current.messageCount,
|
|
116
|
+
prefixDiffs: [],
|
|
117
|
+
};
|
|
118
|
+
const prevMsgs = Array.isArray(prev.prefixMessages) ? prev.prefixMessages : [];
|
|
119
|
+
const nowMsgs = Array.isArray(current.prefixMessages) ? current.prefixMessages : [];
|
|
120
|
+
const maxIdx = Math.max(prevMsgs.length, nowMsgs.length);
|
|
121
|
+
for (let i = 0; i < maxIdx; i++) {
|
|
122
|
+
const prevSer = JSON.stringify(prevMsgs[i] ?? null);
|
|
123
|
+
const nowSer = JSON.stringify(nowMsgs[i] ?? null);
|
|
124
|
+
if (prevSer !== nowSer) {
|
|
125
|
+
diff.prefixDiffs.push({
|
|
126
|
+
index: i,
|
|
127
|
+
prev: prevMsgs[i] ?? null,
|
|
128
|
+
now: nowMsgs[i] ?? null,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return diff;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function diffHasChanges(diff) {
|
|
136
|
+
return (
|
|
137
|
+
diff.prefixDiffs.length > 0 ||
|
|
138
|
+
!diff.toolsMatch ||
|
|
139
|
+
!diff.systemMatch ||
|
|
140
|
+
diff.messageCountPrev !== diff.messageCountNow
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Atomic write: stage to a unique-per-invocation .tmp, then rename to
|
|
145
|
+
// final path. The unique suffix is essential under concurrency — two
|
|
146
|
+
// parallel callers writing to the same finalPath would otherwise share
|
|
147
|
+
// a single .tmp and corrupt each other's content.
|
|
148
|
+
//
|
|
149
|
+
// On rename failure the prior final-path file (if any) remains intact.
|
|
150
|
+
// The orphan .tmp persists on disk — because each invocation uses a
|
|
151
|
+
// unique temp name, later calls do NOT implicitly overwrite it. This is
|
|
152
|
+
// a small leak (accepted: failures are rare, files are tiny) rather than
|
|
153
|
+
// a correctness issue. A follow-up could add best-effort cleanup.
|
|
154
|
+
async function atomicWriteJson(finalPath, obj, fs) {
|
|
155
|
+
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random()
|
|
156
|
+
.toString(36)
|
|
157
|
+
.slice(2, 10)}.tmp`;
|
|
158
|
+
await fs.writeFile(tmpPath, JSON.stringify(obj, null, 2));
|
|
159
|
+
await fs.rename(tmpPath, finalPath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Snapshot the prefix of `payload` and diff against the prior snapshot.
|
|
164
|
+
*
|
|
165
|
+
* Pure-ish: never throws, never mutates `payload`. All I/O is gated by
|
|
166
|
+
* try/catch; failures are debug-logged when CACHE_FIX_DEBUG=1.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} payload The request body (system, tools, messages).
|
|
169
|
+
* @param {object} options
|
|
170
|
+
* @param {string} [options.dir] Snapshot directory. Defaults to ~/.claude/cache-fix-snapshots.
|
|
171
|
+
* @param {object} [options.fs] fs/promises overrides for tests:
|
|
172
|
+
* { mkdir, readFile, writeFile, rename }.
|
|
173
|
+
* Any subset replaces the corresponding default.
|
|
174
|
+
* @returns {Promise<{ key, wroteSnapshot, wroteDiff } | null>} Result for tests; null if no system.
|
|
175
|
+
*/
|
|
176
|
+
async function snapshotPrefix(payload, options = {}) {
|
|
177
|
+
const current = buildSnapshot(payload);
|
|
178
|
+
if (!current) return null;
|
|
179
|
+
|
|
180
|
+
const dir = options.dir || getSnapshotDir();
|
|
181
|
+
const fs = { ...DEFAULT_FS, ...(options.fs || {}) };
|
|
182
|
+
|
|
183
|
+
const sessionKey = computeSessionKey(payload.system);
|
|
184
|
+
const lastPath = join(dir, `${sessionKey}-last.json`);
|
|
185
|
+
const diffPath = join(dir, `${sessionKey}-diff.json`);
|
|
186
|
+
|
|
187
|
+
// Ensure directory exists. mkdir failure aborts — without dir, nothing
|
|
188
|
+
// else can succeed.
|
|
189
|
+
try {
|
|
190
|
+
await fs.mkdir(dir, { recursive: true });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
debug(`mkdir failed for ${dir}: ${err?.message ?? err}`);
|
|
193
|
+
return { key: sessionKey, wroteSnapshot: false, wroteDiff: false };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Read prior snapshot if it exists. Missing file is normal; corrupt
|
|
197
|
+
// file is treated as no prior (skip diff, proceed to overwrite).
|
|
198
|
+
let prev = null;
|
|
199
|
+
try {
|
|
200
|
+
const txt = await fs.readFile(lastPath, "utf-8");
|
|
201
|
+
prev = JSON.parse(txt);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err && err.code !== "ENOENT") {
|
|
204
|
+
debug(`prior snapshot unreadable at ${lastPath}: ${err?.message ?? err}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Compute and write diff if anything changed.
|
|
209
|
+
let wroteDiff = false;
|
|
210
|
+
if (prev) {
|
|
211
|
+
const diff = computeDiff(prev, current);
|
|
212
|
+
if (diffHasChanges(diff)) {
|
|
213
|
+
try {
|
|
214
|
+
await atomicWriteJson(diffPath, diff, fs);
|
|
215
|
+
wroteDiff = true;
|
|
216
|
+
// Always log the summary line when a diff fires (not just under
|
|
217
|
+
// CACHE_FIX_DEBUG) — this is the diagnostic's whole purpose.
|
|
218
|
+
process.stderr.write(
|
|
219
|
+
`[prefix-diff] ${sessionKey}: ${diff.prefixDiffs.length} differences, ` +
|
|
220
|
+
`tools=${diff.toolsMatch ? "match" : "DIFFER"}, ` +
|
|
221
|
+
`system=${diff.systemMatch ? "match" : "DIFFER"}, ` +
|
|
222
|
+
`messages=${diff.messageCountPrev}→${diff.messageCountNow}\n`,
|
|
223
|
+
);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
debug(`diff write failed at ${diffPath}: ${err?.message ?? err}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Always write the new snapshot atomically so the next call has a
|
|
231
|
+
// fresh baseline. On failure, prior snapshot is intact.
|
|
232
|
+
let wroteSnapshot = false;
|
|
233
|
+
try {
|
|
234
|
+
await atomicWriteJson(lastPath, current, fs);
|
|
235
|
+
wroteSnapshot = true;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
debug(`snapshot write failed at ${lastPath}: ${err?.message ?? err}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { key: sessionKey, wroteSnapshot, wroteDiff };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The named exports below are internal test seams, not part of the
|
|
244
|
+
// proxy extension contract. Pipeline loading consumes only `default`.
|
|
245
|
+
// They're exposed so tests can call the helpers directly with their own
|
|
246
|
+
// options (tmpdir, failing fs mocks) instead of mutating process env or
|
|
247
|
+
// monkey-patching node:fs/promises at module scope.
|
|
248
|
+
export {
|
|
249
|
+
snapshotPrefix,
|
|
250
|
+
buildSnapshot,
|
|
251
|
+
computeDiff,
|
|
252
|
+
computeSessionKey,
|
|
253
|
+
truncatePrefixMessages,
|
|
254
|
+
diffHasChanges,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export default {
|
|
258
|
+
name: "prefix-diff",
|
|
259
|
+
description:
|
|
260
|
+
"Snapshot prefix (first 5 msgs + system + tools) and diff against previous run for cache-bust hunting",
|
|
261
|
+
// Always loaded; gated at runtime by CACHE_FIX_PREFIXDIFF=1 inside onRequest.
|
|
262
|
+
// This matches the acceptance criteria (env var alone activates) — the
|
|
263
|
+
// extension is cheap to load (one no-op check per request when disabled).
|
|
264
|
+
enabled: true,
|
|
265
|
+
order: 680,
|
|
266
|
+
|
|
267
|
+
async onRequest(ctx) {
|
|
268
|
+
if (!ENABLED) return;
|
|
269
|
+
if (!ctx || !ctx.body) return;
|
|
270
|
+
// snapshotPrefix never throws; double-belt try/catch is defense in depth.
|
|
271
|
+
try {
|
|
272
|
+
await snapshotPrefix(ctx.body);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
debug(`onRequest unexpected: ${err?.message ?? err}`);
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const TRAILING_SMOOSH = /\n\n(<system-reminder>\n(?:(?!<\/system-reminder>)[\s\S])*?\n<\/system-reminder>)\s*$/;
|
|
2
|
+
|
|
3
|
+
function splitSmooshedReminders(messages) {
|
|
4
|
+
if (!Array.isArray(messages)) return { messages, stats: null };
|
|
5
|
+
|
|
6
|
+
let totalPeeled = 0;
|
|
7
|
+
|
|
8
|
+
const result = messages.map((msg) => {
|
|
9
|
+
if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
|
|
10
|
+
|
|
11
|
+
const out = [];
|
|
12
|
+
const peeledReminders = [];
|
|
13
|
+
let mutated = false;
|
|
14
|
+
|
|
15
|
+
for (const block of msg.content) {
|
|
16
|
+
if (block?.type === "tool_result" && typeof block.content === "string") {
|
|
17
|
+
const reminders = [];
|
|
18
|
+
let s = block.content;
|
|
19
|
+
while (true) {
|
|
20
|
+
const m = s.match(TRAILING_SMOOSH);
|
|
21
|
+
if (!m) break;
|
|
22
|
+
reminders.unshift(m[1]);
|
|
23
|
+
s = s.slice(0, m.index);
|
|
24
|
+
}
|
|
25
|
+
if (reminders.length > 0) {
|
|
26
|
+
out.push({ ...block, content: s });
|
|
27
|
+
for (const r of reminders) peeledReminders.push({ type: "text", text: r });
|
|
28
|
+
totalPeeled += reminders.length;
|
|
29
|
+
mutated = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
out.push(block);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (mutated) {
|
|
37
|
+
return { ...msg, content: [...out, ...peeledReminders] };
|
|
38
|
+
}
|
|
39
|
+
return msg;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
messages: totalPeeled > 0 ? result : messages,
|
|
44
|
+
stats: totalPeeled > 0 ? { peeled: totalPeeled } : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { splitSmooshedReminders, TRAILING_SMOOSH };
|
|
49
|
+
|
|
50
|
+
export default {
|
|
51
|
+
name: "smoosh-split",
|
|
52
|
+
description: "Peel smooshed system-reminders from tool_result content into standalone blocks",
|
|
53
|
+
enabled: false,
|
|
54
|
+
order: 320,
|
|
55
|
+
|
|
56
|
+
async onRequest(ctx) {
|
|
57
|
+
if (!ctx.body.messages) return;
|
|
58
|
+
|
|
59
|
+
const { messages, stats } = splitSmooshedReminders(ctx.body.messages);
|
|
60
|
+
if (stats) {
|
|
61
|
+
ctx.body.messages = messages;
|
|
62
|
+
ctx.meta.smooshSplitStats = stats;
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`[smoosh-split] peeled ${stats.peeled} reminder(s) from tool_result.content\n`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -28,6 +28,8 @@ function isDeferredToolsBlock(text) {
|
|
|
28
28
|
return typeof text === "string" && text.includes("deferred tools are now available");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export { sortSkillsBlock, sortDeferredToolsBlock, isSkillsBlock, isDeferredToolsBlock };
|
|
32
|
+
|
|
31
33
|
export default {
|
|
32
34
|
name: "sort-stabilization",
|
|
33
35
|
description: "Deterministic ordering of skills, deferred tools, and tool definitions",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
function normalizeToolUseInputs(body) {
|
|
2
|
+
if (!body || typeof body !== "object") return 0;
|
|
3
|
+
if (!Array.isArray(body.messages) || !Array.isArray(body.tools)) return 0;
|
|
4
|
+
|
|
5
|
+
const toolSchemas = Object.create(null);
|
|
6
|
+
for (const tool of body.tools) {
|
|
7
|
+
if (!tool || typeof tool !== "object") continue;
|
|
8
|
+
const name = tool.name;
|
|
9
|
+
if (typeof name !== "string") continue;
|
|
10
|
+
const props = tool.input_schema?.properties;
|
|
11
|
+
if (!props || typeof props !== "object") continue;
|
|
12
|
+
toolSchemas[name] = Object.keys(props);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let modified = 0;
|
|
16
|
+
for (const msg of body.messages) {
|
|
17
|
+
if (!msg || msg.role !== "assistant") continue;
|
|
18
|
+
if (!Array.isArray(msg.content)) continue;
|
|
19
|
+
for (let i = 0; i < msg.content.length; i++) {
|
|
20
|
+
const block = msg.content[i];
|
|
21
|
+
if (!block || block.type !== "tool_use") continue;
|
|
22
|
+
if (!block.input || typeof block.input !== "object" || Array.isArray(block.input)) continue;
|
|
23
|
+
const schemaKeys = toolSchemas[block.name];
|
|
24
|
+
if (!schemaKeys) continue;
|
|
25
|
+
|
|
26
|
+
const currentKeys = Object.keys(block.input);
|
|
27
|
+
const schemaKeySet = new Set(schemaKeys);
|
|
28
|
+
const hasExtras = currentKeys.some((k) => !schemaKeySet.has(k));
|
|
29
|
+
|
|
30
|
+
const presentSchemaKeys = schemaKeys.filter((k) =>
|
|
31
|
+
Object.prototype.hasOwnProperty.call(block.input, k)
|
|
32
|
+
);
|
|
33
|
+
const currentInSchema = currentKeys.filter((k) => schemaKeySet.has(k));
|
|
34
|
+
|
|
35
|
+
let orderDiffers = presentSchemaKeys.length !== currentInSchema.length;
|
|
36
|
+
if (!orderDiffers) {
|
|
37
|
+
for (let j = 0; j < presentSchemaKeys.length; j++) {
|
|
38
|
+
if (presentSchemaKeys[j] !== currentInSchema[j]) {
|
|
39
|
+
orderDiffers = true;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!hasExtras && !orderDiffers) continue;
|
|
46
|
+
|
|
47
|
+
const newInput = {};
|
|
48
|
+
for (const k of presentSchemaKeys) {
|
|
49
|
+
newInput[k] = block.input[k];
|
|
50
|
+
}
|
|
51
|
+
msg.content[i] = { ...block, input: newInput };
|
|
52
|
+
modified++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return modified;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { normalizeToolUseInputs };
|
|
59
|
+
|
|
60
|
+
export default {
|
|
61
|
+
name: "tool-input-normalize",
|
|
62
|
+
description: "Normalize tool_use input field ordering to match schema for cache stability",
|
|
63
|
+
enabled: false,
|
|
64
|
+
order: 280,
|
|
65
|
+
|
|
66
|
+
async onRequest(ctx) {
|
|
67
|
+
if (!ctx.body.messages || !ctx.body.tools) return;
|
|
68
|
+
const count = normalizeToolUseInputs(ctx.body);
|
|
69
|
+
if (count > 0) {
|
|
70
|
+
ctx.meta.toolInputNormalizeCount = count;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const LOG_PATH = process.env.CACHE_FIX_USAGE_LOG || join(homedir(), ".claude", "usage.jsonl");
|
|
6
|
+
|
|
7
|
+
function buildRecord(meta, telemetry, responseHeaders) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const utcHour = now.getUTCHours();
|
|
10
|
+
const utcDay = now.getUTCDay();
|
|
11
|
+
|
|
12
|
+
const stats = meta.cacheStats || {};
|
|
13
|
+
const quota = meta._quotaData || {};
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
timestamp: now.toISOString(),
|
|
17
|
+
model: telemetry.model || "unknown",
|
|
18
|
+
input_tokens: stats.inputTokens || 0,
|
|
19
|
+
output_tokens: stats.outputTokens || 0,
|
|
20
|
+
cache_read_input_tokens: stats.cacheRead || 0,
|
|
21
|
+
cache_creation_input_tokens: stats.cacheCreation || 0,
|
|
22
|
+
q5h_pct: quota.five_hour ? quota.five_hour.pct : null,
|
|
23
|
+
q7d_pct: quota.seven_day ? quota.seven_day.pct : null,
|
|
24
|
+
peak_hour: utcDay >= 1 && utcDay <= 5 && utcHour >= 13 && utcHour < 19,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { buildRecord, LOG_PATH };
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
name: "usage-log",
|
|
32
|
+
description: "Append per-call usage record to ~/.claude/usage.jsonl",
|
|
33
|
+
enabled: false,
|
|
34
|
+
order: 650,
|
|
35
|
+
|
|
36
|
+
async onStreamEvent(ctx) {
|
|
37
|
+
if (!ctx.event || ctx.event.type !== "message_delta" || !ctx.event.usage) return;
|
|
38
|
+
|
|
39
|
+
const record = buildRecord(ctx.meta, ctx.telemetry || {}, ctx.responseHeaders);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await mkdir(join(homedir(), ".claude"), { recursive: true });
|
|
43
|
+
await appendFile(LOG_PATH, JSON.stringify(record) + "\n");
|
|
44
|
+
} catch {}
|
|
45
|
+
},
|
|
46
|
+
};
|
package/proxy/extensions.json
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
"sort-stabilization": { "enabled": true, "order": 200 },
|
|
4
4
|
"fresh-session-sort": { "enabled": true, "order": 250 },
|
|
5
5
|
"identity-normalization": { "enabled": true, "order": 300 },
|
|
6
|
+
"smoosh-split": { "enabled": true, "order": 320 },
|
|
7
|
+
"content-strip": { "enabled": true, "order": 330 },
|
|
8
|
+
"tool-input-normalize": { "enabled": true, "order": 340 },
|
|
6
9
|
"cache-control-normalize": { "enabled": true, "order": 400 },
|
|
7
10
|
"ttl-management": { "enabled": true, "order": 500 },
|
|
8
11
|
"cache-telemetry": { "enabled": true, "order": 600 },
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Claude Code Cache Fix Proxy — health check + auto-restart
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=oneshot
|
|
7
|
+
ExecStart=/bin/sh -c 'curl -fs --max-time 3 http://127.0.0.1:{{PORT}}/health > /dev/null || systemctl --user start cache-fix-proxy.service'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Claude Code Cache Fix Proxy — health check timer (every 2 min)
|
|
3
|
+
Documentation=https://github.com/cnighswonger/claude-code-cache-fix
|
|
4
|
+
|
|
5
|
+
[Timer]
|
|
6
|
+
# Run 30s after boot, then every 2 minutes thereafter. Off-round minute
|
|
7
|
+
# so the firing distribution doesn't pile up on :00.
|
|
8
|
+
OnBootSec=30s
|
|
9
|
+
OnUnitActiveSec=2min
|
|
10
|
+
AccuracySec=15s
|
|
11
|
+
Unit=cache-fix-proxy-healthcheck.service
|
|
12
|
+
|
|
13
|
+
[Install]
|
|
14
|
+
WantedBy=timers.target
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Claude Code Cache Fix Proxy
|
|
3
|
+
After=network.target
|
|
4
|
+
{{REQUIRES_LINE}}
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
ExecStart={{NODE}} {{SERVER_PATH}}
|
|
9
|
+
Restart=on-failure
|
|
10
|
+
RestartSec=5
|
|
11
|
+
Environment=CACHE_FIX_PROXY_PORT={{PORT}}
|
|
12
|
+
{{UPSTREAM_LINE}}
|
|
13
|
+
{{DEBUG_LINE}}
|
|
14
|
+
WorkingDirectory={{WORKING_DIR}}
|
|
15
|
+
|
|
16
|
+
[Install]
|
|
17
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>com.cnighswonger.cache-fix-proxy</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
<string>{{NODE}}</string>
|
|
10
|
+
<string>{{SERVER_PATH}}</string>
|
|
11
|
+
</array>
|
|
12
|
+
<key>EnvironmentVariables</key>
|
|
13
|
+
<dict>
|
|
14
|
+
<key>CACHE_FIX_PROXY_PORT</key>
|
|
15
|
+
<string>{{PORT}}</string>
|
|
16
|
+
{{UPSTREAM_PLIST}}
|
|
17
|
+
{{DEBUG_PLIST}}
|
|
18
|
+
</dict>
|
|
19
|
+
<key>WorkingDirectory</key>
|
|
20
|
+
<string>{{WORKING_DIR}}</string>
|
|
21
|
+
<key>RunAtLoad</key>
|
|
22
|
+
<true/>
|
|
23
|
+
<key>KeepAlive</key>
|
|
24
|
+
<dict>
|
|
25
|
+
<key>SuccessfulExit</key>
|
|
26
|
+
<false/>
|
|
27
|
+
</dict>
|
|
28
|
+
<key>StandardOutPath</key>
|
|
29
|
+
<string>{{LOG_DIR}}/cache-fix-proxy.log</string>
|
|
30
|
+
<key>StandardErrorPath</key>
|
|
31
|
+
<string>{{LOG_DIR}}/cache-fix-proxy.err</string>
|
|
32
|
+
</dict>
|
|
33
|
+
</plist>
|