claude-mem-lite 4.0.4 → 5.1.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +18 -72
  4. package/README.zh-CN.md +24 -74
  5. package/adopt-content.mjs +0 -1
  6. package/cli/common.mjs +0 -17
  7. package/cli.mjs +19 -3
  8. package/commands/mem.md +3 -3
  9. package/commands/update.md +5 -12
  10. package/format-utils.mjs +0 -21
  11. package/haiku-client.mjs +36 -5
  12. package/hash-utils.mjs +21 -0
  13. package/hook-episode.mjs +7 -1
  14. package/hook-optimize.mjs +103 -50
  15. package/hook-shared.mjs +8 -2
  16. package/hook-update.mjs +56 -14
  17. package/hook.mjs +21 -29
  18. package/hooks/hooks.json +0 -10
  19. package/install.mjs +100 -678
  20. package/lib/atomic-write.mjs +16 -0
  21. package/lib/deferred-work.mjs +7 -3
  22. package/lib/doctor-drift.mjs +0 -1
  23. package/lib/frontmatter.mjs +1 -2
  24. package/lib/get-core.mjs +27 -1
  25. package/lib/hook-prune.mjs +133 -0
  26. package/lib/hook-stdin.mjs +1 -1
  27. package/lib/hook-telemetry.mjs +1 -1
  28. package/lib/install-shape.mjs +45 -4
  29. package/lib/maintain-core.mjs +9 -0
  30. package/lib/metrics.mjs +2 -2
  31. package/lib/proc-lock.mjs +111 -13
  32. package/lib/proxy-fetch.mjs +26 -2
  33. package/lib/scrub-record.mjs +8 -0
  34. package/lib/shard-gc.mjs +11 -7
  35. package/lib/tmp-fixture-sweep.mjs +9 -0
  36. package/mem-cli.mjs +46 -434
  37. package/nlp.mjs +1 -1
  38. package/npm-shrinkwrap.json +6 -2
  39. package/package.json +9 -16
  40. package/project-utils.mjs +43 -15
  41. package/schema.mjs +112 -50
  42. package/scripts/hook-launcher.mjs +9 -3
  43. package/scripts/prompt-search-utils.mjs +0 -30
  44. package/scripts/setup.sh +5 -0
  45. package/scripts/user-prompt-search.js +3 -125
  46. package/secret-scrub.mjs +32 -3
  47. package/server.mjs +19 -451
  48. package/source-files.mjs +12 -29
  49. package/synonyms.mjs +2 -1
  50. package/tool-schemas.mjs +23 -91
  51. package/utils.mjs +5 -13
  52. package/commands/tools.md +0 -67
  53. package/install-metadata.mjs +0 -2193
  54. package/lib/registry-core.mjs +0 -264
  55. package/registry/preinstalled.json +0 -2419
  56. package/registry-enricher.mjs +0 -124
  57. package/registry-github.mjs +0 -86
  58. package/registry-importer.mjs +0 -569
  59. package/registry-recommend.mjs +0 -503
  60. package/registry-retriever.mjs +0 -611
  61. package/registry-scanner.mjs +0 -261
  62. package/registry.mjs +0 -665
  63. package/resource-discovery.mjs +0 -199
  64. package/scripts/pre-skill-bridge.js +0 -146
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: update
3
- description: "Use when: search results seem noisy, after bulk imports, or for periodic memory/registry maintenance"
3
+ description: "Use when: search results seem noisy or for periodic memory maintenance"
4
4
  ---
5
5
 
6
- # Memory & Registry Maintenance
6
+ # Memory Maintenance
7
7
 
8
- Run intelligent maintenance on both the memory database and tool resource registry.
8
+ Run intelligent maintenance on the memory database.
9
9
 
10
10
  ## Usage
11
11
 
@@ -22,17 +22,10 @@ When the user invokes `/mem:update`, perform the following maintenance cycle:
22
22
  3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
23
23
  4. If duplicates were found in scan, review them and call `mem_maintain(action="execute", operations=["dedup"], merge_ids=[[keepId, removeId1, ...], ...])` — keep the more important/recent observation in each pair
24
24
  5. Run `mem_compress(preview=false)` for old low-value observations
25
- 6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"])`. User may optionally specify `retain_days` (default 30) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
25
+ 6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"], confirm=true)`. **`confirm=true` is required:** without it the call returns a dry-run preview and deletes nothing while still succeeding, so you would report a purge that never happened. User may optionally specify `retain_days` (default 30, allowed range 7–365) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
26
26
 
27
- ### Phase 2: Registry Maintenance
28
-
29
- 1. Call `mem_registry(action="stats")` to get registry overview
30
- 2. Call `mem_registry(action="reindex")` to rebuild FTS5 search index
31
- 3. Report updated stats
32
-
33
- ### Phase 3: Summary
27
+ ### Phase 2: Summary
34
28
 
35
29
  Summarize all maintenance actions taken in zh-CN:
36
30
  - Memory: observations cleaned, decayed, boosted, deduplicated, compressed
37
- - Registry: total resources, adoption rates, reindex status
38
31
  - Overall health assessment
package/format-utils.mjs CHANGED
@@ -125,27 +125,6 @@ export function neutralizeSkillDelimiters(s) {
125
125
  return defangToFixpoint(s, SKILL_BLOCK_RE);
126
126
  }
127
127
 
128
- // <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
129
- // skill body it injects as PreToolUse additionalContext. The body comes from a
130
- // third-party repo (tools/adopt import) — an untrusted boundary — so a literal
131
- // `</skill-bridge>` inside it would close the wrapper early and spill the rest of
132
- // the payload (e.g. a forged <system-reminder>) as undelimited context (audit
133
- // 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
134
- // isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
135
- // call site to the untrusted body only.
136
- const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
137
-
138
- /**
139
- * Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
140
- * to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
141
- * classes above. Never apply to the wrapper itself.
142
- * @param {string} s Input string (any type; coerced)
143
- * @returns {string} Text with skill-bridge delimiters defanged
144
- */
145
- export function neutralizeSkillBridgeDelimiters(s) {
146
- return defangToFixpoint(s, SKILL_BRIDGE_RE);
147
- }
148
-
149
128
  /**
150
129
  * Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
151
130
  * The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
package/haiku-client.mjs CHANGED
@@ -6,13 +6,40 @@
6
6
  // overridable via OPENROUTER_MODEL
7
7
 
8
8
  import { execFileSync, spawn } from 'child_process';
9
+ import { mkdirSync } from 'fs';
9
10
  import { readFileSync } from 'fs';
10
11
  import { join } from 'path';
11
12
  import { randomUUID } from 'crypto';
12
13
  import { debugLog, debugCatch, parseJsonFromLLM } from './utils.mjs';
13
14
  import { DB_DIR } from './schema.mjs';
15
+ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
14
16
  import { httpConnectProxyFor, postViaConnectProxy } from './lib/proxy-fetch.mjs';
15
17
 
18
+ /**
19
+ * cwd for every `claude -p` spawn. R10 P2-13.
20
+ *
21
+ * This was `/tmp`, which is world-writable. Claude Code loads a project-level CLAUDE.md
22
+ * and .claude/settings.json from its cwd, so on a shared host ANY local account could
23
+ * create /tmp/CLAUDE.md and inject instructions into every episode summary, session
24
+ * summary and optimize call this process makes — and the CLI leg is the fallback every
25
+ * keyed provider failure lands on, so it is not an exotic path.
26
+ *
27
+ * The original reason for /tmp was ghost sessions in the user's /resume list, and that is
28
+ * already solved by --no-session-persistence on the same spawns. A private directory under
29
+ * the runtime dir (whose parent is 0700) keeps that property and removes the injection
30
+ * surface. Created lazily; if creation fails we still do not fall back to /tmp — an
31
+ * unwritable cwd fails the spawn loudly, which is the better failure.
32
+ */
33
+ function cliSpawnCwd() {
34
+ const dir = join(resolveRuntimeDir(DB_DIR), 'cli-cwd');
35
+ try {
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ } catch {
38
+ /* already there, or unwritable — the spawn reports it */
39
+ }
40
+ return dir;
41
+ }
42
+
16
43
  // ─── Model Resolution ────────────────────────────────────────────────────────
17
44
 
18
45
  // CLI name → API model ID mapping
@@ -237,7 +264,10 @@ export async function callHaikuJSON(prompt, opts) {
237
264
 
238
265
  /**
239
266
  * Non-blocking sibling of callHaikuJSON for callers reachable from an MCP request
240
- * handler (registry enrichment: mem_registry `enrich` / `import_url`). Same
267
+ * handler. R10 P3-28: this used to name `mem_registry enrich / import_url` as the caller,
268
+ * a tool removed in v5.0.0 — read as a live example, it sent readers looking for a handler
269
+ * that does not exist. The REASON is what still applies to whatever calls it next: an MCP
270
+ * request handler must not block the server event loop. Same
241
271
  * provider priority; the CLI leg — primary AND post-provider-failure fallback —
242
272
  * is the async spawn, so a keyed-provider outage cannot freeze the server event
243
273
  * loop for BG_LLM_TIMEOUT_MS (D#138 MEDIUM-3).
@@ -245,8 +275,9 @@ export async function callHaikuJSON(prompt, opts) {
245
275
  * `resolveModel().cli`, NOT the literal 'haiku': despite the name, callHaikuJSON
246
276
  * reaches the model through resolveModel() on ALL three legs (callHaikuAPI,
247
277
  * callOpenRouterAPI, callHaikuCLI), so it honours the documented CLAUDE_MEM_MODEL
248
- * knob. Pinning 'haiku' here would silently downgrade registry enrichment for
249
- * every user who set CLAUDE_MEM_MODEL=sonnet — pre-tag review finding, v3.68.0.
278
+ * knob. Pinning 'haiku' here would silently downgrade any caller's model for every user
279
+ * who set CLAUDE_MEM_MODEL=sonnet — pre-tag review finding, v3.68.0, when the caller in
280
+ * question was registry enrichment.
250
281
  *
251
282
  * Defaults also mirror callHaiku (10s / 500 tokens), not callModelJSONAsync's
252
283
  * 15s / 1000: a caller that omits opts must get the sync twin's budget.
@@ -565,7 +596,7 @@ export function execClaudeCliSync(modelName, { input, timeout }) {
565
596
  encoding: 'utf8',
566
597
  env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
567
598
  stdio: ['pipe', 'pipe', 'pipe'],
568
- cwd: '/tmp', // Prevent ghost sessions in the user's /resume list
599
+ cwd: cliSpawnCwd(), // private dir, not /tmp — see cliSpawnCwd (R10 P2-13)
569
600
  };
570
601
  const args = claudeArgs(modelName);
571
602
  const started = Date.now();
@@ -650,7 +681,7 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
650
681
  // Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
651
682
  child = spawn(getClaudePath(), args, {
652
683
  env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
653
- cwd: '/tmp',
684
+ cwd: cliSpawnCwd(), // private dir, not /tmp — see cliSpawnCwd (R10 P2-13)
654
685
  stdio: ['pipe', 'pipe', 'pipe'],
655
686
  });
656
687
  } catch (e) {
package/hash-utils.mjs CHANGED
@@ -53,6 +53,27 @@ export function computeMinHash(text, numHashes = 64) {
53
53
  .replace(/[^a-z0-9\s]/g, ' ')
54
54
  .split(/\s+/)
55
55
  .filter((t) => t.length > 2);
56
+ // R10 P3-10: CJK fallback, and ONLY as a fallback. The tokenizer above deletes every
57
+ // non-ASCII character, so a title or narrative written entirely in Chinese or Japanese
58
+ // produced zero tokens and this returned null — and a null signature makes the row
59
+ // invisible to the MinHash prefilter that findDuplicates and selectFuzzyDedupeIds run,
60
+ // so CJK-only rows could never be deduplicated against anything.
61
+ //
62
+ // Confined to rows that get NOTHING from the ASCII path, which is the whole point.
63
+ // Signatures are STORED, and estimateJaccardFromMinHash compares a stored signature
64
+ // against a freshly computed one; widening the tokenization for text that already
65
+ // signs would make the entire existing corpus incomparable with everything written
66
+ // afterwards — dedup degrading everywhere, silently, until a full rebuild. Every text
67
+ // that signs today still produces the same bytes.
68
+ //
69
+ // Character bigrams rather than nlp.mjs's dictionary segmentation: this module is
70
+ // dependency-free on purpose, and bigrams are the standard CJK shingle for exactly this
71
+ // job — no vocabulary to maintain and no dependence on a dictionary that lags usage.
72
+ if (tokens.length === 0) {
73
+ for (const run of text.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff]{2,}/g) || []) {
74
+ for (let i = 0; i + 2 <= run.length; i++) tokens.push(run.slice(i, i + 2));
75
+ }
76
+ }
56
77
  // Require at least 3 tokens for meaningful signature (avoids high collision on short texts)
57
78
  if (tokens.length < 3) return null;
58
79
 
package/hook-episode.mjs CHANGED
@@ -248,7 +248,13 @@ export function mergePendingEntries(episode) {
248
248
  let files;
249
249
  try {
250
250
  files = readdirSync(RUNTIME_DIR)
251
- .filter((f) => f.startsWith('pending-'))
251
+ // R10 P3-4: `.json.tmp` is the WRITER's in-flight temp, not a pending entry.
252
+ // writePendingEntry writes `pending-<ts>-<rand>.json.tmp` then renames it into place;
253
+ // the bare `pending-` prefix matched the temp too, this loop's JSON.parse failed on a
254
+ // partial file, the catch deleted it as corrupt, and the writer's rename then failed
255
+ // ENOENT inside its own swallowed catch. One tool entry lost, silently, every time
256
+ // the two raced.
257
+ .filter((f) => f.startsWith('pending-') && f.endsWith('.json'))
252
258
  .sort();
253
259
  } catch {
254
260
  return;
package/hook-optimize.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  cjkBigrams,
21
21
  notLowSignalTitleClause,
22
22
  scrubSecrets,
23
+ getCurrentBranch,
23
24
  } from './utils.mjs';
24
25
  import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
25
26
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
@@ -28,7 +29,12 @@ import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
28
29
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
29
30
  import { DB_DIR } from './schema.mjs';
30
31
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
31
- import { normalizeScope, SCOPE_PROMPT_LEGEND, upsertObservationVector } from './lib/observation-write.mjs';
32
+ import {
33
+ normalizeScope,
34
+ SCOPE_PROMPT_LEGEND,
35
+ upsertObservationVector,
36
+ insertObservationRow,
37
+ } from './lib/observation-write.mjs';
32
38
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
33
39
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
34
40
 
@@ -364,8 +370,25 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
364
370
  Array.isArray(parsed.search_aliases) && parsed.search_aliases.length
365
371
  ? parsed.search_aliases.slice(0, 6).join(' ')
366
372
  : cand.search_aliases || null;
367
- const title = truncate(scrubSecrets(parsed.title || ''), 120);
368
- const narrative = truncate(scrubSecrets(parsed.narrative || cand.narrative || ''), 500);
373
+ // R10 P1-4. `wide` is the lesson-backfill pass by definition — its WHERE selects rows
374
+ // that already have a substantive narrative and only lack a lesson. Rewriting their
375
+ // title and cutting their narrative at 500 chars is not enrichment, it is content
376
+ // loss: this UPDATE also stamps optimized_at, which evicts the row from all three
377
+ // re-enrich pools permanently, and unlike applyObsUpdate there is no snapshot to
378
+ // restore from. The affected rows are exactly the hand-written ones (mem_save,
379
+ // mem_update --narrative, import-jsonl); hook-llm caps its own writes at 500, which
380
+ // is why this stayed invisible. So wide carries the stored values through unchanged.
381
+ //
382
+ // For `narrow`, the truncate stays on LLM output but NOT on the preserve-on-empty
383
+ // fallback — truncating the row's own stored narrative to buy nothing was the same
384
+ // bug in miniature.
385
+ const isWide = scope === 'wide';
386
+ const title = isWide ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
387
+ const narrative = isWide
388
+ ? cand.narrative
389
+ : parsed.narrative
390
+ ? truncate(scrubSecrets(parsed.narrative), 500)
391
+ : cand.narrative || '';
369
392
  // Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
370
393
  // a user-set/promoted importance (the UPDATE also sets optimized_at → the loss is permanent).
371
394
  // Upgrades are still honored.
@@ -386,31 +409,44 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
386
409
  lesson_learned: lessonLearned,
387
410
  search_aliases: searchAliases,
388
411
  });
389
- db.prepare(
390
- `
412
+ // R10 P3-3: the live-row guard on the WHERE. The LLM round-trip above is up to 45 s,
413
+ // and a concurrent hook can supersede or auto-compress this row inside that window —
414
+ // without the guard the dead row was rewritten with stale content AND stamped
415
+ // optimized_at, which resurrects it into every pool that filters on the stamp. The
416
+ // scopes branch already guards with `AND scope IS NULL`; this is the same idea.
417
+ // 0 changes is a skip, not a success: it must not count as processed and must not
418
+ // rebuild a vector for a row that is no longer live.
419
+ const res = db
420
+ .prepare(
421
+ `
391
422
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
392
423
  text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
393
424
  scope=COALESCE(?, scope)
394
- WHERE id = ?
425
+ WHERE id = ? AND ${liveObsFilterSql('')}
395
426
  `,
396
- ).run(
397
- type,
398
- safe.title,
399
- safe.narrative,
400
- safe.concepts,
401
- safe.facts,
402
- safe.text,
403
- importance,
404
- safe.lesson_learned,
405
- safe.search_aliases,
406
- minhashSig,
407
- Date.now(),
408
- // COALESCE (mirrors the hook-llm upgrade path): a re-enrich that omits
409
- // scope, or emits an off-enum value, must never blank an existing label —
410
- // and THIS update stamps optimized_at, so the loss would be permanent.
411
- normalizeScope(parsed.scope),
412
- cand.id,
413
- );
427
+ )
428
+ .run(
429
+ type,
430
+ safe.title,
431
+ safe.narrative,
432
+ safe.concepts,
433
+ safe.facts,
434
+ safe.text,
435
+ importance,
436
+ safe.lesson_learned,
437
+ safe.search_aliases,
438
+ minhashSig,
439
+ Date.now(),
440
+ // COALESCE (mirrors the hook-llm upgrade path): a re-enrich that omits
441
+ // scope, or emits an off-enum value, must never blank an existing label —
442
+ // and THIS update stamps optimized_at, so the loss would be permanent.
443
+ normalizeScope(parsed.scope),
444
+ cand.id,
445
+ );
446
+ if (res.changes === 0) {
447
+ skipped++;
448
+ continue;
449
+ }
414
450
 
415
451
  rebuildVector(db, cand.id, {
416
452
  title,
@@ -544,8 +580,15 @@ export function applyNormalization(db, groups, { project = null } = {}) {
544
580
  .all(project, project);
545
581
 
546
582
  let updated = 0;
583
+ // R10 P2-2: normalize does NOT stamp optimized_at. That column is the "re-enrich has
584
+ // seen this row" marker read by all three re-enrich pools (:156, :177) and by
585
+ // cluster-merge (:634); writing it here evicted a row from lesson backfill because a
586
+ // synonym replacement touched one concept term — two unrelated passes sharing one flag.
587
+ // Nothing in normalize needs it: its own re-run gate is the 7-day NORMALIZE_GATE_FILE
588
+ // timer, and the pass is idempotent anyway because a canonicalized term stops matching
589
+ // an alias, so `changed` stays false on the second run.
547
590
  const updateStmt = db.prepare(`
548
- UPDATE observations SET concepts = ?, search_aliases = ?, optimized_at = ? WHERE id = ?
591
+ UPDATE observations SET concepts = ?, search_aliases = ? WHERE id = ?
549
592
  `);
550
593
 
551
594
  for (const row of rows) {
@@ -574,7 +617,7 @@ export function applyNormalization(db, groups, { project = null } = {}) {
574
617
  concepts: uniqueConcepts,
575
618
  search_aliases: newAliases,
576
619
  });
577
- updateStmt.run(safe.concepts, safe.search_aliases, Date.now(), row.id);
620
+ updateStmt.run(safe.concepts, safe.search_aliases, row.id);
578
621
  // V-F3: normalize mutated concepts + search_aliases (both vector fields) — rebuild the
579
622
  // vector so it reflects the canonicalized terms (no-op when the vector arm is disabled).
580
623
  rebuildVector(db, row.id, {
@@ -628,7 +671,11 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
628
671
  const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
629
672
  const projectClause = project ? 'AND project = ?' : '';
630
673
  const stmt = db.prepare(`
631
- SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
674
+ -- R10 P3-7: search_aliases is in the list because the merge path reads
675
+ -- keeper.search_aliases when it rebuilds the keeper's vector. Without the column it was
676
+ -- always undefined, so a merge silently dropped the keeper's aliases from its vector
677
+ -- text. No effect while the vector arm is off by default, which is why it went unseen.
678
+ SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts, search_aliases
632
679
  FROM observations
633
680
  WHERE ${liveObsFilterSql('')}
634
681
  AND optimized_at IS NULL
@@ -1039,29 +1086,35 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
1039
1086
  lesson_learned: lessonLearned,
1040
1087
  search_aliases: searchAliases,
1041
1088
  });
1042
- const result = db
1043
- .prepare(
1044
- `INSERT INTO observations
1045
- (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts,
1046
- files_read, files_modified, importance, lesson_learned, search_aliases, optimized_at,
1047
- created_at, created_at_epoch)
1048
- VALUES (?,?,?,?,?,'',?,?,?,'[]','[]',2,?,?,?,?,?)`,
1049
- )
1050
- .run(
1051
- sessionId,
1052
- project,
1053
- safe.text,
1054
- 'discovery',
1055
- safe.title,
1056
- safe.narrative,
1057
- safe.concepts,
1058
- safe.facts,
1059
- safe.lesson_learned,
1060
- safe.search_aliases,
1061
- Date.now(),
1062
- new Date(medianEpoch).toISOString(),
1063
- medianEpoch,
1064
- );
1089
+ // R10 P3-8: through the shared writer, not a fourth hand-spelled INSERT. The copy
1090
+ // this replaces had already drifted from OBS_COLUMNS in three ways — no minhash_sig
1091
+ // (so the summary was invisible to the MinHash prefilter that findDuplicates and
1092
+ // selectFuzzyDedupeIds run, and could never be deduplicated against anything), no
1093
+ // branch, and subtitle written as '' instead of the schema's NULL. That is exactly
1094
+ // the drift lib/observation-write.mjs's docblock says the shared column list exists
1095
+ // to make impossible.
1096
+ //
1097
+ // optimized_at is not in OBS_COLUMNS, so it stays a separate UPDATE below — the
1098
+ // summary must be marked processed so the re-enrich pools do not pick it up.
1099
+ const newId = insertObservationRow(db, {
1100
+ memory_session_id: sessionId,
1101
+ project,
1102
+ text: safe.text,
1103
+ type: 'discovery',
1104
+ title: safe.title,
1105
+ narrative: safe.narrative,
1106
+ concepts: safe.concepts,
1107
+ facts: safe.facts,
1108
+ importance: 2,
1109
+ minhash_sig: computeMinHash(`${title || ''} ${narrative || ''}`),
1110
+ lesson_learned: safe.lesson_learned,
1111
+ search_aliases: safe.search_aliases,
1112
+ branch: getCurrentBranch(),
1113
+ created_at: new Date(medianEpoch).toISOString(),
1114
+ created_at_epoch: medianEpoch,
1115
+ });
1116
+ db.prepare('UPDATE observations SET optimized_at = ? WHERE id = ?').run(Date.now(), newId);
1117
+ const result = { lastInsertRowid: newId };
1065
1118
 
1066
1119
  const sId = Number(result.lastInsertRowid);
1067
1120
 
package/hook-shared.mjs CHANGED
@@ -233,8 +233,11 @@ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
233
233
  export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
234
234
  'session-', // project → memory-session-id pointer
235
235
  CITE_RECALL_FILE_PREFIX, // last session's cite-recall snapshot (nudge input)
236
- '.skill-cooldown-', // suggestion throttle timestamp
237
- '.skill-reco-cooldown-', // recommendation throttle timestamp
236
+ // R10 P3-5: the auto-compressible 24h gate. One file per project, regenerated on demand,
237
+ // and it was in NEITHER list — so a project the user stops working on kept its marker
238
+ // forever. `.skill-cooldown-` / `.skill-reco-cooldown-` left with the skill registry in
239
+ // v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
240
+ 'last-mark-compressible-', // per-project auto-compress 24h gate
238
241
  ]);
239
242
 
240
243
  // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
@@ -312,6 +315,9 @@ export function sweepStaleProjectMarkers(
312
315
  // Kill switch (naming mirrors SKIP_COMPRESS / SKIP_OPTIMIZE / SKIP_SAVE_ENRICH):
313
316
  // this is the only sweep that deletes files a user might want to inspect, so a
314
317
  // released default that reclaims state needs a documented way back out.
318
+ // R10 P3-6: exact '1', not a truthy check, and that is on purpose — the truthy form the
319
+ // sibling CLAUDE_MEM_SKIP_* flags use makes `=0` mean "skip", the opposite of intent.
320
+ // README documents the difference; do not "align" this without aligning the others too.
315
321
  if (env.CLAUDE_MEM_SKIP_MARKER_GC === '1') return 0;
316
322
  let entries;
317
323
  try {
package/hook-update.mjs CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  readFileSync,
8
8
  writeFileSync,
9
9
  copyFileSync,
10
- cpSync,
11
10
  readdirSync,
12
11
  existsSync,
13
12
  lstatSync,
@@ -404,11 +403,11 @@ export function getCurrentVersion() {
404
403
  }
405
404
 
406
405
  // SWITCHABLE_PATHS = everything in SOURCE_FILES plus the recursive dirs that
407
- // install.mjs copies as whole subtrees (scripts, registry, node_modules). It's
406
+ // install.mjs copies as whole subtrees (scripts, node_modules). It's
408
407
  // built per-call from the *tarball's* manifest, not the locally-imported one —
409
408
  // see loadReleaseManifest comment for why.
410
409
  function buildSwitchablePaths(sourceFiles) {
411
- return [...sourceFiles, 'scripts', 'registry', 'node_modules'];
410
+ return [...sourceFiles, 'scripts', 'node_modules'];
412
411
  }
413
412
 
414
413
  // Load the SOURCE_FILES / HOOK_SCRIPT_FILES manifest from the *extracted
@@ -473,7 +472,11 @@ async function downloadAndInstall(tarballUrl, expectedVersion, assets = []) {
473
472
  const tarballPath = join(tmpDir, 'release.tar.gz');
474
473
  execFileSync(
475
474
  'curl',
476
- ['-sL', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
475
+ // R10 P3-18: `-f` makes curl exit non-zero on an HTTP error instead of writing the
476
+ // error BODY to the output file. Without it a 404 or a rate-limit page was saved as
477
+ // release.tar.gz, tar failed on it, and the debug log reported "tarball corrupt" for
478
+ // what was really a network or auth problem — the wrong thing to go debug.
479
+ ['-fsL', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
477
480
  { timeout: 30000, stdio: 'pipe' },
478
481
  );
479
482
  execFileSync('tar', ['xzf', tarballPath, '-C', tmpDir, '--strip-components=1'], {
@@ -989,6 +992,41 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
989
992
  rmSync(stagingDir, { recursive: true, force: true });
990
993
  rmSync(backupDir, { recursive: true, force: true });
991
994
 
995
+ // Post-update migration: reconcile settings.json against the release we just
996
+ // swapped in. `configureHooks()` already strips stale mem entries — but its only
997
+ // caller is `install()`, and this path never runs it, while `buildSwitchablePaths`
998
+ // replaces `scripts/` wholesale. So an upgrade that REMOVES a hook script deletes
999
+ // the file and leaves settings.json pointing at it; the launcher then reports a
1000
+ // broken install on every fire, for a file that is not coming back (R9 review P1-1,
1001
+ // first hit by the `PreToolUse:Skill` removal). Plugin-channel installs are
1002
+ // unaffected — hooks/hooks.json is replaced wholesale — so this is npm-channel only.
1003
+ //
1004
+ // The reconciler lives in lib/hook-prune.mjs, not install.mjs: install.mjs already
1005
+ // imports THIS file, so importing it back would close a cycle the repo's import-graph
1006
+ // guard rejects. Failure is non-fatal — a stale entry is noisy, not broken, and must
1007
+ // never roll back an otherwise-good update.
1008
+ try {
1009
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
1010
+ if (existsSync(settingsPath)) {
1011
+ const { pruneDanglingMemHooks } = await import('./lib/hook-prune.mjs');
1012
+ const before = JSON.parse(readFileSync(settingsPath, 'utf8'));
1013
+ const { settings, removed } = pruneDanglingMemHooks(before, targetDir);
1014
+ if (removed.length > 0) {
1015
+ // Through the shared writer: settings.json is user-owned config and may be a
1016
+ // symlink into a dotfiles repo, which a bare temp+rename would sever (P0-5).
1017
+ const { atomicWriteFileSync } = await import('./lib/atomic-write.mjs');
1018
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { backup: true });
1019
+ debugLog(
1020
+ 'INFO',
1021
+ 'update',
1022
+ `pruned ${removed.length} dangling hook entr(ies): ${removed.join(', ')}`,
1023
+ );
1024
+ }
1025
+ }
1026
+ } catch (e) {
1027
+ debugCatch(e, 'pruneDanglingMemHooks');
1028
+ }
1029
+
992
1030
  // Post-update migration: clean stale global MCPs if plugin handles it.
993
1031
  // Both "mem" (legacy, pre-v2.78) and "mem-lite" (current) are purged so a
994
1032
  // user who manually ran `claude mcp add` in either era doesn't end up with
@@ -1182,7 +1220,7 @@ function copyReleaseIntoStaging(
1182
1220
  // scripts/ is curated to HOOK_SCRIPT_FILES — settings.json hook commands
1183
1221
  // resolve only to these 5 files, and plugin mode does not consume this
1184
1222
  // directory at all. Pre-v2.55 used cpSync({recursive:true}) which silently
1185
- // shipped dev-only files (mock-claude.mjs, extract-repos.mjs, p0-forward-probe.mjs…)
1223
+ // shipped dev-only files (mock-claude.mjs, extract-repos.mjs…)
1186
1224
  // from the GitHub Releases tarball into every user's data dir.
1187
1225
  const stagingScripts = join(stagingDir, 'scripts');
1188
1226
  const sourceScripts = join(sourceDir, 'scripts');
@@ -1194,15 +1232,6 @@ function copyReleaseIntoStaging(
1194
1232
  }
1195
1233
  }
1196
1234
 
1197
- // registry/ stays recursive — preinstalled.json is the only current entry
1198
- // but the directory is consumed wholesale by the registry indexer and may
1199
- // grow subtrees. Pre-v2.55 readdirSync+copyFileSync would EISDIR-throw on
1200
- // any subdir and silently roll back the entire update.
1201
- const sourceRegistry = join(sourceDir, 'registry');
1202
- if (existsSync(sourceRegistry)) {
1203
- cpSync(sourceRegistry, join(stagingDir, 'registry'), { recursive: true });
1204
- }
1205
-
1206
1235
  const stagedScripts = join(stagingDir, 'scripts');
1207
1236
  if (existsSync(stagedScripts)) {
1208
1237
  for (const sf of readdirSync(stagedScripts).filter((n) => n.endsWith('.sh'))) {
@@ -1345,12 +1374,25 @@ export function prunePluginCache() {
1345
1374
  // it is not among the newest 3 — so keep-latest-3 rm -rf'd the tree the running hooks and
1346
1375
  // MCP server import from. scripts/setup.sh step 8 carries the same guard for the same
1347
1376
  // reason; the two prune the same directory and must agree.
1377
+ //
1378
+ // R10 P2-9: CLAUDE_PLUGIN_ROOT alone is not enough, because prune also runs from a
1379
+ // TERMINAL — `self-update` — where Claude Code never sets it. There the guard was
1380
+ // vacuous and keep-latest-3 deleted the recorded live version outright. detectInstallShape
1381
+ // now consults installed_plugins.json as well, so ask it rather than the env var, and
1382
+ // keep the env var as the first-hand answer inside a hook.
1348
1383
  const runningRoot = process.env.CLAUDE_PLUGIN_ROOT;
1384
+ let recordedRoot = null;
1385
+ try {
1386
+ recordedRoot = detectInstallShape({ home: homedir() }).activePluginVersion?.root || null;
1387
+ } catch {
1388
+ /* best-effort — a failure here must not stop pruning entirely */
1389
+ }
1349
1390
  const toRemove = entries.slice(PLUGIN_CACHE_KEEP);
1350
1391
  let removed = 0;
1351
1392
  for (const ver of toRemove) {
1352
1393
  const dir = join(cacheBase, ver);
1353
1394
  if (isSameDir(dir, runningRoot)) continue;
1395
+ if (isSameDir(dir, recordedRoot)) continue;
1354
1396
  try {
1355
1397
  rmSync(dir, { recursive: true, force: true });
1356
1398
  removed++;
package/hook.mjs CHANGED
@@ -165,10 +165,10 @@ async function loadCacheGuard() {
165
165
  // Audit 2026-09-02 P1-8. `hook.mjs` is ONE entry point for seven events, so every static
166
166
  // import is paid by every event — PostToolUse, the highest-frequency one, was loading 85
167
167
  // modules (1.37 MB) to use a handful. The six modules below are loaded inside the handler
168
- // that needs them instead: registry-recommend, patha-exclude-meter, hook-update,
169
- // hook-optimize, adopt-cli, upgrade-banner. Measured marginal cost of the four largest,
170
- // same process: hook-update 9.4 ms, registry-recommend 5.1, hook-optimize 5.9, the rest
171
- // 0.5-2.4 each.
168
+ // that needs them instead: patha-exclude-meter, hook-update, hook-optimize, adopt-cli,
169
+ // upgrade-banner. Measured marginal cost of the largest, same process: hook-update
170
+ // 9.4 ms, hook-optimize 5.9, the rest 0.5-2.4 each. (registry-recommend, 5.1 ms, was a
171
+ // sixth entry until the skill-registry subsystem was removed — see docs/audits/20260906-145304.md.)
172
172
  //
173
173
  // A failing dynamic import lands in the dispatcher's tail catch -> recordHookError, the
174
174
  // same place a failing static import would have landed the whole process; each site keeps
@@ -240,10 +240,26 @@ if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0
240
240
  // Uses flag-based approach to avoid calling file I/O inside signal handlers,
241
241
  // which can deadlock if the signal fires during a main-thread file operation.
242
242
  let _shutdownRequested = false;
243
+ // R10 P3-2: the salvage belongs to the INTERACTIVE hook only. It was installed for every
244
+ // invocation, background workers included — and the seven of them (llm-episode, llm-summary,
245
+ // auto-compress, llm-optimize, auto-maintain, enrich-save, update-check) do not own the
246
+ // live `ep-<project>.json` buffer. A `pkill node` or a machine shutdown therefore had every
247
+ // running worker flush AND DELETE the buffer an interactive session was still appending to,
248
+ // none of them holding the episode lock. Workers still exit promptly on a signal; they just
249
+ // do not touch a buffer that is not theirs.
250
+ const SALVAGES_EPISODE_ON_SIGNAL = !BG_EVENTS.has(event);
243
251
  for (const sig of ['SIGTERM', 'SIGINT']) {
244
252
  process.on(sig, () => {
245
253
  if (_shutdownRequested) process.exit(0); // Double-signal = force exit
246
254
  _shutdownRequested = true;
255
+ if (!SALVAGES_EPISODE_ON_SIGNAL) {
256
+ try {
257
+ flushHookStdout();
258
+ } catch {
259
+ /* never change the exit code for a receipt */
260
+ }
261
+ process.exit(0);
262
+ }
247
263
  // Schedule flush on next tick to avoid re-entering file I/O
248
264
  setTimeout(() => {
249
265
  try {
@@ -642,23 +658,6 @@ async function handlePostToolUse() {
642
658
  if (SKIP_TOOLS.has(tool_name)) return;
643
659
  if (SKIP_PREFIXES.some((p) => tool_name.startsWith(p))) return;
644
660
 
645
- // Shadow skill-adoption telemetry. mem_use is pre-filtered above, so the Skill tool is
646
- // the only visible adoption signal (v1). Placed before the resp-length gate because a
647
- // skill load's response shape varies. Never throws.
648
- if (tool_name === 'Skill') {
649
- const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : tool_input || {};
650
- // hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
651
- // UserPromptSubmit hook earlier in the same session (matched precision, B1).
652
- // Lazy: only a `Skill` tool call needs this module, which is a small fraction of
653
- // PostToolUse fires.
654
- try {
655
- const { recordSkillAdoption } = await import('./registry-recommend.mjs');
656
- recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id);
657
- } catch {
658
- /* telemetry only — never blocks capture */
659
- }
660
- }
661
-
662
661
  const resp = normalizeToolResponse(tool_response);
663
662
  if (!resp || resp.length < 10) return;
664
663
 
@@ -2254,14 +2253,7 @@ async function handleSessionStart() {
2254
2253
  } catch {
2255
2254
  /* best-effort */
2256
2255
  }
2257
- // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
2258
- try {
2259
- const { gcOldShadowShards } = await import('./registry-recommend.mjs');
2260
- gcOldShadowShards();
2261
- } catch {
2262
- /* best-effort, never blocks SessionStart */
2263
- }
2264
- // Same for the opt-in metrics sink, which lives under DB_DIR. Runs even when
2256
+ // Bound the opt-in metrics sink, which lives under DB_DIR. Runs even when
2265
2257
  // metrics are disabled, so shards left by a since-toggled-off run still get pruned.
2266
2258
  try {
2267
2259
  gcOldMetricShards(DB_DIR);