claude-mem-lite 3.91.0 → 3.92.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.91.0",
13
+ "version": "3.92.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.92.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` |
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/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
@@ -12,8 +12,8 @@ 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
19
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
@@ -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 {
package/hook-optimize.mjs CHANGED
@@ -16,11 +16,11 @@ 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
25
 
26
26
  import { DAY_MS } from './lib/time-constants.mjs';
@@ -49,30 +49,22 @@ export function distributeBudget(total = 15) {
49
49
 
50
50
  /**
51
51
  * 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.
52
+ *
53
+ * The SQL, the text derivation and the vocab lookup are lib/observation-write.mjs's since
54
+ * audit 2026-09-02 P1-4; this is the optimize path's name for it. Accepts a legacy [parts]
55
+ * array OR an observation row (preferred — vecTextForRow gives the same field set the save
56
+ * path uses, so a rebuild matches the original write).
57
+ *
58
+ * `gate: false` preserves this path's behaviour exactly: it never checked
59
+ * `vectorsEnabled()`. That is a redundancy rather than a hole — `getVocabulary` returns
60
+ * null whenever the arm is off — but this refactor is not the place to decide it.
61
+ *
62
+ * The historical drift worth remembering: this copy wrote the column as `computed_at`
63
+ * instead of `created_at_epoch`, and its own catch swallowed the error until an experiment
64
+ * surfaced it. That is what a fifth copy of a statement buys.
54
65
  */
55
66
  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'); }
67
+ upsertObservationVector(db, obsId, textPartsOrRow, { gate: false, scope: 'optimize-vector' });
76
68
  }
77
69
 
78
70
  // ─── Task 1: Re-enrich ─────────────────────────────────────────────────────
@@ -676,7 +668,19 @@ Return ONLY valid JSON:
676
668
  text: textField,
677
669
  lesson_learned: lessonLearned,
678
670
  });
679
- db.transaction(() => {
671
+ const mergeApplied = db.transaction(() => {
672
+ // Re-check the keeper's liveness INSIDE the transaction (audit 2026-09-02 P0-3).
673
+ // findMergeCandidates selected live rows, but a Sonnet round-trip sits between that
674
+ // SELECT and this write (:617, BG_LLM_TIMEOUT_MS), and a concurrent SessionStart
675
+ // auto-dedup or `save --supersedes` can tombstone the keeper in that window. Pointing
676
+ // the other members at a tombstoned keeper is precisely the "buried behind a hidden
677
+ // parent" loss that mergeDuplicates' docblock enumerates (lib/maintain-core.mjs), and
678
+ // it is what that function's own isLive gate exists to prevent. Same predicate here.
679
+ const keeperLive = db.prepare(
680
+ `SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`
681
+ ).get(keeper.id);
682
+ if (!keeperLive) return false;
683
+
680
684
  // Snapshot the keeper's pre-merge row BEFORE overwriting it, so its original
681
685
  // full text survives as a recoverable compressed_into child (mirroring
682
686
  // compressGroup / recoverChildrenOf). The keeper is the cluster's most-
@@ -701,9 +705,16 @@ Return ONLY valid JSON:
701
705
 
702
706
  const otherIds = others.map(o => o.id);
703
707
  const ph = otherIds.map(() => '?').join(',');
704
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph})`)
708
+ // Live guard on the members too: one already compressed into ANOTHER summary S during
709
+ // the LLM window would be re-pointed here, silently dropping a row out of S's child set.
710
+ db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
705
711
  .run(keeper.id, ...otherIds);
712
+ return true;
706
713
  })();
714
+ if (!mergeApplied) {
715
+ debugLog('DEBUG', 'llm-optimize', `cluster-merge aborted: keeper #${keeper.id} no longer live`);
716
+ return { merged: false };
717
+ }
707
718
 
708
719
  rebuildVector(db, keeper.id, { title, narrative, concepts: conceptsText, lesson_learned: lessonLearned, search_aliases: keeper.search_aliases });
709
720
 
@@ -741,7 +752,11 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
741
752
  const stmt = db.prepare(`
742
753
  SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
743
754
  FROM observations
744
- WHERE COALESCE(compressed_into, 0) = 0
755
+ -- liveObsFilterSql, not compressed_into alone (audit 2026-09-02 P0-3): auto-dedup losers
756
+ -- carry superseded_at with compressed_into=0 and match this predicate exactly (imp=1,
757
+ -- access 0, no lesson), so the narrower filter fed RETRACTED text to Sonnet and returned
758
+ -- it to live retrieval as a fresh "discovery" summary. Parity with findMergeCandidates.
759
+ WHERE ${liveObsFilterSql('')}
745
760
  AND COALESCE(importance, 1) = 1
746
761
  AND COALESCE(access_count, 0) = 0
747
762
  -- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
@@ -903,7 +918,11 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
903
918
 
904
919
  const obsIds = observations.map(o => o.id);
905
920
  const ph = obsIds.map(() => '?').join(',');
906
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph})`)
921
+ // Live guard (audit 2026-09-02 P0-3): the candidate SELECT is separated from this write
922
+ // by a Sonnet round-trip, so a member may already be compressed into another summary or
923
+ // tombstoned. Re-pointing it here would silently remove a row from that summary's child
924
+ // set. Members that lost liveness stay where they are; the summary still lands.
925
+ db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
907
926
  .run(sId, ...obsIds);
908
927
 
909
928
  return sId;
package/hook-shared.mjs CHANGED
@@ -6,6 +6,7 @@ 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';
10
11
  // Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
11
12
  // neither imports this file) — no cycle.
@@ -104,18 +105,53 @@ export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
104
105
  // stale to any current episode) while leaving every active session's file untouched.
105
106
  export const ORPHAN_READS_AGE_MS = 24 * 60 * 60 * 1000;
106
107
 
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
108
+ // `ep-<project>.json` the LIVE episode buffer, one file per project — had no reclamation
109
+ // path at all: it is excluded from both marker-GC lists below (correctly: it holds unflushed
110
+ // observations, not cache) and `sweepOrphanEpisodeFiles` only ever matched `ep-flush-`.
111
+ // A real install on 2026-09-02 held four of them for projects deleted months earlier, the
112
+ // oldest 53 days (`ep-tmp--loop-testing-e2e.*.json`, 07-11).
113
+ //
114
+ // Leaving them is not neutral. `readEpisode` has no staleness gate, so `handleSessionStart`
115
+ // unconditionally flushes whatever it finds (hook.mjs "Flush any leftover episode buffer") —
116
+ // revisiting such a project injects months-old tool activity into today's memory stamped
117
+ // with today's date. The stale buffer is not preserved data, it is data that will be
118
+ // mis-dated the moment anyone touches the project again.
119
+ //
120
+ // 7 days, and the argument is that no LEGITIMATE state needs a buffer to live even one day:
121
+ // `EPISODE_TIME_GAP_MS` is 5 min and `SESSION_EXPIRY_MS` is 12 h, so a buffer untouched for
122
+ // 7 days outlived its owning session by an order of magnitude. The margin over 12 h is
123
+ // deliberate slack for a laptop suspended across a long weekend, not a second threshold with
124
+ // its own meaning. Considered and rejected: flushing on sweep instead of deleting — it would
125
+ // re-date the content exactly the way the revisit path does, i.e. commit the defect on
126
+ // purpose rather than by omission.
127
+ //
128
+ // Exported as of the pre-tag review for v3.92.0, on this constant's own stated rule ("export
129
+ // it the day something needs it"): the sweep alone does NOT close the harm described above.
130
+ // `handleSessionStart` flushes the leftover buffer in the FOREGROUND, and the sweeper runs
131
+ // later, in a detached auto-maintain worker — so on the revisit itself the mis-dated flush
132
+ // happens first and the sweeper then finds nothing. What the sweep delivers is dir-wide
133
+ // reclamation of OTHER projects' abandoned buffers; the same-project revisit needs the same
134
+ // threshold applied at the flush, which is `hook.mjs`'s importer of this symbol.
135
+ // `bufferAgeMs` stays a parameter so a test can pin the sweep threshold without an import.
136
+ export const STALE_EPISODE_BUFFER_AGE_MS = 7 * 24 * 60 * 60 * 1000;
137
+
138
+ // Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h),
139
+ // `reads-*.txt` (older than `readsAgeMs`, default 24h) and abandoned per-project episode
140
+ // buffers `ep-<project>.json` (older than `bufferAgeMs`, default 7d) in `runtimeDir` by
141
+ // mtime. `onSweep(name, kind)` is called before each unlink so a caller can log the one
142
+ // deletion that discards content rather than residue ('buffer'); it is a callback rather
143
+ // than a debugLog here because this module is imported by every hook entry point and stays
144
+ // dependency-free. Returns the number of files removed. fs-only — no DB / no network. Used by
110
145
  // handleSessionStart auto-maintain to prevent the doctor "Stale temp files" warning
111
146
  // from accumulating across crashes; equivalent to the manual path in
112
147
  // `node install.mjs cleanup` but age-gated so concurrent in-flight workers / active
113
148
  // read sessions are never raced.
114
- export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, now = Date.now() } = {}) {
149
+ 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
150
  let entries;
116
151
  try { entries = readdirSync(runtimeDir); } catch { return 0; }
117
152
  const cutoff = now - ageMs;
118
153
  const readsCutoff = now - readsAgeMs;
154
+ const bufferCutoff = now - bufferAgeMs;
119
155
  let count = 0;
120
156
  for (const f of entries) {
121
157
  // Crash residue: this runtime dir writes four families of temp name, each the middle
@@ -139,15 +175,22 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
139
175
  const isCrashResidue = /\.(claim|collect|trim|tmp)-[^.]*$/.test(f);
140
176
  const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
141
177
  const isReads = f.startsWith('reads-') && f.endsWith('.txt');
142
- if (!isCrashResidue && !isEpisode && !isReads) continue;
178
+ // The live per-project buffer, on its own 7-day cutoff. `ep-flush-*` is also `ep-`-
179
+ // prefixed AND also ends in `.json`, so the exclusion is load-bearing, not defensive:
180
+ // without it a queued flush file would jump from the 1h cutoff to the 7d one.
181
+ const isStaleBuffer = f.startsWith('ep-') && !f.startsWith('ep-flush-') && f.endsWith('.json');
182
+ if (!isCrashResidue && !isEpisode && !isReads && !isStaleBuffer) continue;
143
183
  const full = join(runtimeDir, f);
144
184
  try {
145
185
  // Residue takes the short cutoff and a live tracker takes the 24h one, with no
146
186
  // tie-break needed: residue always APPENDS its suffix, so it never ends in `.txt`
147
187
  // and `isReads` is already false for it. (A `&& !isCrashResidue` tie-break was
148
188
  // 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)) {
189
+ // two predicates cannot both be in.) `isStaleBuffer` is in the same position: a
190
+ // residue name ends in `.tmp-<pid>`, never `.json`.
191
+ const fileCutoff = isReads ? readsCutoff : isStaleBuffer ? bufferCutoff : cutoff;
192
+ if (statSync(full).mtimeMs < fileCutoff) {
193
+ try { onSweep(f, isStaleBuffer ? 'buffer' : isReads ? 'reads' : isCrashResidue ? 'residue' : 'episode'); } catch { /* logging must never block the sweep */ }
151
194
  unlinkSync(full);
152
195
  count++;
153
196
  }
@@ -177,15 +220,17 @@ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
177
220
  // Regenerated on demand; safe to lose at any time.
178
221
  export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
179
222
  'session-', // project → memory-session-id pointer
180
- 'cite-recall-', // last session's cite-recall snapshot (nudge input)
223
+ CITE_RECALL_FILE_PREFIX, // last session's cite-recall snapshot (nudge input)
181
224
  '.skill-cooldown-', // suggestion throttle timestamp
182
225
  '.skill-reco-cooldown-', // recommendation throttle timestamp
183
226
  ]);
184
227
 
185
228
  // 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.
229
+ // `pending-`/`reads-` are absent from BOTH lists on purpose: they all belong to
230
+ // sweepOrphanEpisodeFiles, on three cutoffs of their own (1h residue / 24h reads /
231
+ // 7d abandoned buffer). `ep-<project>.json` was the one with no cutoff at all until
232
+ // audit P1-12 — it is still not marker-GC-able here, because 30 days of unflushed
233
+ // observations is far past the point where flushing them would mis-date them.
189
234
  export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
190
235
  '.auto-adopt-',
191
236
  '.deferred-block-migrated-',