claude-mem-lite 3.6.0 → 3.7.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.6.0",
13
+ "version": "3.7.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.6.0",
3
+ "version": "3.7.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
@@ -144,7 +144,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
144
144
 
145
145
  ## Requirements
146
146
 
147
- - **Node.js** >= 18
147
+ - **Node.js** >= 20
148
148
  - **Claude Code** CLI installed and configured (`claude` command available)
149
149
  - **SQLite3** support (provided by `better-sqlite3`, compiled on install)
150
150
  - **Platform**: Linux or macOS (see [Platform Support](#platform-support))
@@ -632,17 +632,25 @@ claude-mem-lite/
632
632
 
633
633
  ## Search Quality
634
634
 
635
- Benchmarked on 200 observations across 30 queries (standard + hard-negative categories):
636
-
637
- | Metric | Score |
638
- |--------|-------|
639
- | Recall@10 | 0.88 |
640
- | Precision@10 | 0.96 |
641
- | nDCG@10 | 0.95 |
642
- | MRR@10 | 0.95 |
643
- | P95 search latency | 0.15ms |
644
-
645
- The benchmark suite runs as a CI gate (`npm run benchmark:gate`) to prevent search quality regressions.
635
+ Benchmarked on 200 observations across 30 queries (standard + hard-negative categories),
636
+ measuring the **production-hybrid** retriever (FTS5 BM25 + TF-IDF vector + RRF) — the path
637
+ `mem_search` / `recall` actually use. The CI gate (`npm run benchmark:gate`) runs this same
638
+ path and fails on regression.
639
+
640
+ | Metric | Score (production-hybrid) |
641
+ |--------|---------------------------|
642
+ | Recall@10 | 0.90 |
643
+ | Precision@10 | 0.79 |
644
+ | nDCG@10 | 0.97 |
645
+ | MRR@10 | 0.97 |
646
+ | P95 search latency | ~3ms |
647
+
648
+ > **Note on the path measured.** Earlier versions of this table reported the *lexical*
649
+ > FTS-only path (Precision@10 0.96, P95 0.15ms). The hybrid vector arm trades raw
650
+ > precision@10 for higher recall / nDCG / MRR by surfacing semantically-related candidates
651
+ > beyond exact lexical matches; the gate now measures the hybrid path so these numbers
652
+ > reflect real `mem_search` behavior. For field-comparable recall, see the LongMemEval
653
+ > section below.
646
654
 
647
655
  ### Recall on LongMemEval (standard benchmark)
648
656
 
package/deep-search.mjs CHANGED
@@ -103,7 +103,9 @@ export function autoDeepLlmReady(env = process.env, injectedLlm) {
103
103
  * an LLM, so the decision itself is free — only a positive verdict costs a
104
104
  * Haiku call (the escalation).
105
105
  *
106
- * Weak when: too few results (count below minResults floor).
106
+ * Weak when: too few results (count below minResults floor) AND the corpus is
107
+ * large enough that deep search could plausibly find more (see corpus guard
108
+ * below).
107
109
  *
108
110
  * NOTE: ctx.orFallbackFired was intentionally removed as an escalation trigger.
109
111
  * orFallbackFired fires on SUCCESSFUL AND→OR recovery — when the fallback
@@ -114,17 +116,37 @@ export function autoDeepLlmReady(env = process.env, injectedLlm) {
114
116
  * fails, OR also fails) is still caught: if OR recovers nothing, count is 0-2
115
117
  * → escalates on count alone.
116
118
  *
119
+ * Corpus guard (folded in): the count-based trigger above is correct for a real
120
+ * corpus, but on a near-empty / brand-new / benchmark project EVERY 0-hit query
121
+ * looks "weak", so a caller that only checks the count would auto-escalate (and
122
+ * fire a Haiku rewrite) on a store HyDE/multi-query can't possibly rescue — the
123
+ * "[mem] auto-escalated … 0 hits" spam. hasEscalatableCorpus used to be a
124
+ * SEPARATE function each caller had to remember to AND in; folding it in here
125
+ * means passing `db` self-suppresses escalation when the corpus is too small,
126
+ * without changing the (correct) count trigger for real corpora. Backward-
127
+ * compatible: callers that omit `db` keep the pure count behaviour (and may
128
+ * still AND hasEscalatableCorpus themselves — double-gating with the same
129
+ * predicate is idempotent, never a regression).
130
+ *
117
131
  * @param {Array} results normal-search rows
118
132
  * @param {object} ctx the hybrid ctx the engine mutated (unused; kept for
119
133
  * backward-compat with callers that pass it)
120
134
  * @param {object} [opts]
121
135
  * @param {number} [opts.minResults=AUTO_DEEP_MIN_RESULTS]
136
+ * @param {Database} [opts.db] open handle — when given, the corpus-size guard is
137
+ * evaluated here so escalation is suppressed on a
138
+ * too-small store. Omit to keep pure count behaviour.
139
+ * @param {string} [opts.project] project scope for the corpus count (when db given)
140
+ * @param {number} [opts.minCorpus=AUTO_DEEP_MIN_CORPUS] corpus-size floor (when db given)
122
141
  * @returns {boolean}
123
142
  */
124
- export function shouldEscalateToDeep(results, _ctx, { minResults = AUTO_DEEP_MIN_RESULTS } = {}) {
143
+ export function shouldEscalateToDeep(results, _ctx, { minResults = AUTO_DEEP_MIN_RESULTS, db, project = null, minCorpus = AUTO_DEEP_MIN_CORPUS } = {}) {
125
144
  const n = Array.isArray(results) ? results.length : 0;
126
- if (n < minResults) return true;
127
- return false;
145
+ if (n >= minResults) return false;
146
+ // Count is weak. If a db was supplied, also require an escalatable corpus —
147
+ // this is the fold-in that stops 0-hit escalation on a near-empty store.
148
+ if (db && !hasEscalatableCorpus(db, project, minCorpus)) return false;
149
+ return true;
128
150
  }
129
151
 
130
152
  /**
package/hook-update.mjs CHANGED
@@ -13,6 +13,8 @@ import { debugCatch, debugLog } from './utils.mjs';
13
13
  // extracted tarball's own source-files.mjs inside installExtractedRelease.
14
14
  // See loadReleaseManifest below.
15
15
  import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCRIPT_FILES } from './source-files.mjs';
16
+ import { acquireLock } from './lib/proc-lock.mjs';
17
+ import { atomicWriteFileSync } from './lib/atomic-write.mjs';
16
18
 
17
19
  // ── Configuration ──────────────────────────────────────────
18
20
  const GITHUB_REPO = 'sdsrss/claude-mem-lite';
@@ -379,6 +381,16 @@ export function validateExtractedTarball(sourceDir, expectedVersion, expectedNam
379
381
  // the target's node_modules untouched. Dependency bumps still flow through the
380
382
  // GitHub-tarball path (downloadAndInstall), which keeps skipNpmInstall=false.
381
383
  export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR, opts = {}) {
384
+ // Cross-process lock: concurrent SessionStart self-heals / auto-updates must
385
+ // not interleave the rename loop below (→ mixed-version install). A live peer
386
+ // holding the lock means an install is already in flight — skip rather than
387
+ // race. Shared path with install.mjs so direct install + repair + auto-update
388
+ // are mutually exclusive.
389
+ const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock'));
390
+ if (!release) {
391
+ debugLog('DEBUG', 'hook-update', 'installExtractedRelease: another install/update is in progress — skipping');
392
+ return false;
393
+ }
382
394
  const ts = `${Date.now()}-${process.pid}`;
383
395
  const stagingDir = join(targetDir, `.update-staging-${ts}`);
384
396
  const backupDir = join(targetDir, `.update-backup-${ts}`);
@@ -439,7 +451,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
439
451
  debugLog('DEBUG', 'hook-update', `Post-update: removed stale global MCP "${k}"`);
440
452
  }
441
453
  }
442
- if (changed) writeFileSync(claudeJsonPath, JSON.stringify(cfg, null, 2) + '\n');
454
+ // Atomic + one-time backup: ~/.claude.json is the user's ENTIRE Claude
455
+ // Code config; a torn write here breaks them outside our control.
456
+ if (changed) atomicWriteFileSync(claudeJsonPath, JSON.stringify(cfg, null, 2) + '\n', { backup: true });
443
457
  }
444
458
  } catch (e) { debugCatch(e, 'post-update-mcp-dedup'); }
445
459
 
@@ -477,6 +491,8 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
477
491
  try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
478
492
  try { rmSync(backupDir, { recursive: true, force: true }); } catch {}
479
493
  return false;
494
+ } finally {
495
+ release();
480
496
  }
481
497
  }
482
498
 
package/install.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // claude-mem-lite Installer — Smart install/uninstall/status/doctor
3
3
 
4
4
  import { execSync, execFileSync } from 'child_process';
5
- import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
5
+ import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, mkdtempSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
6
6
  import { join, resolve, dirname, isAbsolute } from 'path';
7
7
  import { homedir, tmpdir } from 'os';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -41,6 +41,8 @@ import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
41
41
  import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
42
42
  import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
43
43
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
44
+ import { acquireLock } from './lib/proc-lock.mjs';
45
+ import { atomicWriteFileSync } from './lib/atomic-write.mjs';
44
46
 
45
47
  // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
46
48
  // and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
@@ -1807,11 +1809,11 @@ function readSettings() {
1807
1809
  }
1808
1810
 
1809
1811
  function writeSettings(settings) {
1810
- const settingsDir = dirname(SETTINGS_PATH);
1811
- if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
1812
- const tmp = SETTINGS_PATH + '.tmp';
1813
- writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
1814
- renameSync(tmp, SETTINGS_PATH);
1812
+ // Atomic (pid-unique temp + rename) with a one-time .bak: settings.json is the
1813
+ // user's Claude Code config. The old fixed ".tmp" name let concurrent installs
1814
+ // clobber each other's temp mid-write, and there was no recovery artifact if a
1815
+ // hook-merge bug dropped user config. atomicWriteFileSync handles dir creation.
1816
+ atomicWriteFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', { backup: true });
1815
1817
  }
1816
1818
 
1817
1819
  // ─── Cleanup Stale Files ─────────────────────────────────────────────────────
@@ -1919,8 +1921,7 @@ async function manualUpdate() {
1919
1921
  // buggy on disk.
1920
1922
  async function repair() {
1921
1923
  console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
1922
- const stagingDir = join(tmpdir(), `claude-mem-lite-repair-${Date.now()}`);
1923
- mkdirSync(stagingDir, { recursive: true });
1924
+ const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
1924
1925
  try {
1925
1926
  const tarballUrl = 'https://api.github.com/repos/sdsrss/claude-mem-lite/tarball';
1926
1927
  const tarballPath = join(stagingDir, 'release.tgz');
@@ -2027,13 +2028,27 @@ function regenerateLockfile() {
2027
2028
 
2028
2029
  // ─── Main ───────────────────────────────────────────────────────────────────
2029
2030
 
2031
+ // Cross-process gate around the install write phase. repair() is intentionally
2032
+ // NOT locked here: it spawns `install.mjs install` as a child, which takes this
2033
+ // lock — locking the parent too would deadlock. A live peer (another session's
2034
+ // install/self-heal) holds it → skip rather than race into a torn install. Lock
2035
+ // path is shared with hook-update.installExtractedRelease (both env-aware).
2036
+ async function runLockedInstall() {
2037
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2038
+ if (!release) {
2039
+ console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
2040
+ return;
2041
+ }
2042
+ try { await install(); } finally { release(); }
2043
+ }
2044
+
2030
2045
  export async function main(argv = process.argv.slice(2)) {
2031
2046
  cmd = argv[0];
2032
2047
  flags = new Set(argv.slice(1));
2033
2048
 
2034
2049
  switch (cmd) {
2035
2050
  case 'install':
2036
- await install();
2051
+ await runLockedInstall();
2037
2052
  break;
2038
2053
  case 'uninstall':
2039
2054
  await uninstall();
@@ -2064,7 +2079,7 @@ export async function main(argv = process.argv.slice(2)) {
2064
2079
  default:
2065
2080
  if (IS_NPX) {
2066
2081
  // npx claude-mem-lite (no args) → auto install
2067
- await install();
2082
+ await runLockedInstall();
2068
2083
  } else {
2069
2084
  // Name the unknown token before the usage block. Pre-fix `install frobnicate`
2070
2085
  // dumped usage silently, which read like the user had typed nothing — they had
@@ -0,0 +1,38 @@
1
+ // lib/atomic-write.mjs — crash-safe file writes with optional one-time backup.
2
+ //
3
+ // Why: several write paths mutate user-global config that, if torn or clobbered,
4
+ // breaks the user outside the plugin's control — most acutely ~/.claude.json
5
+ // (the WHOLE Claude Code config) in hook-update's post-update MCP dedup, and
6
+ // ~/.claude/settings.json in install. A plain writeFileSync can leave a
7
+ // half-written file on crash, and a fixed ".tmp" name races concurrent writers.
8
+ // This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
9
+ // a one-time ".bak" so a logic bug in the caller's merge is recoverable.
10
+
11
+ import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+
14
+ /**
15
+ * Atomically write `data` to `filePath` (temp + rename). Optionally back up the
16
+ * existing file once to `<filePath>.bak` before the first overwrite.
17
+ * @param {string} filePath
18
+ * @param {string} data
19
+ * @param {object} [opts]
20
+ * @param {boolean} [opts.backup=false] Create <filePath>.bak if absent and the
21
+ * target exists, before writing. Only the first call creates it, so the backup
22
+ * preserves the last-known-good rather than being overwritten each run.
23
+ */
24
+ export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
25
+ const dir = dirname(filePath);
26
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
+
28
+ if (backup && existsSync(filePath) && !existsSync(filePath + '.bak')) {
29
+ try { copyFileSync(filePath, filePath + '.bak'); } catch { /* best-effort backup */ }
30
+ }
31
+
32
+ // pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
33
+ // each other's temp mid-write. Same-dir temp keeps the rename atomic (no
34
+ // cross-device move).
35
+ const tmp = `${filePath}.tmp-${process.pid}`;
36
+ writeFileSync(tmp, data);
37
+ renameSync(tmp, filePath);
38
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { appendFileSync, mkdirSync, existsSync } from 'fs';
19
19
  import { join } from 'path';
20
+ import { scrubSecrets } from '../secret-scrub.mjs';
20
21
 
21
22
  const DAY_MS = 86400000;
22
23
 
@@ -47,11 +48,14 @@ export function maybeSampleError(e, ctx, dbDir) {
47
48
  const errDir = join(dbDir, 'errors');
48
49
  if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true, mode: 0o700 });
49
50
 
51
+ // Scrub BEFORE truncating: a connection string / Authorization header / 401
52
+ // body can ride along in an error message or stack frame. Scrub the full
53
+ // string first so a secret straddling the slice boundary is still caught.
50
54
  const line = JSON.stringify({
51
55
  ts: new Date().toISOString(),
52
- ctx: String(ctx || '').slice(0, 120),
53
- msg: String(e?.message ?? e ?? '').slice(0, 500),
54
- stack: typeof e?.stack === 'string' ? e.stack.split('\n').slice(0, 6).join('\n') : undefined,
56
+ ctx: scrubSecrets(String(ctx || '')).slice(0, 120),
57
+ msg: scrubSecrets(String(e?.message ?? e ?? '')).slice(0, 500),
58
+ stack: typeof e?.stack === 'string' ? scrubSecrets(e.stack.split('\n').slice(0, 6).join('\n')) : undefined,
55
59
  }) + '\n';
56
60
 
57
61
  appendFileSync(join(errDir, `${today()}.jsonl`), line, { mode: 0o600 });
@@ -0,0 +1,32 @@
1
+ // lib/lesson-idents.mjs — pure, zero-dependency extractor of code identifiers a
2
+ // lesson names, for the bind-salience PostToolUse "dropped a required reference"
3
+ // check (scripts/post-tool-recall.js). Imported by hot standalone hooks → NO
4
+ // heavy imports (lesson #8447): regex over a string only.
5
+ //
6
+ // Identifier shapes: backtick-quoted, camelCase, snake_case, length >= MIN_LEN.
7
+ // These name functions/columns a lesson tells you to keep (recoverChildrenOf,
8
+ // compressed_into). Plain prose ("recover", "delete") is intentionally excluded.
9
+
10
+ const MIN_LEN = 5;
11
+ const BACKTICK = /`([A-Za-z_][A-Za-z0-9_]*)`/g;
12
+ const CAMEL = /\b([a-z][a-z0-9]*[A-Z][A-Za-z0-9]*)\b/g;
13
+ const SNAKE = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/g;
14
+
15
+ export function extractIdents(text) {
16
+ const s = text || '';
17
+ if (!s) return [];
18
+ const out = new Set();
19
+ for (const re of [BACKTICK, CAMEL, SNAKE]) {
20
+ for (const m of s.matchAll(re)) if (m[1].length >= MIN_LEN) out.add(m[1]);
21
+ }
22
+ return [...out];
23
+ }
24
+
25
+ // Of the identifiers a lesson names, keep only those literally present in the
26
+ // pre-edit file — so the PostToolUse check flags "you removed X" and never
27
+ // "you didn't add X that was never here" (the false positive). '' content → [].
28
+ export function presentIdents(lessonText, content) {
29
+ const c = content || '';
30
+ if (!c) return [];
31
+ return extractIdents(lessonText).filter((id) => c.includes(id));
32
+ }
@@ -0,0 +1,112 @@
1
+ // lib/proc-lock.mjs — best-effort inter-process advisory lock (O_EXCL file).
2
+ //
3
+ // Why: multiple Claude Code sessions can fire SessionStart hooks (and their
4
+ // self-heal / auto-update write paths) at the same instant. install(),
5
+ // install.mjs repair, and hook-update.installExtractedRelease all rename source
6
+ // files into the live install dir; two of them interleaving produces a torn /
7
+ // mixed-version install (server vN + hook vN+1). The launcher's 6h cooldown
8
+ // only RATE-LIMITS re-spawns — it is not mutual exclusion (two processes can
9
+ // both observe "no recent attempt" and both spawn). This gives the write paths
10
+ // a real cross-process gate.
11
+ //
12
+ // Semantics: acquireLock() atomically creates the lock file with O_EXCL. If it
13
+ // already exists it is stolen only when STALE (holder's timestamp older than
14
+ // staleMs, or the recorded pid is provably dead on this host). A live holder →
15
+ // acquire returns null and the caller no-ops (someone else is already doing the
16
+ // write). Release unlinks the file. Crash-safe: a crashed holder's lock ages
17
+ // out via staleMs so the next session reclaims it.
18
+
19
+ import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+
22
+ // 5 min: comfortably longer than any install/update write phase (npm install in
23
+ // staging is timeout-capped at 60s) but short enough that a crashed holder does
24
+ // not block self-heal for long.
25
+ const DEFAULT_STALE_MS = 5 * 60 * 1000;
26
+
27
+ function pidAlive(pid) {
28
+ if (typeof pid !== 'number' || pid <= 0) return false;
29
+ try {
30
+ // Signal 0 = existence check, no signal delivered. EPERM means the process
31
+ // exists but is owned by another user (still "alive" for our purposes).
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch (e) {
35
+ return e.code === 'EPERM';
36
+ }
37
+ }
38
+
39
+ function isStale(lockPath, staleMs, now) {
40
+ try {
41
+ const { pid, ts } = JSON.parse(readFileSync(lockPath, 'utf8'));
42
+ if (typeof ts === 'number' && now() - ts > staleMs) return true;
43
+ // Same-host fast reclaim: holder pid is gone. Cross-host (shared homedir)
44
+ // the pid is meaningless, but ts-staleness above still reclaims it.
45
+ if (typeof pid === 'number' && !pidAlive(pid)) return true;
46
+ return false;
47
+ } catch {
48
+ return true; // unparseable / unreadable lock → treat as stale and reclaim
49
+ }
50
+ }
51
+
52
+ function makeRelease(lockPath) {
53
+ let released = false;
54
+ return function release() {
55
+ if (released) return;
56
+ released = true;
57
+ try { unlinkSync(lockPath); } catch { /* already gone — fine */ }
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Try to acquire an advisory lock. Non-blocking.
63
+ * @param {string} lockPath Absolute path to the lock file.
64
+ * @param {object} [opts]
65
+ * @param {number} [opts.staleMs] Age after which a held lock is stolen.
66
+ * @param {() => number} [opts.now] Clock injection seam (tests).
67
+ * @returns {(() => void)|null} A release() fn, or null if a live peer holds it.
68
+ */
69
+ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
70
+ try { mkdirSync(dirname(lockPath), { recursive: true }); } catch { /* best-effort */ }
71
+ const payload = JSON.stringify({ pid: process.pid, ts: now() });
72
+ try {
73
+ writeFileSync(lockPath, payload, { flag: 'wx' }); // O_EXCL — atomic create
74
+ return makeRelease(lockPath);
75
+ } catch (e) {
76
+ if (e.code !== 'EEXIST') return null; // permission / fs error → fail closed
77
+ if (!isStale(lockPath, staleMs, now)) return null; // live peer holds it
78
+ // Stale: steal it. unlink + re-create exclusively; lose the race → null.
79
+ try { unlinkSync(lockPath); } catch { /* raced */ }
80
+ try {
81
+ writeFileSync(lockPath, payload, { flag: 'wx' });
82
+ return makeRelease(lockPath);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Run `fn` while holding the lock; release in a finally. No-op if not acquired.
91
+ * @returns {{acquired: boolean, result?: any}}
92
+ */
93
+ export function withLock(lockPath, fn, opts) {
94
+ const release = acquireLock(lockPath, opts);
95
+ if (!release) return { acquired: false };
96
+ try {
97
+ return { acquired: true, result: fn() };
98
+ } finally {
99
+ release();
100
+ }
101
+ }
102
+
103
+ /** Async variant of withLock — awaits `fn`. */
104
+ export async function withLockAsync(lockPath, fn, opts) {
105
+ const release = acquireLock(lockPath, opts);
106
+ if (!release) return { acquired: false };
107
+ try {
108
+ return { acquired: true, result: await fn() };
109
+ } finally {
110
+ release();
111
+ }
112
+ }
package/mem-cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
10
  import { _resetVocabCache } from './tfidf.mjs';
11
11
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
12
12
  import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
13
- import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady, hasEscalatableCorpus } from './deep-search.mjs';
13
+ import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
16
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -227,7 +227,7 @@ async function cmdSearch(db, args, { llm } = {}) {
227
227
  } else {
228
228
  obsResults = searchObservationsHybrid(db, obsCtx);
229
229
  if (obsCtx.orFallbackFired) orFallbackFired = true;
230
- if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx) && hasEscalatableCorpus(db, project || null)) {
230
+ if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx, { db, project: project || null })) {
231
231
  process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${obsResults.length} hits)\n`);
232
232
  obsResults = await runDeep({ auto: true });
233
233
  isDeep = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.6.0",
3
+ "version": "3.7.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
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -69,7 +69,10 @@
69
69
  "lib/file-intel.mjs",
70
70
  "lib/reread-guard.mjs",
71
71
  "lib/metrics.mjs",
72
+ "lib/lesson-idents.mjs",
72
73
  "lib/binding-probe.mjs",
74
+ "lib/proc-lock.mjs",
75
+ "lib/atomic-write.mjs",
73
76
  "lib/mem-override.mjs",
74
77
  "lib/save-observation.mjs",
75
78
  "lib/observation-write.mjs",
@@ -131,6 +134,7 @@
131
134
  "scripts/post-tool-use.sh",
132
135
  "scripts/user-prompt-search.js",
133
136
  "scripts/pre-tool-recall.js",
137
+ "scripts/post-tool-recall.js",
134
138
  "scripts/pre-skill-bridge.js",
135
139
  "scripts/prompt-search-utils.mjs",
136
140
  "scripts/hook-launcher.mjs",
package/schema.mjs CHANGED
@@ -6,7 +6,7 @@ import Database from 'better-sqlite3';
6
6
  import { homedir } from 'os';
7
7
  import { join } from 'path';
8
8
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
9
- import { OBS_FTS_COLUMNS } from './utils.mjs';
9
+ import { OBS_FTS_COLUMNS, debugCatch } from './utils.mjs';
10
10
 
11
11
  // DATA location — DB, managed resources, registry DB, runtime/. Honors
12
12
  // CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
@@ -79,7 +79,16 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
79
79
  // New TABLE (not a column) reached via CORE_SCHEMA's CREATE TABLE IF NOT EXISTS on the
80
80
  // forced migration pass; LATEST_MIGRATION_COLUMN unchanged (no new column) — same
81
81
  // pattern as v35/v36.
82
- export const CURRENT_SCHEMA_VERSION = 38;
82
+ // v39 (audit P1-5): migration_cleanups table — a sentinel registry that makes the
83
+ // one-shot DATA cleanups (orphan deletes, project-name normalization) RETRYABLE.
84
+ // They previously ran inside the version-gated migration body and were swallowed
85
+ // on failure AFTER the version stamp committed, so a failed cleanup could never
86
+ // re-run (the fast-path then skipped the whole body). They now run via
87
+ // runDeferredCleanups() on every ensureDb, each gated by a done-marker row: a
88
+ // failure leaves the marker unset and retries on the next open. New TABLE via
89
+ // CORE_SCHEMA on the forced pass; LATEST_MIGRATION_COLUMN unchanged (no new
90
+ // column) — same pattern as v35/v36/v38.
91
+ export const CURRENT_SCHEMA_VERSION = 39;
83
92
 
84
93
  // Sentinel column for the LATEST migration set. The fast-path uses this to
85
94
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
@@ -185,6 +194,11 @@ const CORE_SCHEMA = `
185
194
  cited_n INTEGER NOT NULL DEFAULT 0,
186
195
  PRIMARY KEY (project, memory_session_id)
187
196
  );
197
+
198
+ CREATE TABLE IF NOT EXISTS migration_cleanups (
199
+ name TEXT PRIMARY KEY,
200
+ done_at_epoch INTEGER NOT NULL
201
+ );
188
202
  `;
189
203
 
190
204
  // Column migrations (idempotent — only swallow "duplicate column" errors)
@@ -556,18 +570,8 @@ export function initSchema(db) {
556
570
  }
557
571
  } catch { /* non-critical — migration can retry on next open */ }
558
572
 
559
- // v35 (v2.87.0) P1: one-shot cleanup of orphaned observation_files. Same root
560
- // cause as the v28 observation_vectors cleanup below until the warm-start
561
- // fast-path FK fix (initSchema early returns now restore foreign_keys=ON),
562
- // ensureDb() handles ran with FK OFF, so ON DELETE CASCADE never fired and
563
- // junction rows leaked (live DB: 6440/9569 = 67% orphans). Idempotent (NOT IN
564
- // is empty on a clean DB); runs once per version bump via the fast-path gate.
565
- try {
566
- db.prepare(`
567
- DELETE FROM observation_files
568
- WHERE obs_id NOT IN (SELECT id FROM observations)
569
- `).run();
570
- } catch { /* non-critical — table-missing path handled by earlier CREATE */ }
573
+ // observation_files orphan cleanup moved to runDeferredCleanups() (audit P1-5):
574
+ // it now runs retryably outside the version fast-path. See DEFERRED_CLEANUPS.
571
575
 
572
576
  // Observation vectors table for TF-IDF vector search
573
577
  db.exec(`
@@ -582,16 +586,7 @@ export function initSchema(db) {
582
586
 
583
587
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_vectors_version ON observation_vectors(vocab_version)`);
584
588
 
585
- // v28 (v2.47) P0-1: one-shot cleanup of orphaned observation_vectors.
586
- // Live DBs accumulated 44% orphans even with ON DELETE CASCADE because
587
- // early migrations ran with `foreign_keys=OFF` and deletes skipped cascade.
588
- // Idempotent (NOT IN is empty on a clean DB), runs once per ensureDb().
589
- try {
590
- db.prepare(`
591
- DELETE FROM observation_vectors
592
- WHERE observation_id NOT IN (SELECT id FROM observations)
593
- `).run();
594
- } catch { /* non-critical — table-missing path handled by earlier CREATE */ }
589
+ // observation_vectors orphan cleanup moved to runDeferredCleanups() (audit P1-5).
595
590
 
596
591
  // Persisted vocabulary for stable TF-IDF vector indexing
597
592
  db.exec(`
@@ -605,46 +600,9 @@ export function initSchema(db) {
605
600
  `);
606
601
  db.exec('CREATE INDEX IF NOT EXISTS idx_vocab_state_version ON vocab_state(version)');
607
602
 
608
- // Project name normalization: migrate short names ("mem") to canonical form ("projects--mem")
609
- // Strategy: exact suffix match first, then substring match for package-name aliases
610
- // Idempotent: only runs when short-name records exist
611
- try {
612
- const shortProjects = db.prepare(`
613
- SELECT DISTINCT project FROM observations
614
- WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
615
- UNION
616
- SELECT DISTINCT project FROM sdk_sessions
617
- WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
618
- `).all();
619
- if (shortProjects.length > 0) {
620
- const normalize = db.transaction(() => {
621
- for (const { project: shortName } of shortProjects) {
622
- // Strategy 1: exact suffix match (e.g., "mem" → "projects--mem")
623
- let canonical = db.prepare(
624
- `SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
625
- ).get(`%--${shortName}`);
626
- // Strategy 2: substring match for aliases (e.g., "claude-mem-lite" → match project containing "mem")
627
- // Extract the most distinctive token from the short name for fuzzy matching
628
- if (!canonical) {
629
- const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
630
- for (const token of tokens) {
631
- canonical = db.prepare(
632
- `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
633
- GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
634
- ).get(`%${token}%`);
635
- if (canonical) break;
636
- }
637
- }
638
- if (canonical) {
639
- for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
640
- db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
641
- }
642
- }
643
- }
644
- });
645
- normalize();
646
- }
647
- } catch { /* non-critical — normalization can retry on next open */ }
603
+ // Project-name normalization moved to runDeferredCleanups() (audit P1-5) it
604
+ // now retries on a later open if it fails, instead of being lost behind the
605
+ // version fast-path. See DEFERRED_CLEANUPS.
648
606
 
649
607
  // ─── v29 (v2.57.x): session-id mix invariant + lesson-retry stats ─────────
650
608
  //
@@ -804,6 +762,96 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
804
762
  };
805
763
  }
806
764
 
765
+ // ─── Deferred one-shot cleanups (retryable) ─────────────────────────────────
766
+ // Idempotent DATA cleanups that must survive a transient failure. They run on
767
+ // EVERY ensureDb (after initSchema), each gated by a row in migration_cleanups:
768
+ // a cleanup that throws leaves its marker unset and retries on the next open —
769
+ // unlike the old version-gated body, where a swallowed failure committed
770
+ // alongside the version stamp and the fast-path then skipped it forever (P1-5).
771
+ const DEFERRED_CLEANUPS = [
772
+ {
773
+ // v35 (v2.87.0): orphaned observation_files. ON DELETE CASCADE didn't fire
774
+ // while early warm-start handles ran with foreign_keys OFF, so junction rows
775
+ // leaked. Idempotent (NOT IN is empty on a clean DB).
776
+ name: 'orphan-observation-files',
777
+ run: (db) => db.prepare(
778
+ `DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`
779
+ ).run(),
780
+ },
781
+ {
782
+ // v28 (v2.47) P0-1: orphaned observation_vectors — same FK-OFF root cause.
783
+ name: 'orphan-observation-vectors',
784
+ run: (db) => db.prepare(
785
+ `DELETE FROM observation_vectors WHERE observation_id NOT IN (SELECT id FROM observations)`
786
+ ).run(),
787
+ },
788
+ {
789
+ // Project-name normalization: migrate short names ("mem") to canonical
790
+ // ("projects--mem"). Exact suffix match first, then distinctive-token
791
+ // substring. Idempotent: only acts on remaining short-name records.
792
+ name: 'normalize-project-names',
793
+ run: (db) => {
794
+ const shortProjects = db.prepare(`
795
+ SELECT DISTINCT project FROM observations
796
+ WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
797
+ UNION
798
+ SELECT DISTINCT project FROM sdk_sessions
799
+ WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
800
+ `).all();
801
+ if (shortProjects.length === 0) return;
802
+ const normalize = db.transaction(() => {
803
+ for (const { project: shortName } of shortProjects) {
804
+ let canonical = db.prepare(
805
+ `SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
806
+ ).get(`%--${shortName}`);
807
+ if (!canonical) {
808
+ const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
809
+ for (const token of tokens) {
810
+ canonical = db.prepare(
811
+ `SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
812
+ GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
813
+ ).get(`%${token}%`);
814
+ if (canonical) break;
815
+ }
816
+ }
817
+ if (canonical) {
818
+ for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
819
+ db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
820
+ }
821
+ }
822
+ }
823
+ });
824
+ normalize();
825
+ },
826
+ },
827
+ ];
828
+
829
+ /**
830
+ * Run registered one-shot data cleanups that haven't completed yet. Each is
831
+ * gated by a row in migration_cleanups, so a transient failure retries on the
832
+ * next open instead of being silently lost behind the schema-version fast-path
833
+ * (audit P1-5). Best-effort: never throws — callers open the DB regardless.
834
+ */
835
+ export function runDeferredCleanups(db) {
836
+ let done;
837
+ try {
838
+ done = new Set(db.prepare('SELECT name FROM migration_cleanups').all().map(r => r.name));
839
+ } catch {
840
+ return; // table not present yet (pre-migration open) — nothing to do
841
+ }
842
+ const mark = db.prepare('INSERT OR IGNORE INTO migration_cleanups (name, done_at_epoch) VALUES (?, ?)');
843
+ for (const { name, run } of DEFERRED_CLEANUPS) {
844
+ if (done.has(name)) continue;
845
+ try {
846
+ run(db);
847
+ mark.run(name, Date.now());
848
+ } catch (e) {
849
+ // Leave the marker unset → retried next open. Surface for observability.
850
+ debugCatch(e, `deferred-cleanup:${name}`);
851
+ }
852
+ }
853
+ }
854
+
807
855
  /**
808
856
  * Ensure DB directory, database file, and all tables exist.
809
857
  * Safe to call from any process (hook or server). Idempotent.
package/scoring-sql.mjs CHANGED
@@ -3,6 +3,31 @@
3
3
 
4
4
  import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
5
5
 
6
+ // ─── Why these multipliers exist (read before "simplifying" them) ────────────
7
+ //
8
+ // The recency-decay, type-quality, project-boost, importance, cite, and noise
9
+ // multipliers below encode PRODUCT PRIORS: recent / same-project / important /
10
+ // high-signal-type / frequently-cited memories are more relevant to the CURRENT
11
+ // dev session. A periodic audit tends to flag them as "0-lift dead weight" —
12
+ // resist that on benchmark evidence alone. Measured (audit ②, obs #8773):
13
+ // * benchmark.mjs --matrix (micro-fixture, now models the full FULL_SCORE
14
+ // chain): type-quality is the TOP contributor (drop-type ΔnDCG=0.0082,
15
+ // ΔMRR=0.0166), decay +0.0043 nDCG, importance +0.0012; the chain lifts
16
+ // hybrid over bm25_only by +0.0093 nDCG / +0.0166 MRR (net 0 queries hurt).
17
+ // project, access and lesson read exactly 0 — but that is STRUCTURAL: the
18
+ // fixture is single-project, access_count=0, and has 0 lesson_learned rows,
19
+ // so it cannot vary those three axes.
20
+ // * longmemeval.mjs --temporal (n=500, real dates): bit-identical to uniform —
21
+ // LongMemEval-S windows (mean 27.9d, 74% <30d) are far shorter than these
22
+ // half-lives, so decay moves no rank there either.
23
+ // Where a multiplier reads 0 it is a benchmark-MISMATCH artifact (the instrument
24
+ // can't vary that axis), NOT proven dead weight. Decision: KEEP them; do NOT
25
+ // delete on "0 lift". Guardrail: the ci-gate `hybrid_over_bm25 >= -0.05` floor
26
+ // (benchmark/ci-gate.mjs) covers the full modelled chain
27
+ // (decay/type/project/importance/access/lesson); cite + noise live on the
28
+ // injection path (hook-memory.mjs) with no recall-benchmark coverage. Genuine
29
+ // validation of the prior-encoding axes needs a labeled real-dev-memory eval.
30
+
6
31
  // ─── Type-Differentiated Recency Decay ──────────────────────────────────────
7
32
 
8
33
  /** Recency half-life per observation type (in milliseconds) */
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // scripts/post-tool-recall.js — PostToolUse companion to pre-tool-recall.js for
3
+ // the bind-salience forcing-function (component 2). After an Edit/Write, if a
4
+ // lesson surfaced for this file named an identifier that was present BEFORE the
5
+ // edit (recorded in the cooldown by pre-tool-recall.js) and is now GONE, emit a
6
+ // one-line non-blocking nudge. Only active under CLAUDE_MEM_SALIENCE=bind.
7
+ //
8
+ // Catches "you removed a required reference" lessons. It does NOT catch "you
9
+ // failed to ADD a call" (the identifier was never in the pre-edit file →
10
+ // presentIdents excluded it); that class is carried by the pre-edit
11
+ // BIND_DIRECTIVE, not here. See the spec's component-2 limits.
12
+ //
13
+ // Safety: readonly, no DB, exit 0 always. cooldownPathFor mirrors
14
+ // pre-tool-recall.js (inlined per the #8447 fast-path convention).
15
+
16
+ import { existsSync, readFileSync } from 'fs';
17
+ import { basename, join } from 'path';
18
+ import { homedir } from 'os';
19
+
20
+ const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
21
+
22
+ const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
23
+ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
24
+ const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
25
+
26
+ function cooldownPathFor(sessionId) {
27
+ if (!sessionId) return LEGACY_COOLDOWN_PATH;
28
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
29
+ return join(RUNTIME_DIR, `pre-recall-cooldown-${safe}.json`);
30
+ }
31
+
32
+ async function main() {
33
+ if (!SALIENCE_BIND) return;
34
+ if (process.env.CLAUDE_MEM_HOOK_RUNNING) return;
35
+ let input = '';
36
+ for await (const chunk of process.stdin) input += chunk;
37
+ let filePath, sessionId;
38
+ try {
39
+ const e = JSON.parse(input);
40
+ filePath = e.tool_input?.file_path;
41
+ sessionId = e.session_id || null;
42
+ } catch { return; }
43
+ if (!filePath) return;
44
+
45
+ const cdPath = cooldownPathFor(sessionId);
46
+ if (!existsSync(cdPath)) return;
47
+ let entry;
48
+ try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch { return; }
49
+ const idents = entry && entry.lessonIdents;
50
+ if (!idents || typeof idents !== 'object') return;
51
+
52
+ let post;
53
+ try { post = readFileSync(filePath, 'utf8'); } catch { return; }
54
+
55
+ const dropped = [];
56
+ for (const [obsId, tokens] of Object.entries(idents)) {
57
+ for (const t of tokens) if (!post.includes(t)) dropped.push({ obsId, token: t });
58
+ }
59
+ if (!dropped.length) return;
60
+
61
+ const lines = ['[mem] PostToolUse recall — system-injected context, continue your planned action:'];
62
+ for (const d of dropped.slice(0, 3)) {
63
+ lines.push(`[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`);
64
+ }
65
+ process.stdout.write(JSON.stringify({
66
+ suppressOutput: true,
67
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') },
68
+ }));
69
+ }
70
+
71
+ main().catch(() => {}).finally(() => process.exit(0));
@@ -13,6 +13,7 @@ import { citeFactorClause } from '../scoring-sql.mjs';
13
13
  import { fileIntelFor } from '../lib/file-intel.mjs';
14
14
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
15
15
  import { recordMetric } from '../lib/metrics.mjs';
16
+ import { presentIdents } from '../lib/lesson-idents.mjs';
16
17
 
17
18
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
18
19
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -40,7 +41,14 @@ const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
40
41
  // restores the pre-v2.98 passive behavior.
41
42
  const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
42
43
  || process.env.CLAUDE_MEM_SALIENCE === '0';
44
+ const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
43
45
  const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
46
+ // v-bind salience forcing-function (#8771 audit: ack ≠ act). Instead of a cheap
47
+ // '#NN applied / n/a' verdict, demand the model bind the lesson to the concrete
48
+ // line it's editing and quote the satisfying edit line. Selected by
49
+ // CLAUDE_MEM_SALIENCE=bind; default stays ACK_DIRECTIVE.
50
+ const BIND_DIRECTIVE = "For each lesson: state the one concrete check it forces on the line(s) you're editing, quote the edit line that satisfies it, then report '#NN: <check> — pass' or '#NN: n/a — <why this edit can't reach it>'.";
51
+ const ACTIVE_DIRECTIVE = SALIENCE_BIND ? BIND_DIRECTIVE : ACK_DIRECTIVE;
44
52
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
45
53
  // Feature ① (file intelligence): on the first Read of a file each session, inject
46
54
  // its approximate token size + a one-line summary so the agent can decide to read
@@ -238,7 +246,7 @@ try {
238
246
  hookEventName: 'PreToolUse',
239
247
  additionalContext: [
240
248
  '[mem] PreToolUse recall — system-injected context, continue your planned action:',
241
- `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACK_DIRECTIVE}`,
249
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
242
250
  ].join('\n'),
243
251
  },
244
252
  }));
@@ -434,7 +442,7 @@ try {
434
442
  // Read keeps the quiet form; its forcing-function fires at the later Edit
435
443
  // via the Read→Edit ack nudge above.
436
444
  if (!isRead && !SALIENCE_LEGACY) {
437
- lines.push(`[mem] ⚠ Before this edit: ${ACK_DIRECTIVE}`);
445
+ lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
438
446
  }
439
447
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
440
448
  // R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
@@ -474,10 +482,27 @@ try {
474
482
  // full re-read of the unchanged file can be flagged. Read-only, session-scoped;
475
483
  // one stat + bounded read, first-read only.
476
484
  const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
485
+ // bind salience (component 2): record the identifiers each lesson NAMES that
486
+ // ALSO appear in the current (pre-edit) file, so post-tool-recall.js can flag
487
+ // an edit that drops one. Only under =bind with lessons — keeps the default
488
+ // path free of the extra file read. Bounded read; never throws.
489
+ let lessonIdents;
490
+ if (SALIENCE_BIND && allRows.length > 0) {
491
+ try {
492
+ const pre = readFileSync(filePath, 'utf8').slice(0, 256 * 1024);
493
+ const acc = {};
494
+ for (const r of allRows) {
495
+ const present = presentIdents(`${r.lesson_learned || ''} ${r.title || ''}`, pre);
496
+ if (present.length) acc[r.id] = present;
497
+ }
498
+ if (Object.keys(acc).length) lessonIdents = acc;
499
+ } catch { /* unreadable pre-edit file — skip the diff check */ }
500
+ }
477
501
  cooldown[filePath] = {
478
502
  ts: now,
479
503
  lessonIds: allRows.map(r => r.id),
480
504
  mode: isRead ? 'read' : 'edit',
505
+ ...(lessonIdents ? { lessonIdents } : {}),
481
506
  ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
482
507
  };
483
508
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
package/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
11
11
  import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
12
12
  import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
13
- import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady, hasEscalatableCorpus } from './deep-search.mjs';
13
+ import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
15
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
16
16
  import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
@@ -61,8 +61,19 @@ let db;
61
61
  try {
62
62
  db = ensureDb();
63
63
  } catch (firstErr) {
64
+ // WAL-delete recovery is ONLY safe for genuine corruption. On a transient
65
+ // error (SQLITE_BUSY) or the forward-version guard throw, deleting the WAL
66
+ // would discard committed-but-uncheckpointed transactions — silent data loss.
67
+ // Restrict the rm to corruption signatures; otherwise fail fast, WAL intact.
68
+ const sig = `${firstErr.code || ''} ${firstErr.message || ''}`;
69
+ const isCorruption = /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(sig);
70
+ if (!isCorruption) {
71
+ console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${firstErr.message}`);
72
+ console.error(`[claude-mem-lite] Left WAL/SHM intact (not a corruption error). If this persists, retry or reinstall: node install.mjs install`);
73
+ process.exit(1);
74
+ }
64
75
  // Recovery: remove WAL/SHM files (corrupt WAL is the most common cause) and retry
65
- debugLog('WARN', 'server', `DB open failed, attempting WAL recovery: ${firstErr.message}`);
76
+ debugLog('WARN', 'server', `DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
66
77
  try { rmSync(DB_PATH + '-wal', { force: true }); } catch {}
67
78
  try { rmSync(DB_PATH + '-shm', { force: true }); } catch {}
68
79
  try {
@@ -412,7 +423,7 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
412
423
  // results is already obs-only here (sessions/prompts pushed below), but the
413
424
  // filter makes the invariant explicit and robust to future reordering.
414
425
  const obsCountBeforeEscalation = results.length;
415
- if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx) && hasEscalatableCorpus(db, args.project || null)) {
426
+ if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx, { db, project: args.project || null })) {
416
427
  await runDeepInto({ auto: true });
417
428
  isDeep = true;
418
429
  escalated = true;
package/source-files.mjs CHANGED
@@ -59,11 +59,20 @@ export const SOURCE_FILES = [
59
59
  'lib/file-intel.mjs',
60
60
  'lib/reread-guard.mjs',
61
61
  'lib/metrics.mjs',
62
+ // v3.6.x: bind-salience producer — extracts identifiers a lesson names that
63
+ // are present in the pre-edit file (component 2). Imported ONLY by
64
+ // scripts/pre-tool-recall.js; kept here for the same reason as file-intel.mjs.
65
+ 'lib/lesson-idents.mjs',
62
66
  // v2.71.x: better-sqlite3 ABI probe + auto-rebuild. Shared by install.mjs
63
67
  // (post-`npm install` verify) and scripts/launch.mjs (pre-server-launch
64
68
  // self-heal after Node ABI changes). Missing from manifest → auto-update
65
69
  // ships a stale install that FATALs on first DB open after Node upgrade.
66
70
  'lib/binding-probe.mjs',
71
+ // audit P0/P1: inter-process install lock + atomic config writes — imported by
72
+ // install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
73
+ // + auto-update lock). Must ship or a partial install/update skips them.
74
+ 'lib/proc-lock.mjs',
75
+ 'lib/atomic-write.mjs',
67
76
  // v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
68
77
  'cli/common.mjs',
69
78
  'cli/fts-check.mjs',
@@ -149,6 +158,7 @@ export const HOOK_SCRIPT_FILES = [
149
158
  'user-prompt-search.js',
150
159
  'prompt-search-utils.mjs',
151
160
  'pre-tool-recall.js',
161
+ 'post-tool-recall.js',
152
162
  'pre-skill-bridge.js',
153
163
  // v2.84: self-heal wrapper that detects ERR_MODULE_NOT_FOUND under the
154
164
  // install dir and runs install.mjs repair before retrying the entry.