memtrace 0.3.33 → 0.3.35

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,447 @@
1
+ "use strict";
2
+
3
+ // Claude Code integration for Memtrace.
4
+ //
5
+ // Manages three pieces of user-level Claude Code configuration:
6
+ //
7
+ // 1. ~/.claude/MEMTRACE.md — durable directive that Claude
8
+ // reads as ambient context. Owned
9
+ // entirely by us. We control the
10
+ // full file content; uninstall
11
+ // removes it cleanly.
12
+ //
13
+ // 2. ~/.claude/CLAUDE.md — user-authored. We DO NOT
14
+ // overwrite. We append a single
15
+ // sentinel-bracketed breadcrumb
16
+ // line pointing at MEMTRACE.md,
17
+ // only if the file already exists.
18
+ // User edits outside our markers
19
+ // are preserved on uninstall.
20
+ //
21
+ // 3. ~/.claude/settings.json — JSON. We add hook entries tagged
22
+ // with `_managed_by: "memtrace"`.
23
+ // All other entries pass through
24
+ // untouched. Uninstall walks the
25
+ // hooks arrays and drops only
26
+ // entries with our tag.
27
+ //
28
+ // Pure functions where possible — anything that touches the filesystem
29
+ // is wrapped at the bottom for the install.js / uninstall.js entry
30
+ // points. Pure parts are unit + property tested in
31
+ // `test/claude-integration.test.js`.
32
+
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const os = require("os");
36
+
37
+ // ── Constants ─────────────────────────────────────────────────────────
38
+
39
+ const BLOCK_BEGIN = "<!-- BEGIN MEMTRACE BLOCK · managed by `memtrace install` · remove with `memtrace uninstall` -->";
40
+ const BLOCK_END = "<!-- END MEMTRACE BLOCK -->";
41
+ const BLOCK_VERSION = "0.3.35";
42
+
43
+ const MEMTRACE_MD_HEADER = `<!-- This file is managed by \`memtrace install\`.
44
+ User edits here will be overwritten on next install.
45
+ For your own Claude Code directives, edit CLAUDE.md instead. -->
46
+ `;
47
+
48
+ const MEMTRACE_MD_BODY = `# Memtrace is active on this machine
49
+
50
+ For ANY code-discovery question — finding symbols, callers, impacts,
51
+ evolution, "where is", "how does", "what calls", "why does",
52
+ "trace through", "find the function that…" — invoke
53
+ \`mcp__memtrace__find_symbol\` or \`mcp__memtrace__find_code\` FIRST.
54
+ Memtrace returns exact \`file:start_line:end_line\` in one call.
55
+
56
+ After a search hit, follow up with the graph tools that file/grep can't
57
+ match: \`mcp__memtrace__get_symbol_context\` for callers/callees,
58
+ \`mcp__memtrace__get_impact\` for blast radius,
59
+ \`mcp__memtrace__get_evolution\` for history. Read source ONLY at the
60
+ exact spans Memtrace returned, with a few lines of context.
61
+
62
+ \`Read\` / \`Grep\` / \`Glob\` remain correct ONLY for:
63
+
64
+ - Config files (\`.env\`, \`package.json\`, README, raw JSON/YAML/TOML)
65
+ - File-inventory questions ("how many \`*.test.ts\` files exist")
66
+ - Files confirmed outside every indexed repo
67
+ - Reading exact lines you already have from a Memtrace result
68
+
69
+ If Memtrace returns 0 results, broaden the query or call
70
+ \`mcp__memtrace__list_indexed_repositories\` to confirm scope. Zero
71
+ results, missing language stats, or partial-looking output are NOT
72
+ permission to silently fall back to \`Grep\`.
73
+
74
+ This file is generated by \`memtrace install\` and removed by
75
+ \`memtrace uninstall\`. Edit CLAUDE.md for your own Claude Code rules
76
+ — that file is not touched (we only append a single breadcrumb line
77
+ pointing here, inside our marker block).
78
+ `;
79
+
80
+ const CLAUDE_MD_BREADCRUMB_LINES = [
81
+ BLOCK_BEGIN,
82
+ `<!-- block-version: ${BLOCK_VERSION} -->`,
83
+ "> 🧠 Memtrace is installed on this machine. **For code-discovery questions, call \\`mcp__memtrace__find_symbol\\` or \\`mcp__memtrace__find_code\\` BEFORE \\`Read\\` / \\`Grep\\` / \\`Glob\\`** — see [`MEMTRACE.md`](./MEMTRACE.md) for the full rule.",
84
+ BLOCK_END,
85
+ ];
86
+ const CLAUDE_MD_BREADCRUMB = CLAUDE_MD_BREADCRUMB_LINES.join("\n");
87
+
88
+ const HOOK_MANAGED_TAG = "memtrace";
89
+
90
+ // ── Pure functions: MEMTRACE.md content ───────────────────────────────
91
+
92
+ /**
93
+ * Build the canonical MEMTRACE.md body. Pure.
94
+ * @returns {string}
95
+ */
96
+ function buildMemtraceMdContent() {
97
+ return MEMTRACE_MD_HEADER + "\n" + MEMTRACE_MD_BODY;
98
+ }
99
+
100
+ // ── Pure functions: CLAUDE.md sentinel-block management ───────────────
101
+
102
+ /**
103
+ * Add or refresh the Memtrace breadcrumb block in an existing
104
+ * CLAUDE.md. Pure — takes current content, returns new content.
105
+ *
106
+ * Strict-symmetry design: the bytes WE own are exactly
107
+ * `CLAUDE_MD_BREADCRUMB + "\n"`
108
+ * and we always append them at the very end of the file (or
109
+ * replace an existing block in place with the same shape). We
110
+ * never add a leading separator; the user's content is left
111
+ * byte-identical above us. This makes `apply` and `remove` exact
112
+ * inverses for any user content (idempotency + round-trip
113
+ * byte-identity hold).
114
+ *
115
+ * - No existing block: append `CLAUDE_MD_BREADCRUMB + "\n"`.
116
+ * - Existing block: replace the inclusive span
117
+ * BLOCK_BEGIN..BLOCK_END(+optional \n)
118
+ * with `CLAUDE_MD_BREADCRUMB + "\n"`.
119
+ *
120
+ * @param {string} existing
121
+ * @returns {string}
122
+ */
123
+ function applyClaudeMdBreadcrumb(existing) {
124
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
125
+ if (beginIdx === -1) {
126
+ return existing + CLAUDE_MD_BREADCRUMB + "\n";
127
+ }
128
+ const endIdx = existing.indexOf(BLOCK_END, beginIdx);
129
+ if (endIdx === -1) {
130
+ throw new Error(
131
+ "CLAUDE.md contains a BEGIN MEMTRACE BLOCK marker without a matching END marker. " +
132
+ "Repair the file manually or delete the partial block before retrying."
133
+ );
134
+ }
135
+ const blockEnd = endIdx + BLOCK_END.length;
136
+ const tailNewline = existing.charAt(blockEnd) === "\n" ? 1 : 0;
137
+ return (
138
+ existing.slice(0, beginIdx) +
139
+ CLAUDE_MD_BREADCRUMB + "\n" +
140
+ existing.slice(blockEnd + tailNewline)
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Remove the Memtrace breadcrumb block from CLAUDE.md content.
146
+ * Pure. Idempotent. Strict inverse of `applyClaudeMdBreadcrumb`:
147
+ * removes exactly the bytes apply added, leaving everything else
148
+ * byte-identical.
149
+ *
150
+ * @param {string} existing
151
+ * @returns {string}
152
+ */
153
+ function removeClaudeMdBreadcrumb(existing) {
154
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
155
+ if (beginIdx === -1) return existing;
156
+ const endIdx = existing.indexOf(BLOCK_END, beginIdx);
157
+ if (endIdx === -1) {
158
+ throw new Error(
159
+ "CLAUDE.md contains a BEGIN MEMTRACE BLOCK marker without a matching END marker. " +
160
+ "Repair the file manually before retrying."
161
+ );
162
+ }
163
+ const blockEnd = endIdx + BLOCK_END.length;
164
+ const tailNewline = existing.charAt(blockEnd) === "\n" ? 1 : 0;
165
+ return existing.slice(0, beginIdx) + existing.slice(blockEnd + tailNewline);
166
+ }
167
+
168
+ // ── Pure functions: settings.json hook merge ──────────────────────────
169
+
170
+ /**
171
+ * Add Memtrace's hook entries to a parsed settings.json object.
172
+ * Pure — does NOT touch the filesystem; works on objects.
173
+ *
174
+ * The added hook entries carry `_managed_by: "memtrace"` so the
175
+ * uninstall path can identify and remove only our entries.
176
+ *
177
+ * @param {object} settings parsed contents of settings.json (may be {})
178
+ * @param {string} hooksDir absolute directory containing the hook scripts
179
+ * @returns {object} new settings object (deep-copied)
180
+ */
181
+ function applySettingsHooks(settings, hooksDir) {
182
+ const out = JSON.parse(JSON.stringify(settings || {}));
183
+ out.hooks = out.hooks || {};
184
+
185
+ const userPromptHook = {
186
+ type: "command",
187
+ command: path.join(hooksDir, "userprompt-claude.sh"),
188
+ _managed_by: HOOK_MANAGED_TAG,
189
+ _hook_kind: "userprompt-advisory",
190
+ };
191
+ const postToolHook = {
192
+ type: "command",
193
+ command: path.join(hooksDir, "posttool-mcp-telemetry.sh"),
194
+ _managed_by: HOOK_MANAGED_TAG,
195
+ _hook_kind: "posttool-mcp-telemetry",
196
+ };
197
+
198
+ // UserPromptSubmit — single matcher-less entry.
199
+ out.hooks.UserPromptSubmit = mergeHookList(
200
+ out.hooks.UserPromptSubmit || [],
201
+ { hooks: [userPromptHook] },
202
+ userPromptHook,
203
+ );
204
+
205
+ // PostToolUse — matcher narrowed to our MCP tools.
206
+ out.hooks.PostToolUse = mergeHookList(
207
+ out.hooks.PostToolUse || [],
208
+ { matcher: "mcp__memtrace__.*", hooks: [postToolHook] },
209
+ postToolHook,
210
+ );
211
+
212
+ return out;
213
+ }
214
+
215
+ /**
216
+ * Merge our hook into an existing hooks array. If a memtrace-managed
217
+ * entry already exists in any of the matchers, we update it in place
218
+ * (keep the surrounding matcher block) instead of duplicating.
219
+ *
220
+ * @param {Array} existing
221
+ * @param {object} ourEntry shape `{ matcher?, hooks: [...] }`
222
+ * @param {object} ourHook the inner hook object (with _managed_by)
223
+ * @returns {Array}
224
+ */
225
+ function mergeHookList(existing, ourEntry, ourHook) {
226
+ const out = existing.map((block) => ({ ...block }));
227
+ let foundManagedBlock = -1;
228
+ for (let i = 0; i < out.length; i++) {
229
+ const block = out[i];
230
+ if (!block.hooks) continue;
231
+ const hasOurs = block.hooks.some(
232
+ (h) => h && h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind,
233
+ );
234
+ if (hasOurs) {
235
+ foundManagedBlock = i;
236
+ break;
237
+ }
238
+ }
239
+ if (foundManagedBlock === -1) {
240
+ // Add our block as a new entry.
241
+ out.push(ourEntry);
242
+ return out;
243
+ }
244
+ // Replace just our hook entries inside the existing block, keep
245
+ // any user-added hooks in the same matcher untouched.
246
+ const block = { ...out[foundManagedBlock] };
247
+ block.hooks = block.hooks.filter(
248
+ (h) => !(h && h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind),
249
+ );
250
+ block.hooks.push(ourHook);
251
+ out[foundManagedBlock] = block;
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * Remove all memtrace-managed hook entries from a parsed settings.json.
257
+ * Pure. Walks every hooks event type, drops entries tagged
258
+ * `_managed_by: "memtrace"`, and prunes empty containers.
259
+ *
260
+ * @param {object} settings
261
+ * @returns {object}
262
+ */
263
+ function removeSettingsHooks(settings) {
264
+ const out = JSON.parse(JSON.stringify(settings || {}));
265
+ if (!out.hooks) return out;
266
+
267
+ for (const eventName of Object.keys(out.hooks)) {
268
+ const blocks = out.hooks[eventName];
269
+ if (!Array.isArray(blocks)) continue;
270
+ const cleaned = [];
271
+ for (const block of blocks) {
272
+ if (!block || !Array.isArray(block.hooks)) {
273
+ cleaned.push(block);
274
+ continue;
275
+ }
276
+ const filteredHooks = block.hooks.filter(
277
+ (h) => !(h && h._managed_by === HOOK_MANAGED_TAG),
278
+ );
279
+ if (filteredHooks.length === 0) {
280
+ // Block was entirely ours — drop it.
281
+ continue;
282
+ }
283
+ cleaned.push({ ...block, hooks: filteredHooks });
284
+ }
285
+ if (cleaned.length === 0) {
286
+ delete out.hooks[eventName];
287
+ } else {
288
+ out.hooks[eventName] = cleaned;
289
+ }
290
+ }
291
+ if (out.hooks && Object.keys(out.hooks).length === 0) {
292
+ delete out.hooks;
293
+ }
294
+ return out;
295
+ }
296
+
297
+ // ── Side-effecting wrappers for the installer entry point ─────────────
298
+
299
+ /**
300
+ * Install Memtrace's Claude Code integration: write MEMTRACE.md,
301
+ * breadcrumb CLAUDE.md if it exists, register hooks in settings.json.
302
+ *
303
+ * @param {object} [opts]
304
+ * @param {string} [opts.claudeDir] default `~/.claude`
305
+ * @param {string} [opts.hooksDir] absolute dir where hook scripts live
306
+ * @returns {object} summary `{ wrote: [...], skipped: [...] }`
307
+ */
308
+ function installToFs(opts = {}) {
309
+ const claudeDir = opts.claudeDir || path.join(os.homedir(), ".claude");
310
+ const hooksDir = opts.hooksDir;
311
+ if (!hooksDir) {
312
+ throw new Error("installToFs: opts.hooksDir is required (absolute path to hooks/)");
313
+ }
314
+
315
+ const summary = { wrote: [], skipped: [] };
316
+
317
+ fs.mkdirSync(claudeDir, { recursive: true });
318
+
319
+ // 1. Write MEMTRACE.md (always overwrite — we own this file).
320
+ const memtraceMdPath = path.join(claudeDir, "MEMTRACE.md");
321
+ fs.writeFileSync(memtraceMdPath, buildMemtraceMdContent());
322
+ summary.wrote.push(memtraceMdPath);
323
+
324
+ // 2. Breadcrumb in CLAUDE.md (only if file exists; we don't create it).
325
+ const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
326
+ if (fs.existsSync(claudeMdPath)) {
327
+ const existing = fs.readFileSync(claudeMdPath, "utf8");
328
+ const updated = applyClaudeMdBreadcrumb(existing);
329
+ if (updated !== existing) {
330
+ fs.writeFileSync(claudeMdPath, updated);
331
+ summary.wrote.push(claudeMdPath);
332
+ } else {
333
+ summary.skipped.push(claudeMdPath + " (already up to date)");
334
+ }
335
+ } else {
336
+ summary.skipped.push(claudeMdPath + " (does not exist; not creating)");
337
+ }
338
+
339
+ // 3. Register hooks in settings.json.
340
+ const settingsPath = path.join(claudeDir, "settings.json");
341
+ let settings = {};
342
+ if (fs.existsSync(settingsPath)) {
343
+ try {
344
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
345
+ } catch (e) {
346
+ throw new Error(
347
+ `Failed to parse ${settingsPath}: ${e.message}. ` +
348
+ "Repair manually before retrying."
349
+ );
350
+ }
351
+ }
352
+ const updated = applySettingsHooks(settings, hooksDir);
353
+ fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
354
+ summary.wrote.push(settingsPath);
355
+
356
+ return summary;
357
+ }
358
+
359
+ /**
360
+ * Reverse of installToFs. Removes MEMTRACE.md, removes our
361
+ * breadcrumb from CLAUDE.md, removes our hooks from settings.json.
362
+ * Leaves all user-authored content intact.
363
+ *
364
+ * @param {object} [opts]
365
+ * @param {string} [opts.claudeDir] default `~/.claude`
366
+ * @returns {object} summary `{ removed: [...], skipped: [...] }`
367
+ */
368
+ function uninstallFromFs(opts = {}) {
369
+ const claudeDir = opts.claudeDir || path.join(os.homedir(), ".claude");
370
+ const summary = { removed: [], skipped: [] };
371
+
372
+ // 1. Remove MEMTRACE.md.
373
+ const memtraceMdPath = path.join(claudeDir, "MEMTRACE.md");
374
+ if (fs.existsSync(memtraceMdPath)) {
375
+ fs.unlinkSync(memtraceMdPath);
376
+ summary.removed.push(memtraceMdPath);
377
+ } else {
378
+ summary.skipped.push(memtraceMdPath + " (not present)");
379
+ }
380
+
381
+ // 2. Strip breadcrumb from CLAUDE.md.
382
+ const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
383
+ if (fs.existsSync(claudeMdPath)) {
384
+ const existing = fs.readFileSync(claudeMdPath, "utf8");
385
+ const updated = removeClaudeMdBreadcrumb(existing);
386
+ if (updated === "") {
387
+ // Our breadcrumb was the only content — remove the file.
388
+ fs.unlinkSync(claudeMdPath);
389
+ summary.removed.push(claudeMdPath + " (file was empty after cleanup)");
390
+ } else if (updated !== existing) {
391
+ fs.writeFileSync(claudeMdPath, updated);
392
+ summary.removed.push(claudeMdPath + " (breadcrumb removed)");
393
+ } else {
394
+ summary.skipped.push(claudeMdPath + " (no memtrace breadcrumb found)");
395
+ }
396
+ } else {
397
+ summary.skipped.push(claudeMdPath + " (not present)");
398
+ }
399
+
400
+ // 3. Strip our hooks from settings.json.
401
+ const settingsPath = path.join(claudeDir, "settings.json");
402
+ if (fs.existsSync(settingsPath)) {
403
+ try {
404
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
405
+ const updated = removeSettingsHooks(settings);
406
+ const before = JSON.stringify(settings);
407
+ const after = JSON.stringify(updated);
408
+ if (before !== after) {
409
+ if (Object.keys(updated).length === 0) {
410
+ fs.unlinkSync(settingsPath);
411
+ summary.removed.push(settingsPath + " (file was empty after cleanup)");
412
+ } else {
413
+ fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
414
+ summary.removed.push(settingsPath + " (memtrace hook entries removed)");
415
+ }
416
+ } else {
417
+ summary.skipped.push(settingsPath + " (no memtrace hook entries found)");
418
+ }
419
+ } catch (e) {
420
+ summary.skipped.push(settingsPath + ` (parse error: ${e.message})`);
421
+ }
422
+ } else {
423
+ summary.skipped.push(settingsPath + " (not present)");
424
+ }
425
+
426
+ return summary;
427
+ }
428
+
429
+ module.exports = {
430
+ // Constants — exported for tests
431
+ BLOCK_BEGIN,
432
+ BLOCK_END,
433
+ BLOCK_VERSION,
434
+ HOOK_MANAGED_TAG,
435
+ CLAUDE_MD_BREADCRUMB,
436
+
437
+ // Pure functions
438
+ buildMemtraceMdContent,
439
+ applyClaudeMdBreadcrumb,
440
+ removeClaudeMdBreadcrumb,
441
+ applySettingsHooks,
442
+ removeSettingsHooks,
443
+
444
+ // Side-effecting entry points
445
+ installToFs,
446
+ uninstallFromFs,
447
+ };