claude-mem-lite 2.71.3 → 2.72.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": "2.71.3",
13
+ "version": "2.72.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.71.3",
3
+ "version": "2.72.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/bash-utils.mjs CHANGED
@@ -15,9 +15,25 @@ export function detectBashSignificance(input, response) {
15
15
  // Skip error keyword matching when the command is a read/search operation
16
16
  // (grep output naturally contains matched keywords like "error")
17
17
  const isSearchCmd = /\b(grep|rg|ag|ack|cat|head|tail|less|more|find|locate|wc|file|which|type)\b/i.test(cmd);
18
- const isError = !isSearchCmd
18
+ const looksLikeError = !isSearchCmd
19
19
  && /\berror\b|\bERR!|fail(ed|ure)?|exception|panic|traceback|errno|enoent|command not found/i.test(response)
20
20
  && response.length > 15;
21
+ // Green test summary exemption — "0 fail/failed/failures" in test-runner
22
+ // output (bun/jest/pytest) gets matched by the broad `fail(ed|ure)?` token
23
+ // above, driving episode.isError=true for passing runs. A live cluster-merge
24
+ // audit found 5 noise observations with "Error: <test>.ts ... 0 fail" titles
25
+ // from this path. Flip back to non-error iff a "0 fail" marker is present
26
+ // AND no hard-error signal (panic / ENOENT / AssertionError / TypeError /
27
+ // explicit FAIL banner / npm ERR!) coexists in the output.
28
+ const hasGreenTestSummary = looksLikeError
29
+ && /\b0\s+(fail|failed|failures)\b/i.test(response);
30
+ // NOTE: do not add `\bFAIL\s` here — with /i flag it would re-match the
31
+ // very `0 fail\n` token green-summary is trying to exempt. A real test
32
+ // failure produces "N fail" (N≥1) which never triggers hasGreenTestSummary,
33
+ // so a uppercase-FAIL fingerprint isn't needed for correctness.
34
+ const hasHardErrorSignal = hasGreenTestSummary
35
+ && /\bERR!|panic|traceback|enoent|command not found|exception|AssertionError|TypeError:|SyntaxError:/i.test(response);
36
+ const isError = looksLikeError && !(hasGreenTestSummary && !hasHardErrorSignal);
21
37
  // Match actual test runner invocations, not commands that merely reference "test" as a keyword
22
38
  const isTest = /\b(npm\s+test|npm\s+run\s+test|yarn\s+test|pnpm\s+test|pnpm\s+run\s+test|bun\s+test|go\s+test|cargo\s+test)\b/i.test(cmd)
23
39
  || /\b(jest|pytest|vitest|mocha|cypress|playwright)\b/i.test(cmd);
package/hook-optimize.mjs CHANGED
@@ -74,12 +74,13 @@ export function rebuildVector(db, obsId, textParts) {
74
74
  *
75
75
  * @param {object} db better-sqlite3 database handle
76
76
  * @param {number} limit max candidates to return
77
- * @param {{ scope?: 'narrow' | 'wide' }} [opts]
77
+ * @param {{ scope?: 'narrow' | 'wide', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
78
78
  */
79
- export function findReenrichCandidates(db, limit = 10, { scope = 'narrow' } = {}) {
79
+ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', project } = {}) {
80
+ const projectClause = project ? 'AND project = ?' : '';
80
81
  if (scope === 'wide') {
81
- return db.prepare(`
82
- SELECT id, title, narrative, type, subtitle, concepts, facts
82
+ const stmt = db.prepare(`
83
+ SELECT id, title, narrative, type, subtitle, concepts, facts, project
83
84
  FROM observations
84
85
  WHERE COALESCE(compressed_into, 0) = 0
85
86
  AND superseded_at IS NULL
@@ -88,14 +89,16 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow' } = {}
88
89
  AND (lesson_learned IS NULL OR lesson_learned = '')
89
90
  AND LENGTH(COALESCE(narrative, '')) > 100
90
91
  AND ${notLowSignalTitleClause('')}
92
+ ${projectClause}
91
93
  ORDER BY
92
94
  CASE type WHEN 'decision' THEN 0 WHEN 'bugfix' THEN 1 WHEN 'refactor' THEN 2 ELSE 3 END,
93
95
  created_at_epoch DESC
94
96
  LIMIT ?
95
- `).all(limit);
97
+ `);
98
+ return project ? stmt.all(project, limit) : stmt.all(limit);
96
99
  }
97
- return db.prepare(`
98
- SELECT id, title, narrative, type, subtitle
100
+ const stmt = db.prepare(`
101
+ SELECT id, title, narrative, type, subtitle, project
99
102
  FROM observations
100
103
  WHERE COALESCE(compressed_into, 0) = 0
101
104
  AND (concepts IS NULL OR concepts = '')
@@ -103,13 +106,15 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow' } = {}
103
106
  AND lesson_learned IS NULL
104
107
  AND search_aliases IS NULL
105
108
  AND optimized_at IS NULL
109
+ ${projectClause}
106
110
  ORDER BY created_at_epoch DESC
107
111
  LIMIT ?
108
- `).all(limit);
112
+ `);
113
+ return project ? stmt.all(project, limit) : stmt.all(limit);
109
114
  }
110
115
 
111
- export async function executeReenrich(db, limit = 10, { scope = 'narrow' } = {}) {
112
- const candidates = findReenrichCandidates(db, limit, { scope });
116
+ export async function executeReenrich(db, limit = 10, { scope = 'narrow', project } = {}) {
117
+ const candidates = findReenrichCandidates(db, limit, { scope, project });
113
118
  if (candidates.length === 0) return { processed: 0, skipped: 0 };
114
119
 
115
120
  let processed = 0, skipped = 0;
@@ -206,14 +211,17 @@ export function shouldRunNormalize() {
206
211
  }
207
212
  }
208
213
 
209
- export function extractUniqueConcepts(db, limit = 500) {
210
- const rows = db.prepare(`
214
+ export function extractUniqueConcepts(db, limit = 500, { project } = {}) {
215
+ const projectClause = project ? 'AND project = ?' : '';
216
+ const stmt = db.prepare(`
211
217
  SELECT concepts FROM observations
212
218
  WHERE COALESCE(compressed_into, 0) = 0
213
219
  AND concepts IS NOT NULL AND concepts != ''
220
+ ${projectClause}
214
221
  ORDER BY created_at_epoch DESC
215
222
  LIMIT 2000
216
- `).all();
223
+ `);
224
+ const rows = project ? stmt.all(project) : stmt.all();
217
225
 
218
226
  const conceptSet = new Set();
219
227
  for (const row of rows) {
@@ -304,10 +312,10 @@ export function applyNormalization(db, groups) {
304
312
  return { updated };
305
313
  }
306
314
 
307
- export async function executeNormalize(db, force = false) {
315
+ export async function executeNormalize(db, force = false, { project } = {}) {
308
316
  if (!force && !shouldRunNormalize()) return { skipped: true, reason: 'gate' };
309
317
 
310
- const concepts = extractUniqueConcepts(db);
318
+ const concepts = extractUniqueConcepts(db, 500, { project });
311
319
  if (concepts.length < 5) return { skipped: true, reason: 'too few concepts' };
312
320
 
313
321
  const groups = await identifySynonymGroups(concepts);
@@ -326,18 +334,21 @@ const MERGE_TIME_WINDOW_MS = 30 * 86400000;
326
334
  const MERGE_JACCARD_LOW = 0.4;
327
335
  const MERGE_JACCARD_HIGH = 0.85;
328
336
 
329
- export function findMergeCandidates(db, maxClusters = 5) {
337
+ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
330
338
  const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
331
- const rows = db.prepare(`
332
- SELECT id, title, narrative, project, access_count, created_at_epoch, minhash_sig
339
+ const projectClause = project ? 'AND project = ?' : '';
340
+ const stmt = db.prepare(`
341
+ SELECT id, title, narrative, project, type, access_count, created_at_epoch, minhash_sig
333
342
  FROM observations
334
343
  WHERE COALESCE(compressed_into, 0) = 0
335
344
  AND optimized_at IS NULL
336
345
  AND title IS NOT NULL AND title != ''
337
346
  AND created_at_epoch > ?
347
+ ${projectClause}
338
348
  ORDER BY created_at_epoch DESC
339
349
  LIMIT 200
340
- `).all(cutoff);
350
+ `);
351
+ const rows = project ? stmt.all(cutoff, project) : stmt.all(cutoff);
341
352
 
342
353
  const used = new Set();
343
354
  const clusters = [];
@@ -450,8 +461,8 @@ Return ONLY valid JSON:
450
461
  }
451
462
  }
452
463
 
453
- export async function executeClusterMerge(db, maxClusters = 5) {
454
- const clusters = findMergeCandidates(db, maxClusters);
464
+ export async function executeClusterMerge(db, maxClusters = 5, { project } = {}) {
465
+ const clusters = findMergeCandidates(db, maxClusters, { project });
455
466
  if (clusters.length === 0) return { processed: 0, merged: 0 };
456
467
 
457
468
  let merged = 0;
@@ -468,17 +479,20 @@ export async function executeClusterMerge(db, maxClusters = 5) {
468
479
  const COMPRESS_TIME_SPLIT_MS = 14 * 86400000;
469
480
  const COMPRESS_COSINE_THRESHOLD = 0.3;
470
481
 
471
- export function findSmartCompressCandidates(db, ageDays = 30) {
482
+ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {}) {
472
483
  const cutoff = Date.now() - ageDays * 86400000;
473
- return db.prepare(`
484
+ const projectClause = project ? 'AND project = ?' : '';
485
+ const stmt = db.prepare(`
474
486
  SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
475
487
  FROM observations
476
488
  WHERE COALESCE(compressed_into, 0) = 0
477
489
  AND COALESCE(importance, 1) = 1
478
490
  AND COALESCE(access_count, 0) = 0
479
491
  AND created_at_epoch < ?
492
+ ${projectClause}
480
493
  ORDER BY project, created_at_epoch
481
- `).all(cutoff);
494
+ `);
495
+ return project ? stmt.all(cutoff, project) : stmt.all(cutoff);
482
496
  }
483
497
 
484
498
  export function clusterForCompression(candidates, db) {
@@ -642,8 +656,8 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
642
656
  }
643
657
  }
644
658
 
645
- export async function executeSmartCompress(db, maxClusters = 5) {
646
- const candidates = findSmartCompressCandidates(db);
659
+ export async function executeSmartCompress(db, maxClusters = 5, { project } = {}) {
660
+ const candidates = findSmartCompressCandidates(db, 30, { project });
647
661
  if (candidates.length < 3) return { processed: 0, compressed: 0 };
648
662
 
649
663
  const clusters = clusterForCompression(candidates, db);
@@ -661,23 +675,34 @@ export async function executeSmartCompress(db, maxClusters = 5) {
661
675
 
662
676
  // ─── Pipeline Orchestrator ──────────────────────────────────────────────────
663
677
 
664
- export function optimizePreview(db) {
665
- const reenrich = findReenrichCandidates(db, 1000).length;
678
+ /**
679
+ * @param {object} db better-sqlite3 database handle
680
+ * @param {{ project?: string, detail?: boolean }} [opts]
681
+ * project: scope all candidate finders to a single project (opt-in; default scans all).
682
+ * detail: when true, also return `mergeClusters` / `reenrichSamples` / `compressSamples`
683
+ * arrays alongside the aggregate counts so callers (CLI --verbose, MCP detail mode)
684
+ * can render auditable previews without re-running the finders. The candidate-count
685
+ * arms still call the finders with high limits — detail mode does NOT widen scope,
686
+ * it surfaces the same rows the counts already crossed.
687
+ */
688
+ export function optimizePreview(db, { project, detail = false } = {}) {
689
+ const reenrichCandidates = findReenrichCandidates(db, 1000, { project });
690
+ const reenrich = reenrichCandidates.length;
666
691
  // R-7: also report the widened-scope candidate count so users can see how many
667
692
  // bugfix/refactor/feature/decision observations are eligible for lesson backfill.
668
- const reenrichWide = findReenrichCandidates(db, 5000, { scope: 'wide' }).length;
693
+ const reenrichWide = findReenrichCandidates(db, 5000, { scope: 'wide', project }).length;
669
694
 
670
- const concepts = extractUniqueConcepts(db);
695
+ const concepts = extractUniqueConcepts(db, 500, { project });
671
696
  const normalizeReady = shouldRunNormalize() && concepts.length >= 5;
672
697
 
673
- const mergeClusters = findMergeCandidates(db, 50);
698
+ const mergeClusters = findMergeCandidates(db, 50, { project });
674
699
  const clusterMerge = mergeClusters.length;
675
700
 
676
- const compressCandidates = findSmartCompressCandidates(db);
701
+ const compressCandidates = findSmartCompressCandidates(db, 30, { project });
677
702
  const compressClusters = clusterForCompression(compressCandidates, db);
678
703
  const smartCompress = compressClusters.length;
679
704
 
680
- return {
705
+ const result = {
681
706
  reenrich,
682
707
  reenrichWide,
683
708
  normalize: normalizeReady ? concepts.length : 0,
@@ -686,6 +711,15 @@ export function optimizePreview(db) {
686
711
  smartCompress,
687
712
  total: reenrich + (normalizeReady ? 1 : 0) + clusterMerge + smartCompress,
688
713
  };
714
+ if (detail) {
715
+ // Caps avoid dumping arbitrarily large arrays into CLI/MCP output — 20 picks
716
+ // a sample size big enough to be auditable but small enough to fit a terminal
717
+ // page. Callers that need more can drop --verbose and run the finders directly.
718
+ result.mergeClusters = mergeClusters;
719
+ result.reenrichSamples = reenrichCandidates.slice(0, 20);
720
+ result.compressSamples = compressClusters.slice(0, 5);
721
+ }
722
+ return result;
689
723
  }
690
724
 
691
725
  /**
@@ -701,8 +735,10 @@ export function optimizePreview(db) {
701
735
  * @param {boolean} [opts.force=false] Bypass time-based gates (e.g. normalize interval).
702
736
  * @param {'narrow'|'wide'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
703
737
  * 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
738
+ * @param {string} [opts.project] Filter all tasks to a single project. Opt-in;
739
+ * absence preserves the prior all-projects default.
704
740
  */
705
- export async function optimizeRun(db, { tasks, maxItems = 15, force = false, reenrichScope = 'narrow' } = {}) {
741
+ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, reenrichScope = 'narrow', project } = {}) {
706
742
  const allTasks = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
707
743
  const selectedTasks = tasks && tasks.length > 0 ? tasks : allTasks;
708
744
  // Single-task mode: give that task the full budget. Distribution only makes sense
@@ -716,16 +752,16 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
716
752
  try {
717
753
  switch (task) {
718
754
  case 're-enrich':
719
- results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope });
755
+ results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
720
756
  break;
721
757
  case 'normalize':
722
- results.normalize = await executeNormalize(db, force);
758
+ results.normalize = await executeNormalize(db, force, { project });
723
759
  break;
724
760
  case 'cluster-merge':
725
- results.clusterMerge = await executeClusterMerge(db, budget.clusterMerge);
761
+ results.clusterMerge = await executeClusterMerge(db, budget.clusterMerge, { project });
726
762
  break;
727
763
  case 'smart-compress':
728
- results.smartCompress = await executeSmartCompress(db, budget.smartCompress);
764
+ results.smartCompress = await executeSmartCompress(db, budget.smartCompress, { project });
729
765
  break;
730
766
  }
731
767
  } catch (e) {
package/install.mjs CHANGED
@@ -25,11 +25,10 @@ const MARKETPLACE_KEY = 'sdsrss';
25
25
  const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
26
26
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
27
27
 
28
- import { createRequire } from 'module';
29
-
30
28
  import { RESOURCE_METADATA } from './install-metadata.mjs';
31
29
  import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
32
30
  import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
31
+ import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
33
32
 
34
33
  // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
35
34
  // and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
@@ -37,6 +36,12 @@ import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
37
36
  // can share it without a static cycle.
38
37
  export { HOOK_SCRIPT_FILES };
39
38
 
39
+ // Re-export for backward compatibility — tests/install-bsqlite-probe.test.mjs
40
+ // imports these from install.mjs. The implementation moved to lib/binding-probe.mjs
41
+ // so scripts/launch.mjs can share the probe without importing install.mjs (which
42
+ // pulls heavy install-only deps).
43
+ export { probeBetterSqlite3Binding, ensureBetterSqlite3Working };
44
+
40
45
  export function copyHookScripts(srcDir, destDir) {
41
46
  for (const name of HOOK_SCRIPT_FILES) {
42
47
  const src = join(srcDir, name);
@@ -75,55 +80,6 @@ export function migrateLegacyClaudeMemData(oldDir, newDir, opts = {}) {
75
80
  return { action: 'backed-up', backupPath };
76
81
  }
77
82
 
78
- /**
79
- * Probe better-sqlite3's native binding by importing it from `installDir`'s
80
- * node_modules and opening an in-memory DB. Returns {ok, error?}. `npm install`
81
- * exits 0 even when the prebuilt .node binary mismatches the running Node ABI
82
- * (e.g. NODE_MODULE_VERSION 137 on Node v24), so install must verify before
83
- * declaring success — otherwise the next launch FATALs with "Could not locate
84
- * the bindings file".
85
- */
86
- export async function probeBetterSqlite3Binding(installDir) {
87
- try {
88
- const localRequire = createRequire(join(installDir, 'package.json'));
89
- const Database = localRequire('better-sqlite3');
90
- const db = new Database(':memory:');
91
- db.close();
92
- return { ok: true };
93
- } catch (e) {
94
- return { ok: false, error: e.message };
95
- }
96
- }
97
-
98
- /**
99
- * Verify better-sqlite3 binding works in `installDir`; if not, run
100
- * `npm rebuild better-sqlite3` and re-probe. Returns
101
- * { ok: true, action: 'verified' | 'rebuilt' } on success or
102
- * { ok: false, error } if rebuild can't fix it. The `probe` and `rebuild`
103
- * deps are injectable so this can be unit-tested without a real npm
104
- * subprocess.
105
- */
106
- export async function ensureBetterSqlite3Working(installDir, deps = {}) {
107
- const probe = deps.probe || (() => probeBetterSqlite3Binding(installDir));
108
- const rebuild = deps.rebuild || (async () => {
109
- execSync('npm rebuild better-sqlite3', { cwd: installDir, stdio: 'pipe' });
110
- });
111
-
112
- const first = await probe();
113
- if (first.ok) return { ok: true, action: 'verified' };
114
-
115
- try {
116
- await rebuild();
117
- } catch (e) {
118
- return { ok: false, error: `rebuild failed: ${e.message}` };
119
- }
120
-
121
- const second = await probe();
122
- if (second.ok) return { ok: true, action: 'rebuilt' };
123
-
124
- return { ok: false, error: second.error || first.error };
125
- }
126
-
127
83
  /**
128
84
  * Derive invocation_name from resource name when metadata doesn't provide one.
129
85
  * Rules:
@@ -421,7 +377,12 @@ async function install() {
421
377
  } else {
422
378
  log('Ensuring dependencies installed...');
423
379
  try {
424
- execSync(NPM_INSTALL_CMD, { cwd: INSTALL_DIR, stdio: 'pipe' });
380
+ // stderr inherited so users see real-time progress (network slowness,
381
+ // node-gyp compile spinner, prebuild-install fallback messages). With
382
+ // `stdio: 'pipe'` the install appeared to hang under the 5-min Bash
383
+ // timeout when better-sqlite3 had no Node v24 prebuild and had to
384
+ // compile from source — see bug audit 2026-05.
385
+ execSync(NPM_INSTALL_CMD, { cwd: INSTALL_DIR, stdio: ['ignore', 'pipe', 'inherit'] });
425
386
  ok('Dependencies installed');
426
387
  } catch (e) {
427
388
  fail('npm install failed: ' + e.message);
@@ -0,0 +1,63 @@
1
+ // lib/binding-probe.mjs — better-sqlite3 native binding probe + auto-rebuild.
2
+ //
3
+ // Shared by install.mjs (verify after `npm install`) and scripts/launch.mjs
4
+ // (verify before launching the MCP server). `npm install` exits 0 even when
5
+ // the prebuilt .node binary mismatches the running Node ABI (e.g. ABI v137 on
6
+ // Node v24), and the presence of node_modules/better-sqlite3/ on disk is not
7
+ // sufficient — the binding can be present-but-stale after a Node upgrade.
8
+
9
+ import { execSync } from 'node:child_process';
10
+ import { createRequire } from 'node:module';
11
+ import { join } from 'node:path';
12
+
13
+ /**
14
+ * Probe better-sqlite3's native binding by importing it from `installDir`'s
15
+ * node_modules and opening an in-memory DB. Returns {ok, error?}.
16
+ *
17
+ * @param {string} installDir Directory containing node_modules/better-sqlite3
18
+ * @returns {Promise<{ok: true} | {ok: false, error: string}>}
19
+ */
20
+ export async function probeBetterSqlite3Binding(installDir) {
21
+ try {
22
+ const localRequire = createRequire(join(installDir, 'package.json'));
23
+ const Database = localRequire('better-sqlite3');
24
+ const db = new Database(':memory:');
25
+ db.close();
26
+ return { ok: true };
27
+ } catch (e) {
28
+ return { ok: false, error: e.message };
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Verify better-sqlite3 binding works in `installDir`; if not, run
34
+ * `npm rebuild better-sqlite3` and re-probe. Returns
35
+ * { ok: true, action: 'verified' | 'rebuilt' } on success or
36
+ * { ok: false, error } if rebuild can't fix it. The `probe` and `rebuild`
37
+ * deps are injectable so this can be unit-tested without a real npm
38
+ * subprocess.
39
+ *
40
+ * @param {string} installDir Directory containing node_modules/better-sqlite3
41
+ * @param {{probe?: () => Promise<{ok: boolean, error?: string}>, rebuild?: () => Promise<void>}} [deps]
42
+ * @returns {Promise<{ok: true, action: 'verified' | 'rebuilt'} | {ok: false, error: string}>}
43
+ */
44
+ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
45
+ const probe = deps.probe || (() => probeBetterSqlite3Binding(installDir));
46
+ const rebuild = deps.rebuild || (async () => {
47
+ execSync('npm rebuild better-sqlite3', { cwd: installDir, stdio: 'pipe' });
48
+ });
49
+
50
+ const first = await probe();
51
+ if (first.ok) return { ok: true, action: 'verified' };
52
+
53
+ try {
54
+ await rebuild();
55
+ } catch (e) {
56
+ return { ok: false, error: `rebuild failed: ${e.message}` };
57
+ }
58
+
59
+ const second = await probe();
60
+ if (second.ok) return { ok: true, action: 'rebuilt' };
61
+
62
+ return { ok: false, error: second.error || first.error };
63
+ }
package/mem-cli.mjs CHANGED
@@ -2383,6 +2383,8 @@ Commands:
2383
2383
  --task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
2384
2384
  --max N Max items per task (1-100, default 15)
2385
2385
  --scope S re-enrich scope: narrow (default) or wide
2386
+ --project P Limit to a single project (.|current = inferProject())
2387
+ --verbose / -v Preview also dumps cluster contents + re-enrich samples
2386
2388
 
2387
2389
  doctor Environment diagnostics and benchmarks
2388
2390
  --benchmark Run perf benchmark and emit JSON
@@ -2617,6 +2619,7 @@ async function cmdEnrich(argv) {
2617
2619
  async function cmdOptimize(db, args) {
2618
2620
  const run = args.includes('--run');
2619
2621
  const runAll = args.includes('--run-all');
2622
+ const verbose = args.includes('--verbose') || args.includes('-v');
2620
2623
  // T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
2621
2624
  const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
2622
2625
  const taskIdx = args.indexOf('--task');
@@ -2647,23 +2650,56 @@ async function cmdOptimize(db, args) {
2647
2650
  // lesson_learned (the "Haiku judged 'none'" cases). Default 'narrow' preserves old behavior.
2648
2651
  const scopeIdx = args.indexOf('--scope');
2649
2652
  const reenrichScope = scopeIdx >= 0 && args[scopeIdx + 1] === 'wide' ? 'wide' : 'narrow';
2653
+ // --project <name> filters all 4 tasks to one project. Opt-in; absence
2654
+ // preserves prior cross-project default. `.` or `current` auto-resolve via
2655
+ // inferProject() so users don't need to remember the exact name.
2656
+ const projectIdx = args.indexOf('--project');
2657
+ let project;
2658
+ if (projectIdx >= 0 && args[projectIdx + 1]) {
2659
+ const raw = args[projectIdx + 1];
2660
+ project = (raw === '.' || raw === 'current') ? inferProject() : raw;
2661
+ }
2650
2662
 
2651
2663
  if (!run && !runAll) {
2652
- const preview = optimizePreview(db);
2664
+ const preview = optimizePreview(db, { project, detail: verbose });
2653
2665
  out('[mem] 🔍 LLM Optimization Preview:');
2666
+ if (project) out(` Project filter: ${project}`);
2654
2667
  out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}`);
2655
2668
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2656
2669
  out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2657
2670
  out(` Smart-compress: ${preview.smartCompress} clusters`);
2658
2671
  out(` Total: ${preview.total} items`);
2672
+ if (verbose) {
2673
+ out('');
2674
+ if (preview.mergeClusters && preview.mergeClusters.length > 0) {
2675
+ out('─── Cluster-merge details ───');
2676
+ for (const [i, cluster] of preview.mergeClusters.entries()) {
2677
+ out(` Cluster ${i + 1} (${cluster.length} obs, project=${cluster[0]?.project || '?'}):`);
2678
+ for (const obs of cluster) out(` #${obs.id} [${obs.type || 'change'}] ${truncate(obs.title || '(untitled)', 100)}`);
2679
+ }
2680
+ }
2681
+ if (preview.reenrichSamples && preview.reenrichSamples.length > 0) {
2682
+ out('─── Re-enrich sample (first 20) ───');
2683
+ for (const obs of preview.reenrichSamples) {
2684
+ out(` #${obs.id} [${obs.type || 'change'}] (project=${obs.project || '?'}) ${truncate(obs.title || '(untitled)', 100)}`);
2685
+ }
2686
+ }
2687
+ if (preview.compressSamples && preview.compressSamples.length > 0) {
2688
+ out('─── Smart-compress sample (first 5 clusters) ───');
2689
+ for (const [i, cluster] of preview.compressSamples.entries()) {
2690
+ out(` Cluster ${i + 1} (${cluster.observations?.length || 0} obs, project=${cluster.project || '?'})`);
2691
+ }
2692
+ }
2693
+ }
2659
2694
  out('');
2660
2695
  out('Run with --run to execute, --run-all to bypass gates.');
2661
2696
  out('For R-7 backfill: --run --task re-enrich --scope wide --max N');
2697
+ out('Scope: --project <name|.|current> to limit; --verbose for cluster details.');
2662
2698
  return;
2663
2699
  }
2664
2700
 
2665
- out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}...`);
2666
- const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope });
2701
+ out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}${project ? ` (project: ${project})` : ''}...`);
2702
+ const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope, project });
2667
2703
 
2668
2704
  if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
2669
2705
  if (results.normalize) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.71.3",
3
+ "version": "2.72.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -61,6 +61,7 @@
61
61
  "lib/id-routing.mjs",
62
62
  "lib/err-sampler.mjs",
63
63
  "lib/metrics.mjs",
64
+ "lib/binding-probe.mjs",
64
65
  "lib/mem-override.mjs",
65
66
  "lib/save-observation.mjs",
66
67
  "lib/deferred-work.mjs",
@@ -30,6 +30,28 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
30
30
  }
31
31
  }
32
32
 
33
+ // Verify better-sqlite3 native binding matches the current Node ABI. The
34
+ // directory-presence check above is necessary but not sufficient: a Node
35
+ // version change (e.g. v22 → v24, ABI v127 → v137) leaves node_modules
36
+ // intact but the .node binary stale → server FATALs with "Could not locate
37
+ // the bindings file" on first DB open. Probe + auto-rebuild before launching.
38
+ try {
39
+ const { ensureBetterSqlite3Working } = await import('../lib/binding-probe.mjs');
40
+ const verify = await ensureBetterSqlite3Working(ROOT);
41
+ if (!verify.ok) {
42
+ process.stderr.write(`[claude-mem-lite] better-sqlite3 binding unusable: ${verify.error}\n`);
43
+ process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm rebuild better-sqlite3 --build-from-source\n`);
44
+ process.exit(1);
45
+ }
46
+ if (verify.action === 'rebuilt') {
47
+ process.stderr.write('[claude-mem-lite] Rebuilt better-sqlite3 binding for current Node ABI\n');
48
+ }
49
+ } catch (e) {
50
+ // Probe module itself failed to load — fall through to server import and let
51
+ // the native FATAL surface as before. Don't block launch on a probe regression.
52
+ process.stderr.write(`[claude-mem-lite] binding probe skipped: ${e.message}\n`);
53
+ }
54
+
33
55
  // Verify MCP SDK is importable (exports mapping intact).
34
56
  // Incomplete installs can leave the directory present but package.json missing,
35
57
  // causing Node.js to fail resolving subpath exports like /server/mcp.js.
package/server.mjs CHANGED
@@ -1576,15 +1576,33 @@ server.registerTool(
1576
1576
  const action = args.action || 'preview';
1577
1577
 
1578
1578
  if (action === 'preview') {
1579
- const preview = optimizePreview(db);
1579
+ const preview = optimizePreview(db, { project: args.project, detail: args.detail === true });
1580
1580
  const lines = [
1581
1581
  `🔍 LLM Optimization Preview:`,
1582
+ ];
1583
+ if (args.project) lines.push(` Project filter: ${args.project}`);
1584
+ lines.push(
1582
1585
  ` Re-enrich candidates: ${preview.reenrich}`,
1583
1586
  ` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
1584
1587
  ` Cluster-merge candidates: ${preview.clusterMerge} clusters`,
1585
1588
  ` Smart-compress candidates: ${preview.smartCompress} clusters`,
1586
1589
  ` Total: ${preview.total} items`,
1587
- ];
1590
+ );
1591
+ if (args.detail === true) {
1592
+ if (preview.mergeClusters && preview.mergeClusters.length > 0) {
1593
+ lines.push('', '─── Cluster-merge details ───');
1594
+ for (const [i, cluster] of preview.mergeClusters.entries()) {
1595
+ lines.push(` Cluster ${i + 1} (${cluster.length} obs, project=${cluster[0]?.project || '?'}):`);
1596
+ for (const obs of cluster) lines.push(` #${obs.id} [${obs.type || 'change'}] ${truncate(obs.title || '(untitled)', 100)}`);
1597
+ }
1598
+ }
1599
+ if (preview.reenrichSamples && preview.reenrichSamples.length > 0) {
1600
+ lines.push('', '─── Re-enrich sample (first 20) ───');
1601
+ for (const obs of preview.reenrichSamples) {
1602
+ lines.push(` #${obs.id} [${obs.type || 'change'}] (project=${obs.project || '?'}) ${truncate(obs.title || '(untitled)', 100)}`);
1603
+ }
1604
+ }
1605
+ }
1588
1606
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1589
1607
  }
1590
1608
 
@@ -1596,6 +1614,7 @@ server.registerTool(
1596
1614
  // T2-P0-B: scope parity with CLI (--scope wide). When omitted, optimizeRun defaults
1597
1615
  // to narrow via its own code; passing through keeps that fallback intact.
1598
1616
  reenrichScope: args.scope,
1617
+ project: args.project,
1599
1618
  });
1600
1619
 
1601
1620
  const lines = ['🔧 LLM Optimization Results:'];
package/source-files.mjs CHANGED
@@ -44,6 +44,11 @@ export const SOURCE_FILES = [
44
44
  'lib/id-routing.mjs',
45
45
  'lib/err-sampler.mjs',
46
46
  'lib/metrics.mjs',
47
+ // v2.71.x: better-sqlite3 ABI probe + auto-rebuild. Shared by install.mjs
48
+ // (post-`npm install` verify) and scripts/launch.mjs (pre-server-launch
49
+ // self-heal after Node ABI changes). Missing from manifest → auto-update
50
+ // ships a stale install that FATALs on first DB open after Node upgrade.
51
+ 'lib/binding-probe.mjs',
47
52
  // v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
48
53
  'cli/common.mjs',
49
54
  'cli/fts-check.mjs',
package/tool-schemas.mjs CHANGED
@@ -197,6 +197,8 @@ export const memOptimizeSchema = {
197
197
  .describe('Maximum LLM calls across all tasks (default: 15)'),
198
198
  scope: z.enum(['narrow', 'wide']).optional().default('narrow')
199
199
  .describe("Re-enrich scope: narrow=narrative-only candidates (default); wide=R-7 backfill (bugfix/refactor/feature/decision with narrative but lesson_learned='none'). CLI parity: --scope wide."),
200
+ project: z.string().optional().describe('Filter all 4 tasks to a single project. Default: scan all projects.'),
201
+ detail: coerceBool.optional().describe('preview action only — include cluster contents + re-enrich/compress sample arrays alongside aggregate counts.'),
200
202
  };
201
203
 
202
204
  export const memMaintainSchema = {