memtrace 0.3.51 → 0.3.53

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.
@@ -213,9 +213,32 @@ function applySettingsHooks(settings, hooksDir) {
213
213
  }
214
214
 
215
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.
216
+ * Merge our hook into an existing hooks array strictly idempotent.
217
+ *
218
+ * Earlier versions only deduped by the `_managed_by` / `_hook_kind`
219
+ * marker fields and only inspected the first block that matched. Two
220
+ * gaps fell out of that:
221
+ *
222
+ * 1. Some Claude Code workflows (and external settings.json linters)
223
+ * round-trip the file and strip underscore-prefixed keys they
224
+ * don't recognise. With the markers gone, our dedup matched
225
+ * nothing and every `memtrace install` appended a fresh block —
226
+ * we've seen real users end up with 9× duplicates and a
227
+ * noticeable per-prompt slowdown.
228
+ *
229
+ * 2. Even when markers survived, the function only ever reached
230
+ * into the *first* matching block. Pre-existing duplicates in
231
+ * other blocks were never cleaned up, just left to compound.
232
+ *
233
+ * The new contract: regardless of how settings.json got into its
234
+ * current shape, this function leaves it with exactly one of our
235
+ * hooks installed under exactly one block. Running it 100 times
236
+ * produces the same result as running it once — and self-heals
237
+ * existing duplication on the way through.
238
+ *
239
+ * Match strategy: a hook is "ours" if it has the managed markers
240
+ * intact OR its command path matches one we generate. The
241
+ * command-path match is what catches stripped-marker pollution.
219
242
  *
220
243
  * @param {Array} existing
221
244
  * @param {object} ourEntry shape `{ matcher?, hooks: [...] }`
@@ -223,33 +246,40 @@ function applySettingsHooks(settings, hooksDir) {
223
246
  * @returns {Array}
224
247
  */
225
248
  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,
249
+ const isOurs = (h) =>
250
+ !!h && (
251
+ (h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind) ||
252
+ (typeof h.command === "string" && h.command === ourHook.command)
233
253
  );
234
- if (hasOurs) {
235
- foundManagedBlock = i;
236
- break;
254
+
255
+ // Phase 1: walk every block, strip every "ours" hook, drop blocks
256
+ // left empty by the strip. This sweep is what self-heals an
257
+ // already-polluted settings.json instead of leaving prior copies
258
+ // alongside the fresh one.
259
+ const cleaned = [];
260
+ for (const block of existing) {
261
+ if (!block || !Array.isArray(block.hooks)) {
262
+ cleaned.push(block);
263
+ continue;
237
264
  }
265
+ const remainingHooks = block.hooks.filter((h) => !isOurs(h));
266
+ if (remainingHooks.length === 0) {
267
+ // Block existed only to host our hook — drop it entirely so
268
+ // we don't leave behind an empty `{ matcher: '…', hooks: [] }`
269
+ // skeleton for Claude Code to ignore on every read.
270
+ continue;
271
+ }
272
+ cleaned.push({ ...block, hooks: remainingHooks });
238
273
  }
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;
274
+
275
+ // Phase 2: re-insert exactly once as a fresh block. We deliberately
276
+ // do NOT merge into an existing block (even one with the same matcher)
277
+ // because Claude Code semantics treat each block independently anyway,
278
+ // and keeping our hook in its own block makes the install / uninstall
279
+ // boundary explicit. Phase 1 has already removed every prior copy of
280
+ // ours, so this single append yields exactly one of us in the output.
281
+ cleaned.push(ourEntry);
282
+ return cleaned;
253
283
  }
254
284
 
255
285
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace",
3
- "version": "0.3.51",
3
+ "version": "0.3.53",
4
4
  "description": "Code intelligence graph — MCP server + AI agent skills + visualization UI",
5
5
  "keywords": [
6
6
  "mcp",
@@ -39,9 +39,9 @@
39
39
  "fs-extra": "^11.0.0"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@memtrace/darwin-arm64": "0.3.51",
43
- "@memtrace/linux-x64": "0.3.51",
44
- "@memtrace/win32-x64": "0.3.51"
42
+ "@memtrace/darwin-arm64": "0.3.53",
43
+ "@memtrace/linux-x64": "0.3.53",
44
+ "@memtrace/win32-x64": "0.3.53"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=18"