claude-mem-lite 3.91.0 → 3.93.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 (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +14 -0
  4. package/README.zh-CN.md +1 -0
  5. package/claudemd.mjs +9 -6
  6. package/hash-utils.mjs +12 -0
  7. package/hook-context.mjs +9 -8
  8. package/hook-llm.mjs +96 -37
  9. package/hook-optimize.mjs +51 -29
  10. package/hook-shared.mjs +62 -12
  11. package/hook.mjs +139 -56
  12. package/install.mjs +24 -13
  13. package/lib/atomic-write.mjs +14 -3
  14. package/lib/cite-back-hint.mjs +3 -3
  15. package/lib/cite-recall-path.mjs +52 -0
  16. package/lib/compress-core.mjs +31 -15
  17. package/lib/export-columns.mjs +45 -0
  18. package/lib/frontmatter.mjs +67 -0
  19. package/lib/get-core.mjs +17 -0
  20. package/lib/hook-stdin.mjs +134 -0
  21. package/lib/injected-ids.mjs +93 -4
  22. package/lib/maintain-core.mjs +191 -5
  23. package/lib/observation-write.mjs +71 -13
  24. package/lib/plugin-key.mjs +44 -0
  25. package/lib/registry-core.mjs +165 -1
  26. package/lib/resolve-data-dir.mjs +58 -0
  27. package/lib/save-enrich.mjs +10 -6
  28. package/lib/save-observation.mjs +44 -0
  29. package/lib/transcript-scan.mjs +59 -8
  30. package/mem-cli.mjs +95 -229
  31. package/memdir.mjs +6 -6
  32. package/npm-shrinkwrap.json +20 -2
  33. package/package.json +11 -1
  34. package/registry-importer.mjs +4 -34
  35. package/registry-recommend.mjs +2 -2
  36. package/registry.mjs +2 -1
  37. package/schema.mjs +18 -1
  38. package/scripts/hook-launcher.mjs +10 -0
  39. package/scripts/post-tool-recall.js +19 -8
  40. package/scripts/pre-agent-inject.js +12 -4
  41. package/scripts/pre-skill-bridge.js +9 -5
  42. package/scripts/pre-tool-recall.js +44 -40
  43. package/scripts/user-prompt-search.js +37 -60
  44. package/search-scoring.mjs +10 -0
  45. package/server.mjs +95 -215
  46. package/source-files.mjs +4 -0
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.91.0",
13
+ "version": "3.93.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.91.0",
3
+ "version": "3.93.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -823,6 +823,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
823
823
  | `CLAUDE_MEM_ERROR_RECALL_BM25_MIN` | Relevance floor for the error-recall surface (memories injected after a failed Bash command). **Off by default.** Setting it to `10.5` (the calibrated value) makes the surface stay silent when its best-matching memory is not actually about the failure — the whole set is dropped, never trimmed row-by-row. **It is a real trade, not a free win:** measured on a live database at that threshold, injections fall ~37% and ~39% of firings go silent, concentrated in projects with few memories. Off by default because nothing shows the dropped rows were noise. Explore with `node benchmark/error-recall-suite.mjs --sweep`. | `0` (off) |
824
824
  | `CLAUDE_MEM_ERROR_RECALL_RERANK` | `off` restores the flat keyword ordering of the error-recall surface. **On by default**, and unlike the floor above it removes nothing: memories that share only the failed command's vocabulary are demoted below memories that mention the failure itself, and when a project has none of the latter the result is unchanged. Measured on a live database over 52 real failing commands × 15 projects: the lead memory matched no error term in 42.3% of firings before, 21.5% after, with the injected row count identical. | _(on)_ |
825
825
  | `CLAUDE_MEM_ERROR_RECALL_ON_FAILURE` | `off` stops the plugin from recalling memories when a Bash command **fails at the host level**. On by default. Claude Code delivers failed tool calls to a separate `PostToolUseFailure` hook event, so before this the surface only ever saw commands that exited `0` while printing error-ish text — a genuinely failing build recalled nothing. Denials from your own guardrails (sandbox, policy hooks, declined permission prompts) and commands you interrupted are never recalled for. | _(on)_ |
826
+ | `CLAUDE_MEM_REGISTRY_CONFINE` | `off` lets registry enrichment read a resource whose `local_path` lies outside the managed data directory. **On by default.** Enrichment reads the file at `resources.local_path` and sends it to an LLM; the confinement check used to guard one of the four code paths that do this (`mem_registry(action="enrich")`) and not the other three, so `enrich <name>`, `enrich --all` and `import --enrich` read any path the row happened to hold. All four are gated now. Turn it off only if you deliberately registered resources outside `CLAUDE_MEM_DIR` and want them enriched; only `off` disables it — case-insensitively and ignoring surrounding whitespace — so a typo leaves the guard on. | _(on)_ |
826
827
  | `CLAUDE_MEM_UPS_IDENTIFIER_BYPASS` | `0` disables the bypass that lets an exact identifier match skip the score floors. | _(on)_ |
827
828
  | `CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT` | How many past-prompt rows the fallback arm may return. | `1` |
828
829
  | `MEM_COVERAGE_THRESHOLD` | Fraction of query terms a memory must cover to qualify (∈ [0,1]). | `0.4` |
@@ -894,6 +895,19 @@ Set by the tool or by the test harness. Setting these by hand is not supported:
894
895
  `CLAUDE_MEM_DB_PATH`, `CLAUDE_MEM_RUNTIME_DIR`, `MEM_DISABLE_SPAWN_LOG`.
895
896
  `CLAUDE_PLUGIN_ROOT` is set by Claude Code itself.
896
897
 
898
+ The last two are worth one more sentence each, because they are the ones a harness reaches
899
+ for. `CLAUDE_MEM_RUNTIME_DIR` relocates the runtime directory for hook-written state — markers,
900
+ cooldowns, hook-error telemetry, the native-binding breakage marker, `metrics/`, episode
901
+ buffers. Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
902
+ split the runtime rather than moving it. Installation-identity state (`install.lock`,
903
+ `update-state.json`, update residue) deliberately stays under `CLAUDE_MEM_DIR`: two
904
+ installers pointed at different override directories would otherwise each take their own
905
+ lock and both proceed. **`CLAUDE_MEM_DB_PATH` still has the split shape**, and more narrowly
906
+ than it looks: exactly ONE component reads it — `scripts/pre-tool-recall.js` — so setting it
907
+ aims that single hook at one database and leaves the other four hook faces, the CLI and the
908
+ MCP server on the default. Use `CLAUDE_MEM_DIR` — the only override
909
+ every component respects, including the bash pre-filter — to isolate state.
910
+
897
911
  Three more are set by `vitest.config.mjs` / `tests/global-setup.mjs` and exist only to
898
912
  keep a test run off the live database: `CLAUDE_MEM_TEST_GUARD` (`1` arms the guard, `off`
899
913
  opts a test out), `CLAUDE_MEM_TEST_REALDIR` (the live data dir, captured before the suite
package/README.zh-CN.md CHANGED
@@ -620,6 +620,7 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
620
620
  | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
621
621
  | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止每次 SessionStart 在**所有**项目自动写入 `CLAUDE.md` 托管块。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
622
622
  | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径每次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
623
+ | `CLAUDE_MEM_REGISTRY_CONFINE` | 设为 `off` 允许 registry enrich 读取 `local_path` 位于托管数据目录之外的资源。**默认开启。** enrich 会读取 `resources.local_path` 指向的文件并发给 LLM;此前这道路径限制只接在四条读取路径中的一条(`mem_registry(action="enrich")`)上,`enrich <name>`、`enrich --all` 和 `import --enrich` 三条都是裸读。现在四条同门。仅当你确实把资源注册在 `CLAUDE_MEM_DIR` 之外并希望对其 enrich 时才关闭;只有精确值 `off` 会关闭它,拼错则守卫保持开启。 | _(开启)_ |
623
624
 
624
625
  ## 许可证
625
626
 
package/claudemd.mjs CHANGED
@@ -16,7 +16,8 @@
16
16
  //
17
17
  // See docs/CLAUDE-MD-STEERING-PLAN.md for rationale + migration.
18
18
 
19
- import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, mkdirSync, rmdirSync, readdirSync } from 'fs';
19
+ import { readFileSync, existsSync, unlinkSync, mkdirSync, rmdirSync, readdirSync } from 'fs';
20
+ import { atomicWriteFileSync as atomicWrite } from './lib/atomic-write.mjs';
20
21
  import { join } from 'path';
21
22
  import { createHash } from 'crypto';
22
23
  import { memdirPath, removePluginSection, removePluginDoc, isAdopted as memdirIsAdopted } from './memdir.mjs';
@@ -54,11 +55,13 @@ function renderBlock(slug, version, body) {
54
55
 
55
56
  function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
56
57
 
57
- function atomicWrite(path, content) {
58
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
59
- writeFileSync(tmp, content);
60
- renameSync(tmp, path);
61
- }
58
+ // `atomicWriteFileSync`, not a local temp+rename (audit 2026-09-02 P0-5). The local twin
59
+ // renamed onto the PATH; when a project's CLAUDE.md is a symlink into a dotfiles repo
60
+ // (chezmoi/stow/yadm) or a monorepo's shared root, that REPLACES the link with a regular
61
+ // file — silently, on the first SessionStart, with no user-visible signal beyond a git
62
+ // typechange. The shared writer lstats first and writes THROUGH to the real target. It has
63
+ // been in this repo, shipped and used by install.mjs for ~/.claude/settings.json, since the
64
+ // day that failure mode was first written down in its own docblock.
62
65
 
63
66
  function writeState(cwd, slug, state) {
64
67
  const dir = dotClaudeDir(cwd);
package/hash-utils.mjs CHANGED
@@ -68,6 +68,18 @@ export function estimateJaccardFromMinHash(sig1, sig2) {
68
68
  if (sig1.length !== sig2.length) return 0;
69
69
  const numHashes = sig1.length / 8;
70
70
  if (numHashes === 0) return 0;
71
+ // NEGATIVE RESULT, kept so nobody re-proposes it (audit 2026-09-02 P2-13 suggested an
72
+ // "allocation-free comparison"). `slice()` does allocate two 8-char strings per band, and
73
+ // the caller is a full nested pair loop — ~125k pairs at the 500-row scan bound. A
74
+ // charCodeAt inner loop was written and measured against this form over all 124,750 pairs
75
+ // of a 500-title fixture: identical results (0 mismatches, so the rewrite was correct) and
76
+ // NO time difference — 0.77× / 1.08× / 1.03× across three passes, i.e. the first pass was
77
+ // slower and the rest were noise. V8 handles short slices well enough that the byte loop
78
+ // buys nothing, and the whole pass is 0.6 ms.
79
+ //
80
+ // So the slice form stays: it is the more readable of two equally fast implementations,
81
+ // and shipping the other would be churn with a performance claim behind it that the
82
+ // measurement does not support.
71
83
  let matches = 0;
72
84
  for (let i = 0; i < numHashes; i++) {
73
85
  const offset = i * 8;
package/hook-context.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // Handles adaptive time windows, token-budgeted selection, and legacy CLAUDE.md cleanup.
7
7
 
8
8
  import { basename, join } from 'path';
9
- import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
10
+ import { atomicWriteFileSync } from './lib/atomic-write.mjs';
10
11
  import {
11
12
  estimateTokens, truncate, typeIcon, fmtTime, inferProject,
12
13
  debugLog, neutralizeContextDelimiters,
@@ -351,16 +352,16 @@ export function cleanupClaudeMdLegacyBlock() {
351
352
 
352
353
  if (normalized === content) { dropMarker(); return; }
353
354
 
354
- // Per-pid temp suffix so two concurrent first-run SessionStarts in the same
355
- // project (e.g. two terminals) can't rename each other's half-written temp
356
- // onto the user's tracked CLAUDE.md. Matches the idiom in hook-shared.mjs.
357
- const tmp = claudeMdPath + `.mem-tmp-${process.pid}`;
355
+ // atomicWriteFileSync keeps the per-pid temp (two concurrent first-run SessionStarts in
356
+ // one project must not rename each other's half-written temp onto the user's tracked
357
+ // CLAUDE.md) AND adds the lstat the local twin lacked: renaming onto a symlink NAME
358
+ // replaces the link with a regular file, orphaning a dotfiles-managed CLAUDE.md
359
+ // (audit 2026-09-02 P0-5). Its temp lands beside the RESOLVED target, so the rename
360
+ // stays same-device even when the real file lives on another mount.
358
361
  try {
359
- writeFileSync(tmp, normalized);
360
- renameSync(tmp, claudeMdPath);
362
+ atomicWriteFileSync(claudeMdPath, normalized);
361
363
  dropMarker();
362
364
  } catch (e) {
363
- try { unlinkSync(tmp); } catch {}
364
365
  debugLog('ERROR', 'cleanupClaudeMdLegacyBlock', `CLAUDE.md write failed: ${e.message}`);
365
366
  // Intentionally do NOT drop the marker on write failure — retry next session.
366
367
  }
package/hook-llm.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // claude-mem-lite: Background LLM workers for episode extraction and session summaries
2
2
  // Extracted from hook.mjs for testability and reduced complexity
3
3
 
4
- import { basename } from 'path';
5
- import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
4
+ import { basename, join } from 'path';
5
+ import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
6
6
  import {
7
7
  jaccardSimilarity, truncate, clampImportance, computeRuleImportance,
8
8
  inferProject, parseJsonFromLLM, scrubSecrets,
@@ -12,11 +12,11 @@ import {
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
13
  import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
14
14
  import { scrubRecord } from './lib/scrub-record.mjs';
15
- import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
16
- import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
15
+ import { vecTextForRow } from './tfidf.mjs';
16
+ import { insertObservationRow, insertObservationFiles, insertObservationVector, upsertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
17
17
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
18
18
  import {
19
- RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
19
+ RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS, ORPHAN_EPISODE_AGE_MS,
20
20
  sessionFile, getSessionId, openDb, callLLM, sleep,
21
21
  } from './hook-shared.mjs';
22
22
  import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
@@ -26,6 +26,47 @@ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
26
26
 
27
27
  import { DAY_MS } from './lib/time-constants.mjs';
28
28
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
29
+ import { recoverChildrenOf } from './lib/maintain-core.mjs';
30
+
31
+ /**
32
+ * Retract a pre-saved observation this worker created moments ago, after the Haiku
33
+ * round-trip decided the row is not worth keeping.
34
+ *
35
+ * Three call sites used to be three DELETEs and only ONE of them had the guard (audit
36
+ * 2026-09-02 P0-6). Seconds pass between the pre-save and the LLM verdict, and in that
37
+ * window auto-dedup or `save --supersedes` can make the row a keeper or a tombstone:
38
+ * - not live -> not ours to hard-delete any more (a keeper may have absorbed it, and
39
+ * children can point at it through compressed_into). Leave it; the
40
+ * maintenance path deletes with recovery.
41
+ * - live -> still recover any children FIRST, the same order lib/delete-core.mjs
42
+ * uses, so nothing dangles behind a now-missing parent.
43
+ * Exported for a direct test only (no importer in production). Same call as `inertMarkerIds`
44
+ * in lib/patha-exclude-meter.mjs: the rule it encodes is one a review just caught missing on
45
+ * two of three sites, so it gets a test of its own rather than being reachable only through
46
+ * two mocked LLM workers.
47
+ * @returns {boolean} true when the row was actually removed
48
+ */
49
+ export function retractPreSavedObs(db, obsId, where) {
50
+ // Liveness is checked BEFORE the recovery, not folded into the DELETE's WHERE. A first
51
+ // cut ran recoverChildrenOf unconditionally and let the guard live only on the DELETE:
52
+ // on the not-live branch the DELETE was then a no-op while the children had already been
53
+ // un-hidden, so a dedup that had legitimately folded #M into #N was silently undone and
54
+ // the debug line still said the row was "left in place" (true of #N, false of #M).
55
+ // Both statements run in one transaction so a crash between them cannot leave that state
56
+ // either; nesting under persistHaikuSummary's transaction is safe (better-sqlite3 uses
57
+ // savepoints).
58
+ return db.transaction(() => {
59
+ const live = db.prepare(
60
+ `SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
61
+ ).get(obsId);
62
+ if (!live) {
63
+ debugLog('DEBUG', 'llm-episode', `${where}: pre-saved obs #${obsId} no longer live — left in place`);
64
+ return false;
65
+ }
66
+ recoverChildrenOf(db, [obsId]);
67
+ return db.prepare('DELETE FROM observations WHERE id = ?').run(obsId).changes > 0;
68
+ })();
69
+ }
29
70
  // T9: memdir-incompatible types live in the `events` table, not `observations`.
30
71
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
31
72
  const EVENT_TYPE_SET = new Set(EVENT_TYPES);
@@ -358,18 +399,10 @@ export function persistHaikuSummary(db, summary, ctx) {
358
399
 
359
400
  if (ctx.preSavedObsId) {
360
401
  const id = db.transaction(() => {
361
- // Same live-row guard as the in-place upgrade (FLOW-7). If auto-dedup superseded
362
- // or compressed the pre-saved row while this worker was in flight, that row is no
363
- // longer ours to hard-delete a keeper may have absorbed it, and children can
364
- // point at it through compressed_into. Leave it to the maintenance path, which
365
- // recovers children before deleting; the event still gets written either way.
366
- const removed = db.prepare(
367
- `DELETE FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
368
- ).run(ctx.preSavedObsId).changes;
369
- if (removed === 0) {
370
- debugLog('DEBUG', 'llm-episode',
371
- `upgrade-delete: pre-saved obs #${ctx.preSavedObsId} no longer live — left in place`);
372
- }
402
+ // Same live-row guard as the in-place upgrade (FLOW-7); the event gets written
403
+ // either way. Shared helper since audit 2026-09-02 P0-6 this was the only one
404
+ // of the three retraction sites that carried the guard.
405
+ retractPreSavedObs(db, ctx.preSavedObsId, 'upgrade-delete');
373
406
  return insertEvent();
374
407
  })();
375
408
  return { table: 'events', id };
@@ -846,7 +879,7 @@ ${actionList}`;
846
879
  if (episode.savedId) {
847
880
  const ddb = openDb();
848
881
  if (ddb) {
849
- try { ddb.prepare('DELETE FROM observations WHERE id = ?').run(episode.savedId); }
882
+ try { retractPreSavedObs(ddb, episode.savedId, 'low-value-discard'); }
850
883
  finally { ddb.close(); }
851
884
  }
852
885
  }
@@ -971,7 +1004,7 @@ ${actionList}`;
971
1004
  if (episode.savedId) {
972
1005
  const ddb = openDb();
973
1006
  if (ddb) {
974
- try { ddb.prepare('DELETE FROM observations WHERE id = ?').run(episode.savedId); }
1007
+ try { retractPreSavedObs(ddb, episode.savedId, 'low-yield-change-drop'); }
975
1008
  finally { ddb.close(); }
976
1009
  }
977
1010
  }
@@ -1076,18 +1109,13 @@ ${actionList}`;
1076
1109
  savedTable = 'observations';
1077
1110
  debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
1078
1111
 
1079
- // Update TF-IDF vector with enriched content
1080
- try {
1081
- const vocab = getVocabulary(db);
1082
- if (vocab) {
1083
- const vecText = vecTextForRow({ title: obs.title, narrative: obs.narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
1084
- const vec = computeVector(vecText, vocab);
1085
- if (vec) {
1086
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1087
- .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
1088
- }
1089
- }
1090
- } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
1112
+ // Update TF-IDF vector with enriched content. SQL + text derivation are
1113
+ // lib/observation-write.mjs's (audit 2026-09-02 P1-4); `gate: false` keeps this
1114
+ // path's prior behaviour, which never consulted vectorsEnabled().
1115
+ upsertObservationVector(db, savedId, {
1116
+ title: obs.title, narrative: obs.narrative, concepts: conceptsText,
1117
+ lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
1118
+ }, { gate: false, scope: 'handleLLMEpisode-vector' });
1091
1119
  }
1092
1120
  }
1093
1121
  } else {
@@ -1119,13 +1147,44 @@ ${actionList}`;
1119
1147
  export async function handleLLMSummary() {
1120
1148
  const parsed = parseInt(process.env.CLAUDE_MEM_FLUSH_TIMEOUT, 10);
1121
1149
  const flushTimeout = Number.isNaN(parsed) ? 15 : parsed;
1122
- for (let i = 0; i < flushTimeout; i++) {
1123
- try {
1124
- const files = readdirSync(RUNTIME_DIR).filter(f => f.startsWith('ep-flush-'));
1125
- if (files.length === 0) break;
1126
- } catch { break; }
1127
- debugLog('DEBUG', 'llm-summary', `waiting for flush files (${i + 1}/15)`);
1150
+
1151
+ // Wait for a DEFINED SET of flush files, not for "the directory is empty" (audit
1152
+ // 2026-09-02 P1-7). RUNTIME_DIR is shared by every project on the machine, and the old
1153
+ // predicate was `readdirSync(RUNTIME_DIR).some(f => f.startsWith('ep-flush-'))` — so:
1154
+ //
1155
+ // • ONE crashed llm-episode worker leaves a file nothing will ever delete, and from
1156
+ // then on EVERY project's summary burns the full 15 s on every Stop until the next
1157
+ // maintain run sweeps it. Orphan cleanup lives behind a 24 h gate, so "until then"
1158
+ // is up to a day.
1159
+ // • A flush spawned by an unrelated project WHILE this summary is waiting extends the
1160
+ // wait, for work this summary will never read.
1161
+ //
1162
+ // The set is snapshotted at entry and filtered to files young enough to belong to a live
1163
+ // worker. A file that appears after this point is somebody else's; a file older than
1164
+ // ORPHAN_EPISODE_AGE_MS is nobody's. Both were previously indistinguishable from work in
1165
+ // progress.
1166
+ //
1167
+ // Not narrowed to this project: the flush filename is `ep-flush-<ts>-<rand>.json` and
1168
+ // carries no project. Widening it is a marker-format change with in-flight files during
1169
+ // an upgrade, and the two filters above already remove the unbounded cases — what is left
1170
+ // is a bounded overlap with genuinely concurrent work.
1171
+ let pending;
1172
+ try {
1173
+ const cutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
1174
+ pending = readdirSync(RUNTIME_DIR)
1175
+ .filter((f) => f.startsWith('ep-flush-'))
1176
+ .filter((f) => {
1177
+ try { return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff; } catch { return false; }
1178
+ });
1179
+ } catch { pending = []; }
1180
+
1181
+ for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
1128
1182
  await sleep(1000);
1183
+ pending = pending.filter((f) => existsSync(join(RUNTIME_DIR, f)));
1184
+ debugLog('DEBUG', 'llm-summary', `waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`);
1185
+ }
1186
+ if (pending.length > 0) {
1187
+ debugLog('DEBUG', 'llm-summary', `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`);
1129
1188
  }
1130
1189
 
1131
1190
  const db = openDb();
package/hook-optimize.mjs CHANGED
@@ -16,15 +16,18 @@ import {
16
16
  import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
17
17
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
18
18
  import { scrubRecord } from './lib/scrub-record.mjs';
19
- import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
19
+ import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
20
20
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
21
21
  import { DB_DIR } from './schema.mjs';
22
22
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
23
- import { normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
23
+ import { normalizeScope, SCOPE_PROMPT_LEGEND, upsertObservationVector } from './lib/observation-write.mjs';
24
24
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
25
+ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
25
26
 
26
27
  import { DAY_MS } from './lib/time-constants.mjs';
27
- const RUNTIME_DIR = join(DB_DIR, 'runtime');
28
+ // P1-14: same resolver as hook-shared.mjs — this was the second module that had never
29
+ // heard of CLAUDE_MEM_RUNTIME_DIR, and a third hand-written copy of the join().
30
+ const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
28
31
 
29
32
  // ─── Budget ─────────────────────────────────────────────────────────────────
30
33
 
@@ -49,30 +52,22 @@ export function distributeBudget(total = 15) {
49
52
 
50
53
  /**
51
54
  * Rebuild TF-IDF vector for an observation. Non-critical — swallows errors.
52
- * Exported for testing; also kept as the single source of vector-rebuild logic
53
- * for the optimize / re-enrich path to avoid drift with the hook-llm write path.
55
+ *
56
+ * The SQL, the text derivation and the vocab lookup are lib/observation-write.mjs's since
57
+ * audit 2026-09-02 P1-4; this is the optimize path's name for it. Accepts a legacy [parts]
58
+ * array OR an observation row (preferred — vecTextForRow gives the same field set the save
59
+ * path uses, so a rebuild matches the original write).
60
+ *
61
+ * `gate: false` preserves this path's behaviour exactly: it never checked
62
+ * `vectorsEnabled()`. That is a redundancy rather than a hole — `getVocabulary` returns
63
+ * null whenever the arm is off — but this refactor is not the place to decide it.
64
+ *
65
+ * The historical drift worth remembering: this copy wrote the column as `computed_at`
66
+ * instead of `created_at_epoch`, and its own catch swallowed the error until an experiment
67
+ * surfaced it. That is what a fifth copy of a statement buys.
54
68
  */
55
69
  export function rebuildVector(db, obsId, textPartsOrRow) {
56
- try {
57
- const vocab = getVocabulary(db);
58
- if (!vocab) return;
59
- // Accept a legacy [parts] array OR an observation row (preferred — single-source field set
60
- // incl. lesson_learned/search_aliases via vecTextForRow, so rebuilds match the save path).
61
- const text = Array.isArray(textPartsOrRow)
62
- ? textPartsOrRow.filter(Boolean).join(' ')
63
- : vecTextForRow(textPartsOrRow);
64
- const vec = computeVector(text, vocab);
65
- if (vec) {
66
- // Bug #1 fix: column is `created_at_epoch`, not `computed_at`. Every other
67
- // INSERT callsite (server.mjs, hook-llm.mjs, mem-cli.mjs) uses the correct
68
- // name; this was the only drift, silently caught by the catch below until
69
- // the R-7 experiment surfaced it.
70
- db.prepare(`
71
- INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch)
72
- VALUES (?, ?, ?, ?)
73
- `).run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
74
- }
75
- } catch (e) { debugCatch(e, 'optimize-vector'); }
70
+ upsertObservationVector(db, obsId, textPartsOrRow, { gate: false, scope: 'optimize-vector' });
76
71
  }
77
72
 
78
73
  // ─── Task 1: Re-enrich ─────────────────────────────────────────────────────
@@ -676,7 +671,19 @@ Return ONLY valid JSON:
676
671
  text: textField,
677
672
  lesson_learned: lessonLearned,
678
673
  });
679
- db.transaction(() => {
674
+ const mergeApplied = db.transaction(() => {
675
+ // Re-check the keeper's liveness INSIDE the transaction (audit 2026-09-02 P0-3).
676
+ // findMergeCandidates selected live rows, but a Sonnet round-trip sits between that
677
+ // SELECT and this write (:617, BG_LLM_TIMEOUT_MS), and a concurrent SessionStart
678
+ // auto-dedup or `save --supersedes` can tombstone the keeper in that window. Pointing
679
+ // the other members at a tombstoned keeper is precisely the "buried behind a hidden
680
+ // parent" loss that mergeDuplicates' docblock enumerates (lib/maintain-core.mjs), and
681
+ // it is what that function's own isLive gate exists to prevent. Same predicate here.
682
+ const keeperLive = db.prepare(
683
+ `SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`
684
+ ).get(keeper.id);
685
+ if (!keeperLive) return false;
686
+
680
687
  // Snapshot the keeper's pre-merge row BEFORE overwriting it, so its original
681
688
  // full text survives as a recoverable compressed_into child (mirroring
682
689
  // compressGroup / recoverChildrenOf). The keeper is the cluster's most-
@@ -701,9 +708,16 @@ Return ONLY valid JSON:
701
708
 
702
709
  const otherIds = others.map(o => o.id);
703
710
  const ph = otherIds.map(() => '?').join(',');
704
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph})`)
711
+ // Live guard on the members too: one already compressed into ANOTHER summary S during
712
+ // the LLM window would be re-pointed here, silently dropping a row out of S's child set.
713
+ db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
705
714
  .run(keeper.id, ...otherIds);
715
+ return true;
706
716
  })();
717
+ if (!mergeApplied) {
718
+ debugLog('DEBUG', 'llm-optimize', `cluster-merge aborted: keeper #${keeper.id} no longer live`);
719
+ return { merged: false };
720
+ }
707
721
 
708
722
  rebuildVector(db, keeper.id, { title, narrative, concepts: conceptsText, lesson_learned: lessonLearned, search_aliases: keeper.search_aliases });
709
723
 
@@ -741,7 +755,11 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
741
755
  const stmt = db.prepare(`
742
756
  SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
743
757
  FROM observations
744
- WHERE COALESCE(compressed_into, 0) = 0
758
+ -- liveObsFilterSql, not compressed_into alone (audit 2026-09-02 P0-3): auto-dedup losers
759
+ -- carry superseded_at with compressed_into=0 and match this predicate exactly (imp=1,
760
+ -- access 0, no lesson), so the narrower filter fed RETRACTED text to Sonnet and returned
761
+ -- it to live retrieval as a fresh "discovery" summary. Parity with findMergeCandidates.
762
+ WHERE ${liveObsFilterSql('')}
745
763
  AND COALESCE(importance, 1) = 1
746
764
  AND COALESCE(access_count, 0) = 0
747
765
  -- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
@@ -903,7 +921,11 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
903
921
 
904
922
  const obsIds = observations.map(o => o.id);
905
923
  const ph = obsIds.map(() => '?').join(',');
906
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph})`)
924
+ // Live guard (audit 2026-09-02 P0-3): the candidate SELECT is separated from this write
925
+ // by a Sonnet round-trip, so a member may already be compressed into another summary or
926
+ // tombstoned. Re-pointing it here would silently remove a row from that summary's child
927
+ // set. Members that lost liveness stay where they are; the summary still lands.
928
+ db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
907
929
  .run(sId, ...obsIds);
908
930
 
909
931
  return sId;
package/hook-shared.mjs CHANGED
@@ -6,7 +6,9 @@ import { randomUUID } from 'crypto';
6
6
  import { join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
+ import { CITE_RECALL_FILE_PREFIX } from './lib/cite-recall-path.mjs';
9
10
  import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
11
+ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
10
12
  // Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
11
13
  // neither imports this file) — no cycle.
12
14
  import { recordHookError } from './lib/hook-telemetry.mjs';
@@ -21,7 +23,11 @@ import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
21
23
  import { DAY_MS } from './lib/time-constants.mjs';
22
24
  // ─── Constants ────────────────────────────────────────────────────────────────
23
25
 
24
- export const RUNTIME_DIR = join(DB_DIR, 'runtime');
26
+ // P1-14: one resolver, so this module honours CLAUDE_MEM_RUNTIME_DIR like the five
27
+ // standalone hook scripts already did. It did not, and hook.mjs / server.mjs /
28
+ // hook-context.mjs / hook-episode.mjs all take RUNTIME_DIR from here — so the override
29
+ // split the runtime dir in half instead of relocating it.
30
+ export const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
25
31
  export const SCRIPT_PATH = process.argv[1];
26
32
 
27
33
  // Timing constants
@@ -104,18 +110,53 @@ export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
104
110
  // stale to any current episode) while leaving every active session's file untouched.
105
111
  export const ORPHAN_READS_AGE_MS = 24 * 60 * 60 * 1000;
106
112
 
107
- // Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h) and
108
- // `reads-*.txt` (older than `readsAgeMs`, default 24h) files in `runtimeDir` by
109
- // mtime. Returns the number of files removed. fs-only no DB / no network. Used by
113
+ // `ep-<project>.json` the LIVE episode buffer, one file per project — had no reclamation
114
+ // path at all: it is excluded from both marker-GC lists below (correctly: it holds unflushed
115
+ // observations, not cache) and `sweepOrphanEpisodeFiles` only ever matched `ep-flush-`.
116
+ // A real install on 2026-09-02 held four of them for projects deleted months earlier, the
117
+ // oldest 53 days (`ep-tmp--loop-testing-e2e.*.json`, 07-11).
118
+ //
119
+ // Leaving them is not neutral. `readEpisode` has no staleness gate, so `handleSessionStart`
120
+ // unconditionally flushes whatever it finds (hook.mjs "Flush any leftover episode buffer") —
121
+ // revisiting such a project injects months-old tool activity into today's memory stamped
122
+ // with today's date. The stale buffer is not preserved data, it is data that will be
123
+ // mis-dated the moment anyone touches the project again.
124
+ //
125
+ // 7 days, and the argument is that no LEGITIMATE state needs a buffer to live even one day:
126
+ // `EPISODE_TIME_GAP_MS` is 5 min and `SESSION_EXPIRY_MS` is 12 h, so a buffer untouched for
127
+ // 7 days outlived its owning session by an order of magnitude. The margin over 12 h is
128
+ // deliberate slack for a laptop suspended across a long weekend, not a second threshold with
129
+ // its own meaning. Considered and rejected: flushing on sweep instead of deleting — it would
130
+ // re-date the content exactly the way the revisit path does, i.e. commit the defect on
131
+ // purpose rather than by omission.
132
+ //
133
+ // Exported as of the pre-tag review for v3.92.0, on this constant's own stated rule ("export
134
+ // it the day something needs it"): the sweep alone does NOT close the harm described above.
135
+ // `handleSessionStart` flushes the leftover buffer in the FOREGROUND, and the sweeper runs
136
+ // later, in a detached auto-maintain worker — so on the revisit itself the mis-dated flush
137
+ // happens first and the sweeper then finds nothing. What the sweep delivers is dir-wide
138
+ // reclamation of OTHER projects' abandoned buffers; the same-project revisit needs the same
139
+ // threshold applied at the flush, which is `hook.mjs`'s importer of this symbol.
140
+ // `bufferAgeMs` stays a parameter so a test can pin the sweep threshold without an import.
141
+ export const STALE_EPISODE_BUFFER_AGE_MS = 7 * 24 * 60 * 60 * 1000;
142
+
143
+ // Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h),
144
+ // `reads-*.txt` (older than `readsAgeMs`, default 24h) and abandoned per-project episode
145
+ // buffers `ep-<project>.json` (older than `bufferAgeMs`, default 7d) in `runtimeDir` by
146
+ // mtime. `onSweep(name, kind)` is called before each unlink so a caller can log the one
147
+ // deletion that discards content rather than residue ('buffer'); it is a callback rather
148
+ // than a debugLog here because this module is imported by every hook entry point and stays
149
+ // dependency-free. Returns the number of files removed. fs-only — no DB / no network. Used by
110
150
  // handleSessionStart auto-maintain to prevent the doctor "Stale temp files" warning
111
151
  // from accumulating across crashes; equivalent to the manual path in
112
152
  // `node install.mjs cleanup` but age-gated so concurrent in-flight workers / active
113
153
  // read sessions are never raced.
114
- export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, now = Date.now() } = {}) {
154
+ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, bufferAgeMs = STALE_EPISODE_BUFFER_AGE_MS, now = Date.now(), onSweep = () => {} } = {}) {
115
155
  let entries;
116
156
  try { entries = readdirSync(runtimeDir); } catch { return 0; }
117
157
  const cutoff = now - ageMs;
118
158
  const readsCutoff = now - readsAgeMs;
159
+ const bufferCutoff = now - bufferAgeMs;
119
160
  let count = 0;
120
161
  for (const f of entries) {
121
162
  // Crash residue: this runtime dir writes four families of temp name, each the middle
@@ -139,15 +180,22 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
139
180
  const isCrashResidue = /\.(claim|collect|trim|tmp)-[^.]*$/.test(f);
140
181
  const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
141
182
  const isReads = f.startsWith('reads-') && f.endsWith('.txt');
142
- if (!isCrashResidue && !isEpisode && !isReads) continue;
183
+ // The live per-project buffer, on its own 7-day cutoff. `ep-flush-*` is also `ep-`-
184
+ // prefixed AND also ends in `.json`, so the exclusion is load-bearing, not defensive:
185
+ // without it a queued flush file would jump from the 1h cutoff to the 7d one.
186
+ const isStaleBuffer = f.startsWith('ep-') && !f.startsWith('ep-flush-') && f.endsWith('.json');
187
+ if (!isCrashResidue && !isEpisode && !isReads && !isStaleBuffer) continue;
143
188
  const full = join(runtimeDir, f);
144
189
  try {
145
190
  // Residue takes the short cutoff and a live tracker takes the 24h one, with no
146
191
  // tie-break needed: residue always APPENDS its suffix, so it never ends in `.txt`
147
192
  // and `isReads` is already false for it. (A `&& !isCrashResidue` tie-break was
148
193
  // written here first and no mutation could kill it — it was guarding a state the
149
- // two predicates cannot both be in.)
150
- if (statSync(full).mtimeMs < (isReads ? readsCutoff : cutoff)) {
194
+ // two predicates cannot both be in.) `isStaleBuffer` is in the same position: a
195
+ // residue name ends in `.tmp-<pid>`, never `.json`.
196
+ const fileCutoff = isReads ? readsCutoff : isStaleBuffer ? bufferCutoff : cutoff;
197
+ if (statSync(full).mtimeMs < fileCutoff) {
198
+ try { onSweep(f, isStaleBuffer ? 'buffer' : isReads ? 'reads' : isCrashResidue ? 'residue' : 'episode'); } catch { /* logging must never block the sweep */ }
151
199
  unlinkSync(full);
152
200
  count++;
153
201
  }
@@ -177,15 +225,17 @@ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
177
225
  // Regenerated on demand; safe to lose at any time.
178
226
  export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
179
227
  'session-', // project → memory-session-id pointer
180
- 'cite-recall-', // last session's cite-recall snapshot (nudge input)
228
+ CITE_RECALL_FILE_PREFIX, // last session's cite-recall snapshot (nudge input)
181
229
  '.skill-cooldown-', // suggestion throttle timestamp
182
230
  '.skill-reco-cooldown-', // recommendation throttle timestamp
183
231
  ]);
184
232
 
185
233
  // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
186
- // `pending-`/`reads-` are absent from BOTH lists on purpose: the first holds
187
- // unflushed observations (data, not cache) and the rest already belong to
188
- // sweepOrphanEpisodeFiles on tighter cutoffs.
234
+ // `pending-`/`reads-` are absent from BOTH lists on purpose: they all belong to
235
+ // sweepOrphanEpisodeFiles, on three cutoffs of their own (1h residue / 24h reads /
236
+ // 7d abandoned buffer). `ep-<project>.json` was the one with no cutoff at all until
237
+ // audit P1-12 — it is still not marker-GC-able here, because 30 days of unflushed
238
+ // observations is far past the point where flushing them would mis-date them.
189
239
  export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
190
240
  '.auto-adopt-',
191
241
  '.deferred-block-migrated-',