claude-mem-lite 2.71.4 → 2.73.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.4",
13
+ "version": "2.73.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.4",
3
+ "version": "2.73.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/adopt-cli.mjs CHANGED
@@ -192,9 +192,22 @@ function statusAll() {
192
192
  /**
193
193
  * cmdUnadopt — precise removal of sentinel section + plugin doc.
194
194
  * Exit code stays 0: unadopt is idempotent; "absent" isn't an error.
195
+ *
196
+ * Flags:
197
+ * --all Operate on every memdir under ~/.claude/projects/*\/memory/
198
+ * --status Read-only: list currently-adopted memdirs (mirrors `adopt --status`).
199
+ * --dry-run Preview what would be removed; no filesystem writes.
200
+ *
201
+ * Pre-fix history: unrecognized flags (e.g. `--status` extrapolated from `adopt --status`,
202
+ * or `--dry-run` extrapolated from `adopt --dry-run`) were silently ignored and the
203
+ * destructive default ran anyway, removing the sentinel block when the user expected
204
+ * a read-only probe.
195
205
  */
196
206
  export function cmdUnadopt(args = []) {
207
+ if (hasFlag(args, '--status')) return statusAll();
208
+
197
209
  const all = hasFlag(args, '--all');
210
+ const dryRun = hasFlag(args, '--dry-run');
198
211
  const targets = all
199
212
  ? listAllMemdirs().map((m) => m.memdir)
200
213
  : [memdirPath(detectCwd())];
@@ -206,6 +219,13 @@ export function cmdUnadopt(args = []) {
206
219
 
207
220
  let removed = 0, absent = 0;
208
221
  for (const memdir of targets) {
222
+ if (dryRun) {
223
+ const adopted = isAdopted(memdir, PLUGIN_SLUG);
224
+ const action = adopted ? 'would-remove' : 'absent';
225
+ log(`[unadopt --dry-run] ${memdir} → ${action}`);
226
+ if (adopted) removed++; else absent++;
227
+ continue;
228
+ }
209
229
  const r = removePluginSection(memdir, PLUGIN_SLUG);
210
230
  removePluginDoc(memdir, PLUGIN_SLUG);
211
231
  if (r.action === 'removed') removed++;
@@ -214,5 +234,6 @@ export function cmdUnadopt(args = []) {
214
234
  }
215
235
 
216
236
  log('');
217
- log(`[unadopt] ${targets.length} target(s): ${removed} removed, ${absent} absent`);
237
+ const verb = dryRun ? 'would remove' : 'removed';
238
+ log(`[unadopt${dryRun ? ' --dry-run' : ''}] ${targets.length} target(s): ${removed} ${verb}, ${absent} absent`);
218
239
  }
package/cli/activity.mjs CHANGED
@@ -20,7 +20,7 @@ function formatActivityResults(rows) {
20
20
  export async function cmdActivity(db, args) {
21
21
  const sub = args[0];
22
22
  if (!sub) {
23
- fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show> ...');
23
+ fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show|delete> ...');
24
24
  return;
25
25
  }
26
26
 
@@ -105,7 +105,11 @@ export async function cmdActivity(db, args) {
105
105
  return;
106
106
  }
107
107
  const row = getEvent(db, id);
108
- out(row ? JSON.stringify(row, null, 2) : 'Not found');
108
+ if (row) {
109
+ out(JSON.stringify(row, null, 2));
110
+ } else {
111
+ out(`[mem] activity show: event #${id} Not found`);
112
+ }
109
113
  return;
110
114
  }
111
115
 
package/cli/fts-check.mjs CHANGED
@@ -7,10 +7,16 @@ import { parseArgs, out, fail } from './common.mjs';
7
7
  export function cmdFtsCheck(db, args) {
8
8
  const { positional } = parseArgs(args);
9
9
  const action = positional[0];
10
- if (!action || !['check', 'rebuild'].includes(action)) {
10
+ if (!action) {
11
11
  fail('[mem] Usage: claude-mem-lite fts-check <check|rebuild>');
12
12
  return;
13
13
  }
14
+ if (!['check', 'rebuild'].includes(action)) {
15
+ // Tell the user what was wrong rather than dumping the usage — they passed
16
+ // something concrete, the error should name the invalid token.
17
+ fail(`[mem] Invalid action "${action}". Use: check, rebuild`);
18
+ return;
19
+ }
14
20
 
15
21
  if (action === 'check') {
16
22
  const result = checkFTSIntegrity(db);
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
@@ -1057,18 +1057,21 @@ async function cleanupHooks() {
1057
1057
  // ─── Status ─────────────────────────────────────────────────────────────────
1058
1058
 
1059
1059
  async function status() {
1060
- console.log('\nclaude-mem-lite status\n');
1060
+ // Dogfood-8: support --json so CI / setup scripts can probe install state
1061
+ // without scraping text. Collect each check as a structured record first,
1062
+ // then print text OR JSON. Text path keeps identical wording so existing
1063
+ // users / docs / screenshots stay correct.
1064
+ const json = flags.has('--json');
1065
+ const checks = [];
1066
+ const push = (level, key, message, extra = {}) => checks.push({ level, key, message, ...extra });
1061
1067
 
1062
1068
  // MCP
1063
1069
  try {
1064
1070
  const list = execFileSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
1065
- if (list.includes('mem:') || list.includes('mem ')) {
1066
- ok('MCP server: registered');
1067
- } else {
1068
- fail('MCP server: not registered');
1069
- }
1071
+ const registered = list.includes('mem:') || list.includes('mem ');
1072
+ push(registered ? 'ok' : 'fail', 'mcp', registered ? 'MCP server: registered' : 'MCP server: not registered', { registered });
1070
1073
  } catch {
1071
- warn('Could not check MCP status');
1074
+ push('warn', 'mcp', 'Could not check MCP status', { registered: null });
1072
1075
  }
1073
1076
 
1074
1077
  // Hooks
@@ -1077,34 +1080,29 @@ async function status() {
1077
1080
  const pluginDisabled = isPluginExplicitlyDisabled(settings);
1078
1081
  const pluginEnabled = settings.enabledPlugins?.[PLUGIN_KEY] === true;
1079
1082
 
1080
- if (pluginEnabled) {
1081
- ok('Plugin: enabled in settings');
1082
- } else if (pluginDisabled) {
1083
- warn('Plugin: disabled in settings');
1084
- } else {
1085
- warn('Plugin: not present in enabledPlugins');
1086
- }
1083
+ if (pluginEnabled) push('ok', 'plugin', 'Plugin: enabled in settings', { enabled: true, disabled: false });
1084
+ else if (pluginDisabled) push('warn', 'plugin', 'Plugin: disabled in settings', { enabled: false, disabled: true });
1085
+ else push('warn', 'plugin', 'Plugin: not present in enabledPlugins', { enabled: false, disabled: false });
1087
1086
 
1088
1087
  if (hasHooks && pluginDisabled) {
1089
- warn('Hooks: still configured in settings.json while plugin is disabled (runtime ignores them; run cleanup-hooks or uninstall to clean up)');
1088
+ push('warn', 'hooks', 'Hooks: still configured in settings.json while plugin is disabled (runtime ignores them; run cleanup-hooks or uninstall to clean up)', { configured: true });
1090
1089
  } else if (hasHooks) {
1091
- ok('Hooks: configured');
1090
+ push('ok', 'hooks', 'Hooks: configured', { configured: true });
1092
1091
  } else if (pluginDisabled) {
1093
- ok('Hooks: not configured');
1092
+ push('ok', 'hooks', 'Hooks: not configured', { configured: false });
1094
1093
  } else {
1095
- fail('Hooks: not configured');
1094
+ push('fail', 'hooks', 'Hooks: not configured', { configured: false });
1096
1095
  }
1097
1096
 
1098
1097
  // Plugin cache pollution: populated hooks.json in cache AND install.mjs-managed
1099
1098
  // settings.json hooks → runtime registers both → duplicate firing.
1100
1099
  const polluted = scanPluginCacheHookPollution();
1101
1100
  if (polluted.length > 0 && hasHooks) {
1102
- fail(`Plugin cache: stale hooks.json in version(s) ${polluted.join(', ')} — duplicate firing alongside settings.json (run 'install' to auto-clear)`);
1101
+ push('fail', 'plugin_cache', `Plugin cache: stale hooks.json in version(s) ${polluted.join(', ')} — duplicate firing alongside settings.json (run 'install' to auto-clear)`, { polluted_versions: polluted });
1103
1102
  } else if (polluted.length > 0) {
1104
- // plugin-only mode (no settings.json hooks) cache hooks.json is the sole source, expected
1105
- ok(`Plugin cache: ${polluted.length} version(s) with hooks.json (plugin-only mode)`);
1103
+ push('ok', 'plugin_cache', `Plugin cache: ${polluted.length} version(s) with hooks.json (plugin-only mode)`, { polluted_versions: polluted });
1106
1104
  } else if (pluginEnabled || hasHooks) {
1107
- ok('Plugin cache: no stale hooks.json (no duplicate firing)');
1105
+ push('ok', 'plugin_cache', 'Plugin cache: no stale hooks.json (no duplicate firing)', { polluted_versions: [] });
1108
1106
  }
1109
1107
 
1110
1108
  // Database
@@ -1115,35 +1113,67 @@ async function status() {
1115
1113
  const obs = db.prepare('SELECT COUNT(*) as c FROM observations').get();
1116
1114
  const sess = db.prepare('SELECT COUNT(*) as c FROM session_summaries').get();
1117
1115
  db.close();
1118
- ok(`Database: ${obs.c} observations, ${sess.c} sessions`);
1116
+ push('ok', 'database', `Database: ${obs.c} observations, ${sess.c} sessions`, { exists: true, observations: obs.c, sessions: sess.c });
1119
1117
  } catch (e) {
1120
- warn('Database: exists but check failed — ' + e.message);
1118
+ push('warn', 'database', 'Database: exists but check failed — ' + e.message, { exists: true, error: e.message });
1121
1119
  }
1122
1120
  } else {
1123
- warn('Database: not found');
1121
+ push('warn', 'database', 'Database: not found', { exists: false });
1124
1122
  }
1125
1123
 
1126
1124
  // CLI
1127
1125
  try {
1128
1126
  execFileSync('claude-mem-lite', ['--help'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
1129
- ok('CLI: claude-mem-lite command available');
1127
+ push('ok', 'cli', 'CLI: claude-mem-lite command available', { available: true });
1130
1128
  } catch {
1131
- warn('CLI: command not on PATH — run install again to create symlink');
1129
+ push('warn', 'cli', 'CLI: command not on PATH — run install again to create symlink', { available: false });
1132
1130
  }
1133
1131
 
1134
1132
  // Old system
1135
1133
  const vectorDb = join(OLD_DATA_DIR, 'vector-db');
1136
1134
  if (existsSync(vectorDb)) {
1137
- warn('Old vector-db still exists (can be removed)');
1135
+ push('warn', 'old_data', 'Old vector-db still exists (can be removed)', { vector_db_exists: true });
1138
1136
  }
1139
1137
 
1138
+ if (json) {
1139
+ const out = {};
1140
+ for (const c of checks) {
1141
+ const { level, key, message, ...extra } = c;
1142
+ out[key] = { level, message, ...extra };
1143
+ }
1144
+ console.log(JSON.stringify(out, null, 2));
1145
+ return;
1146
+ }
1147
+
1148
+ console.log('\nclaude-mem-lite status\n');
1149
+ for (const c of checks) {
1150
+ if (c.level === 'ok') ok(c.message);
1151
+ else if (c.level === 'warn') warn(c.message);
1152
+ else fail(c.message);
1153
+ }
1140
1154
  console.log('');
1141
1155
  }
1142
1156
 
1143
1157
  // ─── Doctor ─────────────────────────────────────────────────────────────────
1144
1158
 
1145
1159
  async function doctor() {
1146
- console.log('\nclaude-mem-lite doctor\n');
1160
+ // Dogfood-9: structured --json output for CI / wrapper scripts that want to
1161
+ // act on individual checks (e.g. "fail my deploy if FTS5 integrity not ok").
1162
+ // Implementation strategy: shadow ok/warn/fail/log inside doctor() so every
1163
+ // existing call site automatically captures into `checks`, and route final
1164
+ // output to JSON or text. Mirror install.mjs::status() shape — { key: {...} }
1165
+ // would lose ordering, so use a flat array of { level, message } objects
1166
+ // (doctor checks are ordered by significance: deps → server → DB → drift).
1167
+ const json = flags.has('--json');
1168
+ const checks = [];
1169
+ if (!json) console.log('\nclaude-mem-lite doctor\n');
1170
+
1171
+ // Shadow file-level helpers so every call site auto-records.
1172
+ const ok = (msg) => { checks.push({ level: 'ok', message: msg }); if (!json) console.log(` ✓ ${msg}`); };
1173
+ const warn = (msg) => { checks.push({ level: 'warn', message: msg }); if (!json) console.log(` ⚠ ${msg}`); };
1174
+ const fail = (msg) => { checks.push({ level: 'fail', message: msg }); if (!json) console.log(` ✗ ${msg}`); };
1175
+ const log = (msg) => { if (!json) console.log(` ${msg}`); };
1176
+
1147
1177
  let issues = 0;
1148
1178
  let warnings = 0;
1149
1179
  // Doctor-local ⚠ helper: visually identical to the file-level `warn`, but
@@ -1151,7 +1181,7 @@ async function doctor() {
1151
1181
  // "warnings present". Used for informational ⚠ checks; the two ⚠ paths
1152
1182
  // that ALSO bump `issues` (stale procs, dev drift) keep using the file-level
1153
1183
  // `warn` directly to avoid double-counting.
1154
- const dwarn = (msg) => { warnings++; console.log(` ⚠ ${msg}`); };
1184
+ const dwarn = (msg) => { warnings++; warn(msg); };
1155
1185
 
1156
1186
  // Node version
1157
1187
  const nodeVer = process.version;
@@ -1398,7 +1428,16 @@ async function doctor() {
1398
1428
  } catch {}
1399
1429
  }
1400
1430
 
1401
- console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
1431
+ if (json) {
1432
+ console.log(JSON.stringify({
1433
+ issues,
1434
+ warnings,
1435
+ summary: buildDoctorSummary(issues, warnings),
1436
+ checks,
1437
+ }, null, 2));
1438
+ } else {
1439
+ console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
1440
+ }
1402
1441
  // Diagnostic-tool exit-code contract: any ✗-level finding must propagate non-zero
1403
1442
  // so CI / wrapper scripts (`claude-mem-lite doctor || alert`) actually trip. Keeps
1404
1443
  // ⚠-only states at exit 0 (#8268 already established the visual ⚠ vs counted-issue
@@ -1532,7 +1571,12 @@ function writeSettings(settings) {
1532
1571
  // ─── Cleanup Stale Files ─────────────────────────────────────────────────────
1533
1572
 
1534
1573
  function cleanup() {
1535
- console.log('\nclaude-mem-lite cleanup\n');
1574
+ // Dogfood-7 addition: --dry-run lists which files would be removed without
1575
+ // touching disk. Useful before running cleanup on a remote/CI machine where
1576
+ // accidentally pruning the wrong file would be costly. Doctor reports stale
1577
+ // file counts and points users here; --dry-run lets them confirm the list.
1578
+ const dryRun = flags.has('--dry-run');
1579
+ console.log(`\nclaude-mem-lite cleanup${dryRun ? ' (--dry-run)' : ''}\n`);
1536
1580
  let removed = 0;
1537
1581
 
1538
1582
  // Clean .update-staging-* / .update-backup-* in INSTALL_DIR
@@ -1540,6 +1584,11 @@ function cleanup() {
1540
1584
  if (existsSync(INSTALL_DIR)) {
1541
1585
  for (const f of readdirSync(INSTALL_DIR)) {
1542
1586
  if (stalePatterns.some(p => f.startsWith(p))) {
1587
+ if (dryRun) {
1588
+ ok(`Would remove: ${f}`);
1589
+ removed++;
1590
+ continue;
1591
+ }
1543
1592
  try {
1544
1593
  rmSync(join(INSTALL_DIR, f), { recursive: true, force: true });
1545
1594
  ok(`Removed: ${f}`);
@@ -1556,6 +1605,11 @@ function cleanup() {
1556
1605
  if (existsSync(runtimeDir)) {
1557
1606
  for (const f of readdirSync(runtimeDir)) {
1558
1607
  if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
1608
+ if (dryRun) {
1609
+ ok(`Would remove: runtime/${f}`);
1610
+ removed++;
1611
+ continue;
1612
+ }
1559
1613
  try {
1560
1614
  rmSync(join(runtimeDir, f), { force: true });
1561
1615
  ok(`Removed: runtime/${f}`);
@@ -1567,7 +1621,8 @@ function cleanup() {
1567
1621
  }
1568
1622
  }
1569
1623
 
1570
- console.log(`\n ${removed === 0 ? 'No stale files found.' : `Removed ${removed} stale file(s).`}\n`);
1624
+ const verb = dryRun ? 'would be removed' : 'removed';
1625
+ console.log(`\n ${removed === 0 ? 'No stale files found.' : `${removed} stale file(s) ${verb}.`}\n`);
1571
1626
  }
1572
1627
 
1573
1628
  // ─── Manual Update ───────────────────────────────────────────────────────────
@@ -1707,6 +1762,13 @@ export async function main(argv = process.argv.slice(2)) {
1707
1762
  // npx claude-mem-lite (no args) → auto install
1708
1763
  await install();
1709
1764
  } else {
1765
+ // Name the unknown token before the usage block. Pre-fix `install frobnicate`
1766
+ // dumped usage silently, which read like the user had typed nothing — they had
1767
+ // no idea their command was rejected.
1768
+ if (cmd) {
1769
+ console.error(`[install] Unknown command: "${cmd}"`);
1770
+ process.exitCode = 1;
1771
+ }
1710
1772
  console.log(`
1711
1773
  claude-mem-lite — Lightweight memory system for Claude Code
1712
1774
 
@@ -1715,9 +1777,9 @@ Usage:
1715
1777
  node install.mjs install --dev Install dev mode (symlinks to dev dir)
1716
1778
  node install.mjs uninstall Remove (keep data)
1717
1779
  node install.mjs uninstall --purge Remove and delete all data
1718
- node install.mjs status Show current status
1719
- node install.mjs doctor Diagnose issues
1720
- node install.mjs cleanup Remove stale temp/staging files
1780
+ node install.mjs status Show current status (use --json for structured output)
1781
+ node install.mjs doctor Diagnose issues (use --json for structured output)
1782
+ node install.mjs cleanup Remove stale temp/staging files (use --dry-run to preview)
1721
1783
  node install.mjs cleanup-hooks Remove only claude-mem-lite hooks from settings.json
1722
1784
  node install.mjs self-update Check for and install updates
1723
1785
  node install.mjs release Sync versions (plugin/marketplace/CLAUDE.md) + regen lockfile via npm@10.9.2 (use --no-lock to skip lock regen)
package/mem-cli.mjs CHANGED
@@ -104,13 +104,16 @@ function cmdSearch(db, args) {
104
104
  }
105
105
 
106
106
  // Warn if obs-only filters used with non-observation source
107
- if (source && source !== 'observations' && (type || tier || minImportance)) {
108
- const ignored = [type && '--type', tier && '--tier', minImportance && '--importance'].filter(Boolean);
107
+ if (source && source !== 'observations' && (type || tier || minImportance || branch)) {
108
+ const ignored = [type && '--type', tier && '--tier', minImportance && '--importance', branch && '--branch'].filter(Boolean);
109
109
  process.stderr.write(`[mem] Note: ${ignored.join(', ')} only apply to observations, ignored for --source ${source}\n`);
110
110
  }
111
111
 
112
- // When --type/--tier/--importance (obs-only fields) is specified, implicitly restrict to observations
113
- const effectiveSource = source || ((type || tier || minImportance) ? 'observations' : null);
112
+ // When --type/--tier/--importance/--branch (obs-only fields) is specified, implicitly restrict to observations.
113
+ // --branch was previously cross-source: sessions/prompts have no branch column, so a query like
114
+ // `search "cache" --branch main` would include unrelated session/prompt rows, surprising users
115
+ // who passed --branch expecting a branch-scoped result.
116
+ const effectiveSource = source || ((type || tier || minImportance || branch) ? 'observations' : null);
114
117
 
115
118
  // Cross-source mode: each source needs more candidates than the final limit
116
119
  // so the post-merge sort has room to pick the best from each (paired-path with
@@ -392,9 +395,21 @@ function cmdRecent(db, args) {
392
395
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
393
396
  const jsonOutput = flags.json === true || flags.json === 'true';
394
397
 
398
+ // `recent --type bugfix` previously parsed as a silent no-op — users naturally
399
+ // try this for "show recent bugfixes". Mirror cmdSearch's enum validation.
400
+ const type = flags.type || null;
401
+ if (type) {
402
+ const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
403
+ if (!validObsTypes.has(type)) {
404
+ fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
405
+ return;
406
+ }
407
+ }
408
+
395
409
  const params = [];
396
410
  const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
397
411
  if (project) { wheres.push('project = ?'); params.push(project); }
412
+ if (type) { wheres.push('type = ?'); params.push(type); }
398
413
  params.push(limit);
399
414
 
400
415
  const rows = db.prepare(`
@@ -409,6 +424,7 @@ function cmdRecent(db, args) {
409
424
  out(JSON.stringify({
410
425
  project: project || null,
411
426
  limit,
427
+ type: type || null,
412
428
  total: rows.length,
413
429
  results: rows.map(r => ({
414
430
  id: r.id,
@@ -1010,6 +1026,13 @@ function cmdDeferAdd(db, args) {
1010
1026
  fail('[mem] Usage: claude-mem-lite defer add "<title>" [--priority 1|2|3] [--detail T] [--files f1,f2] [--project P]');
1011
1027
  return;
1012
1028
  }
1029
+ // Mirror MCP memDeferSchema.title (z.string().min(1).max(200)). CLI used to
1030
+ // accept multi-line / 1000-char titles, then `defer list` would render them
1031
+ // as one wrapped row that pushed every other item off-screen.
1032
+ if (title.length > 200) {
1033
+ fail(`[mem] defer add: title too long (${title.length} chars, max 200). Move detail to --detail "<text>".`);
1034
+ return;
1035
+ }
1013
1036
  const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
1014
1037
  if (![1, 2, 3].includes(priority)) {
1015
1038
  fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
@@ -1054,7 +1077,7 @@ function cmdDeferList(db, args) {
1054
1077
  function cmdDeferDrop(db, args) {
1055
1078
  const { positional, flags } = parseArgs(args);
1056
1079
  if (positional.length === 0) {
1057
- fail('[mem] Usage: claude-mem-lite defer drop <id-or-D#N> --reason "<reason>" [--project P]');
1080
+ fail('[mem] Usage: claude-mem-lite defer drop <id-or-D#N>[,id2,...] --reason "<reason>" [--project P]');
1058
1081
  return;
1059
1082
  }
1060
1083
  const reason = flags.reason;
@@ -1062,26 +1085,34 @@ function cmdDeferDrop(db, args) {
1062
1085
  fail('[mem] defer drop requires --reason "<non-empty string>"');
1063
1086
  return;
1064
1087
  }
1065
- const rawTok = positional[0];
1066
- // Accept both bare integer (ordinal) and "D#N" string. Mirrors the MCP
1067
- // mem_defer_drop input contract (server.mjs:1025) by using the same
1068
- // single-element resolveDeferredIds call.
1069
- const token = /^\d+$/.test(rawTok) ? parseInt(rawTok, 10) : rawTok;
1088
+ // Accept either a single token or a comma-separated batch. `save --closes-deferred`
1089
+ // already accepts the batch form (cmdSave uses resolveDeferredIds on a split list);
1090
+ // drop now mirrors that ergonomic so users can prune multiple items in one call
1091
+ // without N shell invocations.
1092
+ const rawTokens = positional.join(' ').split(',').map(s => s.trim()).filter(Boolean);
1093
+ const tokens = rawTokens.map(t => /^\d+$/.test(t) ? parseInt(t, 10) : t);
1070
1094
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1071
1095
 
1072
- let realId;
1096
+ let realIds;
1073
1097
  try {
1074
- [realId] = resolveDeferredIds(db, project, [token]);
1098
+ realIds = resolveDeferredIds(db, project, tokens);
1075
1099
  } catch (e) {
1076
1100
  fail(`[mem] defer drop: ${e.message}`);
1077
1101
  return;
1078
1102
  }
1079
- const r = dropDeferred(db, realId, reason);
1080
- if (r.changed === 0) {
1081
- out(`[mem] D#${realId} was not in 'open' status — drop is a no-op.`);
1082
- return;
1103
+ const dropped = [];
1104
+ const noop = [];
1105
+ for (const realId of realIds) {
1106
+ const r = dropDeferred(db, realId, reason);
1107
+ if (r.changed === 0) noop.push(realId);
1108
+ else dropped.push(realId);
1109
+ }
1110
+ if (dropped.length > 0) {
1111
+ out(`[mem] Dropped ${dropped.map(id => `D#${id}`).join(', ')} in project "${project}". Reason: ${reason.trim()}`);
1112
+ }
1113
+ if (noop.length > 0) {
1114
+ out(`[mem] No-op (not in 'open' status): ${noop.map(id => `D#${id}`).join(', ')}`);
1083
1115
  }
1084
- out(`[mem] Dropped D#${realId} in project "${project}". Reason: ${reason.trim()}`);
1085
1116
  }
1086
1117
 
1087
1118
  // N-1: Quality-focused stats for R-2 A/B baseline.
@@ -1563,7 +1594,15 @@ function cmdUpdate(db, args) {
1563
1594
 
1564
1595
  const updates = [];
1565
1596
  const params = [];
1566
- if (flags.title !== undefined) { updates.push('title = ?'); params.push(scrubSecrets(flags.title)); }
1597
+ if (flags.title !== undefined) {
1598
+ // Reject empty title — clears the observation's identifier and would render it
1599
+ // as `(untitled)` in every listing. Almost always an accidental shell-stripped arg.
1600
+ if (typeof flags.title === 'string' && flags.title.trim() === '') {
1601
+ fail('[mem] --title cannot be empty. Pass a non-empty string or omit the flag to leave the title unchanged.');
1602
+ return;
1603
+ }
1604
+ updates.push('title = ?'); params.push(scrubSecrets(flags.title));
1605
+ }
1567
1606
  if (flags.narrative !== undefined) { updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative)); }
1568
1607
  if (flags.type) {
1569
1608
  const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
@@ -1581,7 +1620,18 @@ function cmdUpdate(db, args) {
1581
1620
  }
1582
1621
  updates.push('importance = ?'); params.push(imp);
1583
1622
  }
1584
- if (flags.lesson !== undefined || flags['lesson-learned'] !== undefined) { updates.push('lesson_learned = ?'); params.push(scrubSecrets(flags.lesson ?? flags['lesson-learned'] ?? '')); }
1623
+ if (flags.lesson !== undefined || flags['lesson-learned'] !== undefined) {
1624
+ const rawLesson = flags.lesson ?? flags['lesson-learned'] ?? '';
1625
+ // Mirror cmdSave's 500-char cap — pre-fix `update --lesson <501-char>` was silently
1626
+ // accepted, letting overlong lessons leak into the DB through the update path
1627
+ // even though save's path rejected them. MCP memSaveSchema also caps at 500.
1628
+ if (typeof rawLesson === 'string' && rawLesson.length > 500) {
1629
+ fail(`[mem] --lesson too long (${rawLesson.length} chars, max 500).`);
1630
+ return;
1631
+ }
1632
+ updates.push('lesson_learned = ?');
1633
+ params.push(scrubSecrets(rawLesson));
1634
+ }
1585
1635
  if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(flags.concepts); }
1586
1636
 
1587
1637
  if (updates.length === 0) {
@@ -1632,7 +1682,16 @@ function cmdExport(db, args) {
1632
1682
 
1633
1683
  const project = flags.project ? resolveProject(db, flags.project) : null;
1634
1684
  if (project) { wheres.push('project = ?'); params.push(project); }
1635
- if (flags.type) { wheres.push('type = ?'); params.push(flags.type); }
1685
+ if (flags.type) {
1686
+ // Reject unknown types — silently returning [] for `--type bogus` looked like a
1687
+ // legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
1688
+ const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1689
+ if (!validObsTypes.has(flags.type)) {
1690
+ fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
1691
+ return;
1692
+ }
1693
+ wheres.push('type = ?'); params.push(flags.type);
1694
+ }
1636
1695
  let exportFromEpoch = null;
1637
1696
  let exportToEpoch = null;
1638
1697
  if (flags.from) {
@@ -1690,8 +1749,18 @@ function cmdExport(db, args) {
1690
1749
  function cmdCompress(db, args) {
1691
1750
  const { flags } = parseArgs(args);
1692
1751
  const preview = flags.execute !== true && flags.execute !== 'true';
1693
- const ageDaysRaw = parseInt(flags['age-days'], 10);
1694
- const ageDays = Number.isFinite(ageDaysRaw) && ageDaysRaw >= 1 ? ageDaysRaw : 30;
1752
+ // Reject malformed --age-days explicitly. The prior fallback (`|| 30`) silently used
1753
+ // the default whenever the value parsed as NaN or <1, so users typing `--age-days abc`
1754
+ // got the 30-day cutoff without knowing their input was discarded.
1755
+ let ageDays = 30;
1756
+ if (flags['age-days'] !== undefined) {
1757
+ const parsed = parseInt(flags['age-days'], 10);
1758
+ if (!Number.isFinite(parsed) || parsed < 1) {
1759
+ fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
1760
+ return;
1761
+ }
1762
+ ageDays = parsed;
1763
+ }
1695
1764
  const cutoff = Date.now() - ageDays * 86400000;
1696
1765
  const project = flags.project ? resolveProject(db, flags.project) : null;
1697
1766
  const projectFilter = project ? 'AND project = ?' : '';
@@ -2084,6 +2153,20 @@ function cmdRegistry(_memDb, args) {
2084
2153
  return;
2085
2154
  }
2086
2155
 
2156
+ // `--type` and `--resource-type` are both constrained to skill|agent across
2157
+ // registry sub-actions. Validating once here means search/list/import/remove
2158
+ // all reject typos like `--type sklil` instead of silently returning
2159
+ // "No resources found." (which looked like the registry was empty for that
2160
+ // type, not like a typo).
2161
+ if (flags.type !== undefined && flags.type !== 'skill' && flags.type !== 'agent') {
2162
+ fail(`[mem] Invalid --type "${flags.type}". Use: skill, agent`);
2163
+ return;
2164
+ }
2165
+ if (flags['resource-type'] !== undefined && flags['resource-type'] !== 'skill' && flags['resource-type'] !== 'agent') {
2166
+ fail(`[mem] Invalid --resource-type "${flags['resource-type']}". Use: skill, agent`);
2167
+ return;
2168
+ }
2169
+
2087
2170
  try {
2088
2171
  if (action === 'search') {
2089
2172
  const query = flags.query || positional.slice(1).join(' ');
@@ -2199,7 +2282,9 @@ function cmdRegistry(_memDb, args) {
2199
2282
  const resourceType = flags['resource-type'];
2200
2283
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
2201
2284
  const result = rdb.prepare('DELETE FROM resources WHERE type = ? AND name = ?').run(resourceType, name);
2202
- out(result.changes > 0 ? `[mem] Removed: ${resourceType}:${name}` : '[mem] Not found.');
2285
+ out(result.changes > 0
2286
+ ? `[mem] Removed: ${resourceType}:${name}`
2287
+ : `[mem] Not found: ${resourceType}:${name}`);
2203
2288
  return;
2204
2289
  }
2205
2290
 
@@ -2286,7 +2371,7 @@ Commands:
2286
2371
  --project P Filter by project
2287
2372
  --from DATE Start date (YYYY-MM-DD or ISO 8601)
2288
2373
  --to DATE End date (YYYY-MM-DD or ISO 8601)
2289
- --importance N Minimum importance (1-3)
2374
+ --importance N Minimum importance (1=routine, 2=notable, 3=critical)
2290
2375
  --branch B Filter by git branch
2291
2376
  --offset N Skip first N results (pagination)
2292
2377
  --tier T Filter by tier (working|active|archive, observations only)
@@ -2298,7 +2383,8 @@ Commands:
2298
2383
  recent [N] Show N most recent observations (default 10)
2299
2384
  --limit N Sibling-parity alias for [N] (max 1000)
2300
2385
  --project P Filter by project
2301
- --json Output as JSON: {project,limit,total,results:[…]}
2386
+ --type T Filter obs type (bugfix|decision|discovery|feature|refactor|change)
2387
+ --json Output as JSON: {project,limit,type,total,results:[…]}
2302
2388
 
2303
2389
  recall <file> Show observations related to a file
2304
2390
  --limit N Max results (default 10)
@@ -2328,22 +2414,22 @@ Commands:
2328
2414
  save "<text>" Save a new observation
2329
2415
  --type T Observation type (default: discovery)
2330
2416
  --title T Title (auto-generated if omitted)
2331
- --importance N 1-3 (default: 2)
2417
+ --importance N 1=routine, 2=notable, 3=critical (default: 2)
2332
2418
  --project P Project name
2333
2419
  --files f1,f2 Comma-separated file paths
2334
2420
  --lesson T Lesson learned (≤500 chars; alias: --lesson-learned)
2335
2421
  --closes-deferred 1,D#42 Close deferred items in same transaction
2336
2422
 
2337
2423
  defer <action> First-class deferred work (v2.70+)
2338
- add "<title>" Mark deferred work for next session
2339
- --priority N 1-3 (default 2)
2424
+ add "<title>" Mark deferred work for next session (≤200 chars)
2425
+ --priority N 1=low, 2=normal, 3=urgent (default: 2)
2340
2426
  --detail T Constraint + why deferred
2341
2427
  --files f1,f2 Comma-separated file paths
2342
2428
  --project P Project name
2343
2429
  list List open deferred items
2344
2430
  --limit N Max results (default 10)
2345
2431
  --project P Filter by project
2346
- drop <D#N|ordinal> Drop a deferred item (no fix needed)
2432
+ drop <D#N|ordinal>[,...] Drop one or more deferred items (no fix needed)
2347
2433
  --reason "..." Required audit trail
2348
2434
 
2349
2435
  delete <id1,id2,...> Delete observations by ID
@@ -2352,7 +2438,7 @@ Commands:
2352
2438
  update <id> Update an existing observation
2353
2439
  --title T New title
2354
2440
  --type T New type
2355
- --importance N New importance (1-3)
2441
+ --importance N New importance (1=routine, 2=notable, 3=critical)
2356
2442
  --lesson T Add/update lesson learned (alias: --lesson-learned)
2357
2443
  --narrative T New narrative
2358
2444
  --concepts T Space-separated concept tags
@@ -2383,6 +2469,8 @@ Commands:
2383
2469
  --task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
2384
2470
  --max N Max items per task (1-100, default 15)
2385
2471
  --scope S re-enrich scope: narrow (default) or wide
2472
+ --project P Limit to a single project (.|current = inferProject())
2473
+ --verbose / -v Preview also dumps cluster contents + re-enrich samples
2386
2474
 
2387
2475
  doctor Environment diagnostics and benchmarks
2388
2476
  --benchmark Run perf benchmark and emit JSON
@@ -2449,6 +2537,8 @@ Commands:
2449
2537
 
2450
2538
  unadopt Precise removal of the sentinel block + plugin_claude_mem_lite.md.
2451
2539
  --all Unadopt every project
2540
+ --status Read-only: list adopted projects (same as adopt --status)
2541
+ --dry-run Preview what would be removed; no filesystem writes
2452
2542
 
2453
2543
  memdir-audit Audit memdir feedback_*.md / project_*.md for the
2454
2544
  body-structure contract (**Why:** + **How to apply:**).
@@ -2549,16 +2639,30 @@ async function cmdImportJsonl(db, argv) {
2549
2639
  if (files.length === 0) { out('[mem] No .jsonl files found.'); return; }
2550
2640
 
2551
2641
  const { importJsonl } = await import('./lib/import-jsonl.mjs');
2552
- let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0;
2642
+ let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0, errorCount = 0;
2553
2643
  for (const f of files) {
2554
- const r = await importJsonl(db, f, { project });
2644
+ // Per-file isolation: one unreadable file (EACCES, EBUSY, mid-batch IO error)
2645
+ // shouldn't crash the whole import — readFileSync inside importJsonl would
2646
+ // otherwise throw an unhandled exception with a node stack trace, leaving
2647
+ // earlier successes uncommitted-looking from the user's perspective.
2648
+ let r;
2649
+ try {
2650
+ r = await importJsonl(db, f, { project });
2651
+ } catch (e) {
2652
+ errorCount++;
2653
+ // e.message for node fs errors already begins with the code (e.g. "EACCES: permission denied, ...");
2654
+ // don't double-prefix.
2655
+ process.stderr.write(`[mem] ${f}: import failed — ${e.message}\n`);
2656
+ continue;
2657
+ }
2555
2658
  totalPrompts += r.prompts;
2556
2659
  totalObs += r.observations;
2557
2660
  totalSkip += r.skipped;
2558
2661
  totalOrphans += r.orphans || 0;
2559
2662
  out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations, ${r.orphans || 0} orphan tool_use, ${r.skipped} skipped`);
2560
2663
  }
2561
- out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s).`);
2664
+ const errorTail = errorCount > 0 ? `, ${errorCount} file(s) errored` : '';
2665
+ out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2562
2666
  if (totalPrompts > 0 || totalObs > 0) {
2563
2667
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2564
2668
  }
@@ -2617,6 +2721,7 @@ async function cmdEnrich(argv) {
2617
2721
  async function cmdOptimize(db, args) {
2618
2722
  const run = args.includes('--run');
2619
2723
  const runAll = args.includes('--run-all');
2724
+ const verbose = args.includes('--verbose') || args.includes('-v');
2620
2725
  // T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
2621
2726
  const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
2622
2727
  const taskIdx = args.indexOf('--task');
@@ -2645,25 +2750,67 @@ async function cmdOptimize(db, args) {
2645
2750
  }
2646
2751
  // R-7 micro: --scope wide targets bugfix/refactor/feature/decision with narrative but no
2647
2752
  // lesson_learned (the "Haiku judged 'none'" cases). Default 'narrow' preserves old behavior.
2753
+ // Validate explicitly so `--scope wlde` (typo) doesn't silently become narrow and waste an LLM run.
2648
2754
  const scopeIdx = args.indexOf('--scope');
2649
- const reenrichScope = scopeIdx >= 0 && args[scopeIdx + 1] === 'wide' ? 'wide' : 'narrow';
2755
+ let reenrichScope = 'narrow';
2756
+ if (scopeIdx >= 0 && args[scopeIdx + 1] !== undefined) {
2757
+ const raw = args[scopeIdx + 1];
2758
+ if (raw !== 'narrow' && raw !== 'wide') {
2759
+ fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide`);
2760
+ return;
2761
+ }
2762
+ reenrichScope = raw;
2763
+ }
2764
+ // --project <name> filters all 4 tasks to one project. Opt-in; absence
2765
+ // preserves prior cross-project default. `.` or `current` auto-resolve via
2766
+ // inferProject() so users don't need to remember the exact name.
2767
+ const projectIdx = args.indexOf('--project');
2768
+ let project;
2769
+ if (projectIdx >= 0 && args[projectIdx + 1]) {
2770
+ const raw = args[projectIdx + 1];
2771
+ project = (raw === '.' || raw === 'current') ? inferProject() : raw;
2772
+ }
2650
2773
 
2651
2774
  if (!run && !runAll) {
2652
- const preview = optimizePreview(db);
2775
+ const preview = optimizePreview(db, { project, detail: verbose });
2653
2776
  out('[mem] 🔍 LLM Optimization Preview:');
2777
+ if (project) out(` Project filter: ${project}`);
2654
2778
  out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}`);
2655
2779
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2656
2780
  out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2657
2781
  out(` Smart-compress: ${preview.smartCompress} clusters`);
2658
2782
  out(` Total: ${preview.total} items`);
2783
+ if (verbose) {
2784
+ out('');
2785
+ if (preview.mergeClusters && preview.mergeClusters.length > 0) {
2786
+ out('─── Cluster-merge details ───');
2787
+ for (const [i, cluster] of preview.mergeClusters.entries()) {
2788
+ out(` Cluster ${i + 1} (${cluster.length} obs, project=${cluster[0]?.project || '?'}):`);
2789
+ for (const obs of cluster) out(` #${obs.id} [${obs.type || 'change'}] ${truncate(obs.title || '(untitled)', 100)}`);
2790
+ }
2791
+ }
2792
+ if (preview.reenrichSamples && preview.reenrichSamples.length > 0) {
2793
+ out('─── Re-enrich sample (first 20) ───');
2794
+ for (const obs of preview.reenrichSamples) {
2795
+ out(` #${obs.id} [${obs.type || 'change'}] (project=${obs.project || '?'}) ${truncate(obs.title || '(untitled)', 100)}`);
2796
+ }
2797
+ }
2798
+ if (preview.compressSamples && preview.compressSamples.length > 0) {
2799
+ out('─── Smart-compress sample (first 5 clusters) ───');
2800
+ for (const [i, cluster] of preview.compressSamples.entries()) {
2801
+ out(` Cluster ${i + 1} (${cluster.observations?.length || 0} obs, project=${cluster.project || '?'})`);
2802
+ }
2803
+ }
2804
+ }
2659
2805
  out('');
2660
2806
  out('Run with --run to execute, --run-all to bypass gates.');
2661
2807
  out('For R-7 backfill: --run --task re-enrich --scope wide --max N');
2808
+ out('Scope: --project <name|.|current> to limit; --verbose for cluster details.');
2662
2809
  return;
2663
2810
  }
2664
2811
 
2665
- out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}...`);
2666
- const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope });
2812
+ out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}${project ? ` (project: ${project})` : ''}...`);
2813
+ const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope, project });
2667
2814
 
2668
2815
  if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
2669
2816
  if (results.normalize) {
@@ -2722,7 +2869,11 @@ export async function run(argv) {
2722
2869
  const JSON_SUPPORTED_CMDS = new Set([
2723
2870
  'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export',
2724
2871
  ]);
2725
- if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd)) {
2872
+ // `doctor --benchmark` already emits JSON on its own — don't print the misleading
2873
+ // "doctor outputs text" note for that subpath. Without --benchmark, doctor is text
2874
+ // and the note is still useful.
2875
+ const doctorBenchmark = cmd === 'doctor' && cmdArgs.includes('--benchmark');
2876
+ if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd) && !doctorBenchmark) {
2726
2877
  process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
2727
2878
  }
2728
2879
 
@@ -2756,6 +2907,19 @@ export async function run(argv) {
2756
2907
  out('[mem] Run "claude-mem-lite help" for usage');
2757
2908
  process.exitCode = 1;
2758
2909
  }
2910
+ } catch (e) {
2911
+ // SQLITE_BUSY / SQLITE_LOCKED + extended variants (SQLITE_BUSY_SNAPSHOT,
2912
+ // SQLITE_BUSY_RECOVERY, SQLITE_LOCKED_SHAREDCACHE…). All mean the same thing
2913
+ // to the user: writer contention past the 5s busy_timeout. Pre-fix this
2914
+ // raised an unhandled SqliteError with a node stack trace.
2915
+ const code = e && typeof e.code === 'string' ? e.code : '';
2916
+ if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED' ||
2917
+ code.startsWith('SQLITE_BUSY_') || code.startsWith('SQLITE_LOCKED_')) {
2918
+ process.stderr.write(`[mem] Database busy — another process held the writer past the 5s timeout. Retry shortly.\n`);
2919
+ process.exitCode = 1;
2920
+ return;
2921
+ }
2922
+ throw e;
2759
2923
  } finally {
2760
2924
  try { db.close(); } catch {}
2761
2925
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.71.4",
3
+ "version": "2.73.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -696,7 +696,10 @@ export function ensureDb() {
696
696
  const db = new Database(DB_PATH);
697
697
  try { chmodSync(DB_PATH, 0o600); } catch {}
698
698
  db.pragma('journal_mode = WAL');
699
- db.pragma('busy_timeout = 3000');
699
+ // 5000ms matches the MCP server (server.mjs) — 3000ms wasn't enough under realistic
700
+ // concurrency (parallel CLI saves + a long-running FTS rebuild can push individual
701
+ // transactions past 3s, triggering SQLITE_BUSY on the third caller).
702
+ db.pragma('busy_timeout = 5000');
700
703
  db.pragma('synchronous = NORMAL');
701
704
  db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
702
705
 
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/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 = {