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,314 @@
1
+ // messages-cache-breakpoint — inject the missing breakpoint #3 cache_control
2
+ // at the boundary between Claude Code's auto-injected blocks (hooks, skills,
3
+ // project CLAUDE.md, deferred-tools, MCP server descriptions) and the first
4
+ // real user content inside `messages[0]`.
5
+ //
6
+ // Activation: `enabled: true` in extensions.json (always loaded), runtime
7
+ // gates per env var:
8
+ //
9
+ // - CACHE_FIX_INJECT_MESSAGES_BREAKPOINT=1 → opt-in injection
10
+ // - CACHE_FIX_DUMP_MESSAGES_HEAD=<path> → diagnostic-only JSONL dump
11
+ // of messages[0].content shape
12
+ //
13
+ // Order 410 — runs immediately after `cache-control-normalize` (400), so we
14
+ // count markers and place breakpoint #3 against a normalized baseline.
15
+ //
16
+ // See `docs/directives/proxy-messages-cache-breakpoint.md` for the full
17
+ // design (boundary detection algorithm, marker-count guard, telemetry surface).
18
+
19
+ import { appendFile, mkdir } from "node:fs/promises";
20
+ import { dirname } from "node:path";
21
+
22
+ // --- Env gates (read per-call so tests can flip without re-importing) ---
23
+
24
+ function isInjectEnabled() {
25
+ return process.env.CACHE_FIX_INJECT_MESSAGES_BREAKPOINT === "1";
26
+ }
27
+ function getDumpPath() {
28
+ const v = process.env.CACHE_FIX_DUMP_MESSAGES_HEAD;
29
+ return v && v.length > 0 ? v : null;
30
+ }
31
+ function isDebug() {
32
+ return process.env.CACHE_FIX_DEBUG === "1";
33
+ }
34
+
35
+ function debug(msg) {
36
+ if (isDebug()) process.stderr.write(`[messages-breakpoint] DEBUG: ${msg}\n`);
37
+ }
38
+
39
+ // --- Block classification ---
40
+ //
41
+ // Auto-injected block kinds that CC writes into `messages[0].content` ahead of
42
+ // the real user content. Order matters: each block runs through these checks
43
+ // in declaration order and the first match wins. Tightening notes:
44
+ //
45
+ // - Hooks: requires both `<system-reminder>` opening AND `hook success`
46
+ // substring — narrow enough that user prose discussing hook semantics
47
+ // won't false-positive.
48
+ // - Skills: anchored on `<system-reminder>` opening tag; won't match user
49
+ // messages that quote `<available-skills>` from documentation.
50
+ // - CLAUDE.md: regex anchored on absolute-path prefix (`/`); won't match
51
+ // "see CLAUDE.md in the docs".
52
+ // - Deferred-tools: exact `<deferred-tools>` tag substring; won't match
53
+ // user prose about "deferred tools".
54
+ // - MCP: two specific sentinels (`<mcp-resources>` tag OR
55
+ // `Available MCP servers:` literal); won't match generic MCP prose.
56
+
57
+ const CLAUDE_MD_RE = /Contents of \/[^\n]*?CLAUDE\.md/;
58
+
59
+ function getBlockText(block) {
60
+ if (!block || typeof block !== "object") return null;
61
+ if (block.type !== "text") return null;
62
+ if (typeof block.text !== "string") return null;
63
+ return block.text;
64
+ }
65
+
66
+ export function classifyBlock(block) {
67
+ const text = getBlockText(block);
68
+ if (text === null) return "user";
69
+
70
+ // Hooks: <system-reminder> + "hook success"
71
+ if (text.startsWith("<system-reminder>") && text.includes("hook success")) {
72
+ return "hooks";
73
+ }
74
+ // Skills: <system-reminder> + (<available-skills> OR <plugin-skills>)
75
+ if (
76
+ text.startsWith("<system-reminder>") &&
77
+ (text.includes("<available-skills>") || text.includes("<plugin-skills>"))
78
+ ) {
79
+ return "skills";
80
+ }
81
+ // Project CLAUDE.md: <system-reminder> wrapper + absolute-path Contents-of
82
+ // marker. The system-reminder wrapper is required to keep user prose that
83
+ // happens to mention "Contents of /path/to/CLAUDE.md" from matching.
84
+ if (text.includes("<system-reminder>") && CLAUDE_MD_RE.test(text)) {
85
+ return "claude_md";
86
+ }
87
+ // Deferred tools: exact <deferred-tools> tag
88
+ if (text.includes("<deferred-tools>")) {
89
+ return "deferred_tools";
90
+ }
91
+ // MCP: either sentinel
92
+ if (text.includes("<mcp-resources>") || text.includes("Available MCP servers:")) {
93
+ return "mcp_resources";
94
+ }
95
+ return "user";
96
+ }
97
+
98
+ const AUTO_INJECTED_KINDS = new Set([
99
+ "hooks",
100
+ "skills",
101
+ "claude_md",
102
+ "deferred_tools",
103
+ "mcp_resources",
104
+ ]);
105
+
106
+ // Return the LAST index in `content` whose block classifies as auto-injected,
107
+ // or -1 if no auto-injected block is found. Walking the full array (rather
108
+ // than stopping at the first user block) keeps us correct in the defensive
109
+ // case where auto-injected and user blocks are interleaved.
110
+ export function detectAutoInjectedBoundary(content) {
111
+ if (!Array.isArray(content)) return -1;
112
+ let lastIdx = -1;
113
+ for (let i = 0; i < content.length; i++) {
114
+ const kind = classifyBlock(content[i]);
115
+ if (AUTO_INJECTED_KINDS.has(kind)) lastIdx = i;
116
+ }
117
+ return lastIdx;
118
+ }
119
+
120
+ // --- Marker counting ---
121
+
122
+ export function countAllCacheControlMarkers(body) {
123
+ if (!body || typeof body !== "object") return 0;
124
+ let n = 0;
125
+ if (Array.isArray(body.system)) {
126
+ for (const block of body.system) {
127
+ if (block && typeof block === "object" && block.cache_control) n++;
128
+ }
129
+ }
130
+ if (Array.isArray(body.messages)) {
131
+ for (const msg of body.messages) {
132
+ if (!msg || !Array.isArray(msg.content)) continue;
133
+ for (const block of msg.content) {
134
+ if (block && typeof block === "object" && block.cache_control) n++;
135
+ }
136
+ }
137
+ }
138
+ return n;
139
+ }
140
+
141
+ // --- Stats shape (also used as telemetry on ctx.meta) ---
142
+
143
+ function initStats() {
144
+ return {
145
+ enabled: true,
146
+ injected: false,
147
+ boundary_idx: -1,
148
+ boundary_block_kind: null,
149
+ blocks_examined: 0,
150
+ existing_marker_count: 0,
151
+ skip_reason: null,
152
+ };
153
+ }
154
+
155
+ // --- Orchestrator (pure on body — no I/O) ---
156
+
157
+ export function injectMessagesBreakpoint(reqCtx) {
158
+ const stats = initStats();
159
+ if (!reqCtx || !reqCtx.body) {
160
+ stats.skip_reason = "unexpected_role_or_shape";
161
+ return stats;
162
+ }
163
+ const body = reqCtx.body;
164
+ const messages = body.messages;
165
+ if (!Array.isArray(messages) || messages.length === 0) {
166
+ stats.skip_reason = "unexpected_role_or_shape";
167
+ return stats;
168
+ }
169
+ const first = messages[0];
170
+ if (!first || first.role !== "user" || !Array.isArray(first.content)) {
171
+ stats.skip_reason = "unexpected_role_or_shape";
172
+ return stats;
173
+ }
174
+
175
+ const existingMarkers = countAllCacheControlMarkers(body);
176
+ stats.existing_marker_count = existingMarkers;
177
+
178
+ if (existingMarkers === 0) {
179
+ stats.skip_reason = "no_existing_markers";
180
+ return stats;
181
+ }
182
+ if (existingMarkers >= 4) {
183
+ stats.skip_reason = "at_marker_limit";
184
+ if (existingMarkers > 4) {
185
+ process.stderr.write(
186
+ `[messages-breakpoint] warn: existing_markers=${existingMarkers} exceeds Anthropic's documented max of 4\n`,
187
+ );
188
+ }
189
+ return stats;
190
+ }
191
+
192
+ stats.blocks_examined = first.content.length;
193
+ const boundaryIdx = detectAutoInjectedBoundary(first.content);
194
+ stats.boundary_idx = boundaryIdx;
195
+ if (boundaryIdx === -1) {
196
+ stats.skip_reason = "boundary_not_found";
197
+ return stats;
198
+ }
199
+
200
+ const target = first.content[boundaryIdx];
201
+ stats.boundary_block_kind = classifyBlock(target);
202
+
203
+ if (target && target.cache_control) {
204
+ stats.skip_reason = "boundary_already_marked";
205
+ return stats;
206
+ }
207
+
208
+ first.content[boundaryIdx] = {
209
+ ...target,
210
+ cache_control: { type: "ephemeral", ttl: "1h" },
211
+ };
212
+ stats.injected = true;
213
+ return stats;
214
+ }
215
+
216
+ // --- Diagnostic dump ---
217
+ //
218
+ // Dumps the structural shape of messages[0].content (per-block kind, first
219
+ // 200 chars of text, cache_control presence flag) to a JSONL file. Read-only
220
+ // — no body mutation. Independent of injection: a user can enable the dump
221
+ // without enabling injection to gather fixture data first.
222
+
223
+ const DUMP_TEXT_PREFIX_CHARS = 200;
224
+
225
+ export function buildDumpRecord(body, ts = new Date().toISOString()) {
226
+ const messages = body?.messages;
227
+ const first = Array.isArray(messages) ? messages[0] : null;
228
+ const content = first && Array.isArray(first.content) ? first.content : null;
229
+ const blocks = content
230
+ ? content.map((block, idx) => {
231
+ const kind = classifyBlock(block);
232
+ const text = getBlockText(block);
233
+ return {
234
+ idx,
235
+ type: block?.type ?? null,
236
+ kind,
237
+ text_prefix: text === null ? null : text.slice(0, DUMP_TEXT_PREFIX_CHARS),
238
+ has_cache_control: !!(block && block.cache_control),
239
+ };
240
+ })
241
+ : [];
242
+ return {
243
+ ts,
244
+ role: first?.role ?? null,
245
+ block_count: blocks.length,
246
+ existing_marker_count: countAllCacheControlMarkers(body),
247
+ blocks,
248
+ };
249
+ }
250
+
251
+ async function writeDump(path, record) {
252
+ await mkdir(dirname(path), { recursive: true });
253
+ await appendFile(path, JSON.stringify(record) + "\n");
254
+ }
255
+
256
+ // --- Stderr summary ---
257
+
258
+ function emitStderrSummary(stats) {
259
+ if (stats.injected) {
260
+ process.stderr.write(
261
+ `[messages-breakpoint] injected boundary_idx=${stats.boundary_idx} kind=${stats.boundary_block_kind} existing_markers=${stats.existing_marker_count}\n`,
262
+ );
263
+ } else {
264
+ process.stderr.write(
265
+ `[messages-breakpoint] skipped reason=${stats.skip_reason} existing_markers=${stats.existing_marker_count}\n`,
266
+ );
267
+ }
268
+ }
269
+
270
+ // --- Extension contract ---
271
+
272
+ export default {
273
+ name: "messages-cache-breakpoint",
274
+ description:
275
+ "Inject the missing breakpoint #3 cache_control marker at the boundary " +
276
+ "between Claude Code's auto-injected messages[0] blocks (hooks, skills, " +
277
+ "CLAUDE.md, deferred-tools, MCP) and the first real user content",
278
+ enabled: false, // overridden by extensions.json
279
+ order: 410,
280
+
281
+ async onRequest(ctx) {
282
+ const dumpPath = getDumpPath();
283
+ const inject = isInjectEnabled();
284
+
285
+ // Both gates off → no-op. Avoid even building stats so the disabled path
286
+ // is essentially free.
287
+ if (!dumpPath && !inject) return;
288
+
289
+ if (!ctx || !ctx.body) return;
290
+
291
+ // Diagnostic dump runs first and is independent of injection. We dump
292
+ // BEFORE injection so the recorded shape is the request as CC sent it,
293
+ // not as we mutated it.
294
+ if (dumpPath) {
295
+ try {
296
+ const record = buildDumpRecord(ctx.body);
297
+ await writeDump(dumpPath, record);
298
+ } catch (err) {
299
+ debug(`dump write failed: ${err?.message ?? err}`);
300
+ }
301
+ }
302
+
303
+ if (!inject) return;
304
+
305
+ try {
306
+ const stats = injectMessagesBreakpoint(ctx);
307
+ ctx.meta = ctx.meta || {};
308
+ ctx.meta.messagesBreakpointStats = stats;
309
+ emitStderrSummary(stats);
310
+ } catch (err) {
311
+ debug(`onRequest unexpected: ${err?.message ?? err}`);
312
+ }
313
+ },
314
+ };