claude-mem-lite 2.72.0 → 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.72.0",
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.72.0",
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/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
@@ -2451,6 +2537,8 @@ Commands:
2451
2537
 
2452
2538
  unadopt Precise removal of the sentinel block + plugin_claude_mem_lite.md.
2453
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
2454
2542
 
2455
2543
  memdir-audit Audit memdir feedback_*.md / project_*.md for the
2456
2544
  body-structure contract (**Why:** + **How to apply:**).
@@ -2551,16 +2639,30 @@ async function cmdImportJsonl(db, argv) {
2551
2639
  if (files.length === 0) { out('[mem] No .jsonl files found.'); return; }
2552
2640
 
2553
2641
  const { importJsonl } = await import('./lib/import-jsonl.mjs');
2554
- let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0;
2642
+ let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0, errorCount = 0;
2555
2643
  for (const f of files) {
2556
- 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
+ }
2557
2658
  totalPrompts += r.prompts;
2558
2659
  totalObs += r.observations;
2559
2660
  totalSkip += r.skipped;
2560
2661
  totalOrphans += r.orphans || 0;
2561
2662
  out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations, ${r.orphans || 0} orphan tool_use, ${r.skipped} skipped`);
2562
2663
  }
2563
- 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}.`);
2564
2666
  if (totalPrompts > 0 || totalObs > 0) {
2565
2667
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2566
2668
  }
@@ -2648,8 +2750,17 @@ async function cmdOptimize(db, args) {
2648
2750
  }
2649
2751
  // R-7 micro: --scope wide targets bugfix/refactor/feature/decision with narrative but no
2650
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.
2651
2754
  const scopeIdx = args.indexOf('--scope');
2652
- 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
+ }
2653
2764
  // --project <name> filters all 4 tasks to one project. Opt-in; absence
2654
2765
  // preserves prior cross-project default. `.` or `current` auto-resolve via
2655
2766
  // inferProject() so users don't need to remember the exact name.
@@ -2758,7 +2869,11 @@ export async function run(argv) {
2758
2869
  const JSON_SUPPORTED_CMDS = new Set([
2759
2870
  'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export',
2760
2871
  ]);
2761
- 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) {
2762
2877
  process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
2763
2878
  }
2764
2879
 
@@ -2792,6 +2907,19 @@ export async function run(argv) {
2792
2907
  out('[mem] Run "claude-mem-lite help" for usage');
2793
2908
  process.exitCode = 1;
2794
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;
2795
2923
  } finally {
2796
2924
  try { db.close(); } catch {}
2797
2925
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.72.0",
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