claude-mem-lite 2.66.0 → 2.69.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.66.0",
13
+ "version": "2.69.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.66.0",
3
+ "version": "2.69.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/cli/activity.mjs CHANGED
@@ -109,5 +109,52 @@ export async function cmdActivity(db, args) {
109
109
  return;
110
110
  }
111
111
 
112
+ if (sub === 'delete') {
113
+ // Mirrors cmdDelete (mem-cli.mjs:1316): preview by default, --confirm
114
+ // executes. Per Tier 3b in tasks/v2.66-carry-forward.md the events table
115
+ // accumulates corrupted titles from old hook-llm fallback bugs (#8158).
116
+ // This command lets users prune them by ID without dropping to raw SQL.
117
+ const idStr = positional.join(',').trim();
118
+ if (!idStr) {
119
+ fail('[mem] Usage: claude-mem-lite activity delete <id1,id2,...> [--confirm]');
120
+ return;
121
+ }
122
+ const ids = idStr.split(',')
123
+ .map(s => s.trim())
124
+ .filter(Boolean)
125
+ .map(s => parseInt(s, 10))
126
+ .filter(n => Number.isInteger(n) && n > 0);
127
+ if (ids.length === 0) {
128
+ fail('[mem] activity delete: no valid IDs provided (must be positive integers)');
129
+ return;
130
+ }
131
+
132
+ const placeholders = ids.map(() => '?').join(',');
133
+ const rows = db.prepare(`SELECT id, event_type, title FROM events WHERE id IN (${placeholders})`).all(...ids);
134
+ if (rows.length === 0) {
135
+ fail(`[mem] activity delete: no events found for ID(s) ${ids.join(', ')}`);
136
+ return;
137
+ }
138
+
139
+ const confirm = flags.confirm === true || flags.confirm === 'true';
140
+ if (!confirm) {
141
+ out(`[mem] Preview: ${rows.length} event(s) will be deleted:`);
142
+ for (const r of rows) {
143
+ const titleStr = (r.title || '').slice(0, 100);
144
+ out(` #${r.id} [${r.event_type}] ${titleStr}`);
145
+ }
146
+ const missingIds = ids.filter(i => !rows.some(r => r.id === i));
147
+ if (missingIds.length > 0) {
148
+ out(`[mem] Note: ${missingIds.length} ID(s) not found and will be skipped: ${missingIds.join(', ')}`);
149
+ }
150
+ out('[mem] Run with --confirm to execute deletion.');
151
+ return;
152
+ }
153
+
154
+ const result = db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
155
+ out(`[mem] Deleted ${result.changes} event(s).`);
156
+ return;
157
+ }
158
+
112
159
  fail(`[mem] Unknown activity subcommand: ${sub}`);
113
160
  }
package/hook-context.mjs CHANGED
@@ -407,6 +407,30 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
407
407
  handoffLines.push('');
408
408
  }
409
409
 
410
+ // 5b. Deferred Work — project-level importance≥3 obs that survived prior
411
+ // session boundaries. Independent of the per-session handoff: even when the
412
+ // most recent /clear or /exit handoff has stale or meta-only `working_on`,
413
+ // genuine carry-forward decisions stay surfaced. Capped at 3 to keep the
414
+ // banner skim-able. Quiet-hooks does NOT suppress: the whole point is
415
+ // visibility for cross-session continuity.
416
+ const deferredObs = db.prepare(`
417
+ SELECT id, type, title FROM observations
418
+ WHERE project = ? AND COALESCE(compressed_into, 0) = 0
419
+ AND superseded_at IS NULL
420
+ AND COALESCE(importance, 1) >= 3
421
+ AND ${notLowSignalTitleClause('')}
422
+ ORDER BY created_at_epoch DESC LIMIT 3
423
+ `).all(project);
424
+
425
+ const deferredLines = [];
426
+ if (deferredObs.length > 0) {
427
+ deferredLines.push('### Deferred Work');
428
+ for (const o of deferredObs) {
429
+ deferredLines.push(`- ${typeIcon(o.type)} [${o.type}] ${truncate(o.title, 140)} (#${o.id})`);
430
+ }
431
+ deferredLines.push('');
432
+ }
433
+
410
434
  // 6. Recent observations table
411
435
  const obsLines = [];
412
436
  const obsToShow = observations.length >= 3 ? observations : fallbackObs;
@@ -422,7 +446,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
422
446
  }
423
447
  }
424
448
 
425
- return [...summaryLines, ...handoffLines, ...obsLines].join('\n');
449
+ return [...summaryLines, ...handoffLines, ...deferredLines, ...obsLines].join('\n');
426
450
  }
427
451
 
428
452
  /**
package/hook-handoff.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Extracted for testability — hook.mjs has module-level side effects
3
3
 
4
4
  import { basename } from 'path';
5
- import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SIGNAL_TITLE, EDIT_TOOLS } from './utils.mjs';
5
+ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
6
6
  import {
7
7
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_ANCHOR_MAX_AGE,
8
8
  HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
@@ -39,14 +39,38 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
39
39
  `).all(sessionId);
40
40
  if (prompts.length === 0) return; // Empty session — nothing to hand off
41
41
 
42
+ // Filter prompts whose only content is workflow/control language ("继续",
43
+ // "提交代码", "/exit", etc.). Storing them verbatim into working_on creates
44
+ // self-referential handoffs ("Working On: 继续前面的工作") that point at the
45
+ // trigger instead of the subject. When ALL prompts are meta, fall back to
46
+ // the project's most recent importance≥3 non-low-signal observation as the
47
+ // carry-forward anchor — that's the closest durable signal of "what was
48
+ // being worked on at a higher level than this session".
49
+ const subjectPrompts = prompts.filter(p => !isMetaTriggerPrompt(p.prompt_text));
50
+ const sourcePrompts = subjectPrompts.length > 0 ? subjectPrompts : prompts;
51
+
42
52
  const seen = new Set();
43
- const uniquePrompts = prompts.filter(p => {
53
+ const uniquePrompts = sourcePrompts.filter(p => {
44
54
  const t = truncate(p.prompt_text, 200);
45
55
  if (seen.has(t)) return false;
46
56
  seen.add(t);
47
57
  return true;
48
58
  });
49
- const workingOn = uniquePrompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
59
+ let workingOn = uniquePrompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
60
+
61
+ if (subjectPrompts.length === 0) {
62
+ const fallback = db.prepare(`
63
+ SELECT title FROM observations
64
+ WHERE project = ? AND COALESCE(compressed_into, 0) = 0
65
+ AND superseded_at IS NULL
66
+ AND COALESCE(importance, 1) >= 3
67
+ AND ${notLowSignalTitleClause('')}
68
+ ORDER BY created_at_epoch DESC LIMIT 1
69
+ `).get(project);
70
+ if (fallback?.title) {
71
+ workingOn = `(carry-forward subject) ${truncate(fallback.title, 180)}`;
72
+ }
73
+ }
50
74
 
51
75
  // 2. Completed — from observations (include narrative for richer handoff)
52
76
  const completed = db.prepare(`
@@ -0,0 +1,48 @@
1
+ // CLI numeric-flag validation helper.
2
+ //
3
+ // Extends the audit pattern from #8277 (Number.isInteger + range check) with a
4
+ // single reusable surface that also enforces upper bounds. The pre-existing
5
+ // 5-line parseInt boilerplate was duplicated across cmdSearch / cmdRecall /
6
+ // cmdBrowse / cmdTimeline / cmdExport — each maintainer drifted slightly,
7
+ // and none capped --limit, so `claude-mem-lite search "x" --limit 99999999`
8
+ // silently dumped the entire result set.
9
+ //
10
+ // Behavior contract:
11
+ // - Returns defaultValue when rawValue is undefined/null/empty.
12
+ // - On invalid input (non-integer, out of [min, max]), writes a single
13
+ // stderr warning and returns defaultValue. Does NOT throw — calling code
14
+ // keeps running with a sane fallback.
15
+ // - Returns rawValue (parsed) only when it's a finite integer in range.
16
+ //
17
+ // Caller responsibilities: pick min/max that make sense for the flag's
18
+ // domain (e.g. --offset min=0; --limit max=1000; --importance min=1, max=3).
19
+
20
+ const DEFAULT_STDERR_WRITE = msg => process.stderr.write(msg);
21
+
22
+ /**
23
+ * Validate and parse a CLI numeric flag with optional bounds.
24
+ *
25
+ * @param {string|number|undefined|null} rawValue Flag value as captured by parseArgs (or undefined when absent).
26
+ * @param {object} opts
27
+ * @param {string} opts.name Flag name with leading dashes (e.g. "--limit") for the warning text.
28
+ * @param {number} opts.defaultValue Fallback when input is missing or invalid.
29
+ * @param {number} [opts.min=1] Inclusive lower bound.
30
+ * @param {number} [opts.max=Number.MAX_SAFE_INTEGER] Inclusive upper bound.
31
+ * @param {(msg: string) => void} [opts.warn] Test seam — defaults to process.stderr.write.
32
+ * @returns {number} Validated integer or defaultValue.
33
+ */
34
+ export function parseIntFlag(rawValue, opts) {
35
+ const { name, defaultValue, min = 1, max = Number.MAX_SAFE_INTEGER, warn = DEFAULT_STDERR_WRITE } = opts;
36
+
37
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
38
+ return defaultValue;
39
+ }
40
+
41
+ const parsed = parseInt(rawValue, 10);
42
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
43
+ const range = max === Number.MAX_SAFE_INTEGER ? `≥ ${min}` : `between ${min} and ${max}`;
44
+ warn(`[mem] Invalid ${name} "${rawValue}" (must be an integer ${range}); using default ${defaultValue}\n`);
45
+ return defaultValue;
46
+ }
47
+ return parsed;
48
+ }
package/mem-cli.mjs CHANGED
@@ -17,6 +17,7 @@ import { searchResources } from './registry-retriever.mjs';
17
17
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
18
18
  import { buildSessionContextLines } from './hook-context.mjs';
19
19
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
20
+ import { parseIntFlag } from './lib/cli-flags.mjs';
20
21
  import { auditMemdir, memdirPath } from './memdir.mjs';
21
22
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
22
23
  import { basename, join } from 'path';
@@ -38,13 +39,7 @@ function cmdSearch(db, args) {
38
39
  return;
39
40
  }
40
41
 
41
- const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
42
- // Distinguish missing/non-integer (use default) from non-positive (silently clamping to 1
43
- // produced confusing "Found 1 of 44 result" output for --limit 0/-N — warn instead).
44
- if (flags.limit !== undefined && (!Number.isInteger(rawLimit) || rawLimit < 1)) {
45
- process.stderr.write(`[mem] Invalid --limit "${flags.limit}" (must be a positive integer); using default 20\n`);
46
- }
47
- const limit = Number.isInteger(rawLimit) && rawLimit >= 1 ? rawLimit : 20;
42
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
48
43
  const type = flags.type || null;
49
44
  const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
50
45
  if (type && !validObsTypes.has(type)) {
@@ -383,8 +378,14 @@ function cmdRecent(db, args) {
383
378
  if (rawArg !== undefined && !isValid) {
384
379
  process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
385
380
  }
386
- const limit = isValid ? rawLimit : 10;
381
+ // Positional [N] wins for backward-compat; --limit is sibling-parity alias
382
+ // (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
383
+ // was silently ignored — surprising users extrapolating from siblings.
384
+ const limit = isValid
385
+ ? rawLimit
386
+ : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
387
387
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
388
+ const jsonOutput = flags.json === true || flags.json === 'true';
388
389
 
389
390
  const params = [];
390
391
  const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
@@ -392,13 +393,30 @@ function cmdRecent(db, args) {
392
393
  params.push(limit);
393
394
 
394
395
  const rows = db.prepare(`
395
- SELECT id, type, title, subtitle, created_at_epoch, created_at
396
+ SELECT id, type, title, subtitle, importance, created_at_epoch, created_at
396
397
  FROM observations
397
398
  WHERE ${wheres.join(' AND ')}
398
399
  ORDER BY created_at_epoch DESC
399
400
  LIMIT ?
400
401
  `).all(...params);
401
402
 
403
+ if (jsonOutput) {
404
+ out(JSON.stringify({
405
+ project: project || null,
406
+ limit,
407
+ total: rows.length,
408
+ results: rows.map(r => ({
409
+ id: r.id,
410
+ type: r.type,
411
+ title: r.title || r.subtitle || null,
412
+ importance: r.importance ?? null,
413
+ created_at: r.created_at,
414
+ created_at_epoch: r.created_at_epoch,
415
+ })),
416
+ }));
417
+ return;
418
+ }
419
+
402
420
  if (rows.length === 0) {
403
421
  out(`[mem] No recent observations${project ? ` (${project})` : ''}`);
404
422
  return;
@@ -416,24 +434,22 @@ function cmdRecall(db, args) {
416
434
  const { positional, flags } = parseArgs(args);
417
435
  const file = positional.join(' ');
418
436
  if (!file) {
419
- fail('[mem] Usage: claude-mem-lite recall <file> [--limit N] [--include-noise]');
437
+ fail('[mem] Usage: claude-mem-lite recall <file> [--limit N] [--include-noise] [--json]');
420
438
  return;
421
439
  }
422
440
 
423
441
  const filename = basename(file);
424
- const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
425
- if (flags.limit !== undefined && (!Number.isInteger(rawLimit) || rawLimit < 1)) {
426
- process.stderr.write(`[mem] Invalid --limit "${flags.limit}" (must be a positive integer); using default 10\n`);
427
- }
428
- const limit = Number.isInteger(rawLimit) && rawLimit >= 1 ? rawLimit : 10;
442
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
429
443
  const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
444
+ const jsonOutput = flags.json === true || flags.json === 'true';
430
445
 
431
446
  // Search via observation_files junction table for indexed filename lookups
432
447
  const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
433
448
  const likePattern = `%${escaped}`;
434
449
  const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
435
450
  const rows = db.prepare(`
436
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
451
+ SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
452
+ o.created_at, o.created_at_epoch, o.project
437
453
  FROM observations o
438
454
  JOIN observation_files of2 ON of2.obs_id = o.id
439
455
  WHERE COALESCE(o.compressed_into, 0) = 0
@@ -443,18 +459,40 @@ function cmdRecall(db, args) {
443
459
  LIMIT ?
444
460
  `).all(filename, likePattern, limit);
445
461
 
462
+ if (rows.length > 0) {
463
+ // Update access_count for recalled observations (aligned with MCP mem_recall)
464
+ const recalledIds = rows.map(r => r.id);
465
+ const recallPh = recalledIds.map(() => '?').join(',');
466
+ try {
467
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
468
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
469
+ }
470
+
471
+ if (jsonOutput) {
472
+ out(JSON.stringify({
473
+ file: filename,
474
+ limit,
475
+ include_noise: includeNoise,
476
+ total: rows.length,
477
+ results: rows.map(r => ({
478
+ id: r.id,
479
+ type: r.type,
480
+ title: r.title || null,
481
+ lesson_learned: r.lesson_learned || null,
482
+ importance: r.importance ?? null,
483
+ project: r.project,
484
+ created_at: r.created_at,
485
+ created_at_epoch: r.created_at_epoch,
486
+ })),
487
+ }));
488
+ return;
489
+ }
490
+
446
491
  if (rows.length === 0) {
447
492
  out(`[mem] No history for "${filename}"`);
448
493
  return;
449
494
  }
450
495
 
451
- // Update access_count for recalled observations (aligned with MCP mem_recall)
452
- const recalledIds = rows.map(r => r.id);
453
- const recallPh = recalledIds.map(() => '?').join(',');
454
- try {
455
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
456
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
457
-
458
496
  out(`[mem] History for ${filename} (${rows.length}):`);
459
497
  for (const r of rows) {
460
498
  const title = truncate(r.title || '(untitled)', 80);
@@ -635,6 +673,15 @@ function cmdTimeline(db, args) {
635
673
  const before = parseWindow('before', flags.before);
636
674
  const after = parseWindow('after', flags.after);
637
675
  const project = flags.project ? resolveProject(db, flags.project) : null;
676
+ const jsonOutput = flags.json === true || flags.json === 'true';
677
+
678
+ const toRow = r => ({
679
+ id: r.id,
680
+ type: r.type,
681
+ title: r.title || r.subtitle || null,
682
+ created_at: r.created_at,
683
+ created_at_epoch: r.created_at_epoch,
684
+ });
638
685
 
639
686
  // Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
640
687
  // For prompt/session anchors, resolve to the nearest-in-time observation so timeline semantics
@@ -745,6 +792,18 @@ function cmdTimeline(db, args) {
745
792
  LIMIT ?
746
793
  `).all(...fallbackParams);
747
794
 
795
+ if (jsonOutput) {
796
+ out(JSON.stringify({
797
+ anchor: null,
798
+ anchor_note: queryStr ? `no anchor matched query "${queryStr}"` : null,
799
+ before: [],
800
+ after: [],
801
+ fallback: 'recent',
802
+ results: rows.map(toRow),
803
+ }));
804
+ return;
805
+ }
806
+
748
807
  if (rows.length === 0) {
749
808
  out('[mem] No observations found.');
750
809
  return;
@@ -800,6 +859,16 @@ function cmdTimeline(db, args) {
800
859
  'SELECT id, type, title, subtitle, created_at, created_at_epoch FROM observations WHERE id = ?'
801
860
  ).get(anchorId);
802
861
 
862
+ if (jsonOutput) {
863
+ out(JSON.stringify({
864
+ anchor: toRow(anchor),
865
+ anchor_note: anchorNote,
866
+ before: beforeRows.reverse().map(toRow),
867
+ after: afterRows.map(toRow),
868
+ }));
869
+ return;
870
+ }
871
+
803
872
  const all = [...beforeRows.reverse(), anchor, ...afterRows];
804
873
 
805
874
  out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
@@ -887,12 +956,18 @@ async function renderQualityReport(db, { project, days }) {
887
956
  async function cmdStats(db, args) {
888
957
  const { flags } = parseArgs(args);
889
958
  const project = flags.project ? resolveProject(db, flags.project) : null;
890
- const days = parseInt(flags.days, 10) || 30;
959
+ const days = parseIntFlag(flags.days, { name: '--days', defaultValue: 30, max: 3650 });
960
+ const jsonOutput = flags.json === true || flags.json === 'true';
891
961
  // N-1: --quality routes to a separate quality-focused report (lesson rate,
892
962
  // LOW_SIGNAL rate, per-type hit+lesson %, R-2 watchdog targets). Intended as
893
963
  // the baseline metric dashboard for the future Haiku prompt A/B test.
894
964
  const quality = flags.quality === true || flags.quality === 'true';
895
965
  if (quality) {
966
+ if (jsonOutput) {
967
+ const { computeQualityStats } = await import('./lib/stats-quality.mjs');
968
+ out(JSON.stringify(computeQualityStats(db, { project, days })));
969
+ return;
970
+ }
896
971
  await renderQualityReport(db, { project, days });
897
972
  return;
898
973
  }
@@ -1012,6 +1087,39 @@ async function cmdStats(db, args) {
1012
1087
  `).all(...tdParams, ...baseParams);
1013
1088
  const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
1014
1089
 
1090
+ if (jsonOutput) {
1091
+ out(JSON.stringify({
1092
+ project,
1093
+ days,
1094
+ totals: {
1095
+ observations: obsTotal.c,
1096
+ sessions: sessTotal.c,
1097
+ prompts: promptTotal.c,
1098
+ },
1099
+ recent: {
1100
+ observations: obsRecent.c,
1101
+ sessions: sessRecent.c,
1102
+ },
1103
+ type_distribution: types.map(t => ({ type: t.type, count: t.c })),
1104
+ top_projects: projects.map(p => ({ project: p.project, count: p.c })),
1105
+ daily_activity: daily.map(d => ({ day: d.day, count: d.c })),
1106
+ data_health: {
1107
+ estimated_tokens: tokenEst.t ?? 0,
1108
+ avg_importance: Number((avgImp.v ?? 1).toFixed(2)),
1109
+ low_value_count: lowVal.c,
1110
+ noise_ratio: Number(noiseRatio.toFixed(4)),
1111
+ compressed: compressedCount.c,
1112
+ superseded_only: supersededOnlyCount.c,
1113
+ },
1114
+ tier_distribution: {
1115
+ working: tierMap.working ?? 0,
1116
+ active: tierMap.active ?? 0,
1117
+ archive: tierMap.archive ?? 0,
1118
+ },
1119
+ }));
1120
+ return;
1121
+ }
1122
+
1015
1123
  out(`[mem] Stats${project ? ` (${project})` : ''}:`);
1016
1124
  out(`Total: ${obsTotal.c.toLocaleString()} observations | ${sessTotal.c} sessions | ${promptTotal.c} prompts`);
1017
1125
  out(`Last ${days}d: ${obsRecent.c} observations | ${sessRecent.c} sessions`);
@@ -1093,8 +1201,8 @@ function cmdBrowse(db, args) {
1093
1201
  fail(`[mem] Invalid tier: "${tierFilter}". Use: working, active, or archive`);
1094
1202
  return;
1095
1203
  }
1096
- const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
1097
- const limit = Number.isInteger(rawLimit) ? Math.max(1, rawLimit) : (tierFilter ? 20 : 5);
1204
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: tierFilter ? 20 : 5, max: 1000 });
1205
+ const jsonOutput = flags.json === true || flags.json === 'true';
1098
1206
  const now = Date.now();
1099
1207
 
1100
1208
  const ctx = {
@@ -1108,10 +1216,12 @@ function cmdBrowse(db, args) {
1108
1216
  const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
1109
1217
  const showTiers = tierFilter ? [tierFilter] : tiers;
1110
1218
 
1111
- out(`📊 Memory Dashboard (${project})\n`);
1112
-
1113
- let grandTotal = 0;
1219
+ // Collect data first (for JSON), then format. The text path also prints
1220
+ // tier headers as it walks; refactored to two passes so the JSON shape can
1221
+ // include row arrays alongside totals.
1222
+ const tierData = {};
1114
1223
  const tierCounts = {};
1224
+ let grandTotal = 0;
1115
1225
 
1116
1226
  for (const tier of showTiers) {
1117
1227
  const countRow = db.prepare(`
@@ -1124,24 +1234,62 @@ function cmdBrowse(db, args) {
1124
1234
  tierCounts[tier] = count;
1125
1235
  grandTotal += count;
1126
1236
 
1127
- out(`${tierLabels[tier]} (${count})`);
1128
-
1129
- if (tier === 'archive' && !tierFilter) {
1130
- if (count > 0) out('');
1237
+ // Archive in unfiltered view: keep count but skip row fetch (matches text path).
1238
+ const skipRows = tier === 'archive' && !tierFilter;
1239
+ if (count === 0 || skipRows) {
1240
+ tierData[tier] = { count, rows: [] };
1131
1241
  continue;
1132
1242
  }
1133
1243
 
1134
- if (count === 0) { out(''); continue; }
1135
-
1136
1244
  const rows = db.prepare(`
1137
1245
  SELECT * FROM (
1138
- SELECT id, type, title, created_at_epoch, ${TIER_CASE_SQL} as tier
1246
+ SELECT id, type, title, importance, created_at_epoch, created_at, ${TIER_CASE_SQL} as tier
1139
1247
  FROM observations
1140
1248
  WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
1141
1249
  ) WHERE tier = ?
1142
1250
  ORDER BY created_at_epoch DESC
1143
1251
  LIMIT ?
1144
1252
  `).all(...params, project, tier, limit);
1253
+ tierData[tier] = { count, rows };
1254
+ }
1255
+
1256
+ if (jsonOutput) {
1257
+ const tiersOut = {};
1258
+ for (const tier of showTiers) {
1259
+ tiersOut[tier] = {
1260
+ count: tierData[tier].count,
1261
+ results: tierData[tier].rows.map(r => ({
1262
+ id: r.id,
1263
+ type: r.type,
1264
+ title: r.title || null,
1265
+ importance: r.importance ?? null,
1266
+ created_at: r.created_at,
1267
+ created_at_epoch: r.created_at_epoch,
1268
+ })),
1269
+ };
1270
+ }
1271
+ out(JSON.stringify({
1272
+ project,
1273
+ limit,
1274
+ tier_filter: tierFilter,
1275
+ totals: { ...tierCounts, grand_total: grandTotal },
1276
+ tiers: tiersOut,
1277
+ }));
1278
+ return;
1279
+ }
1280
+
1281
+ out(`📊 Memory Dashboard (${project})\n`);
1282
+
1283
+ for (const tier of showTiers) {
1284
+ const { count, rows } = tierData[tier];
1285
+ out(`${tierLabels[tier]} (${count})`);
1286
+
1287
+ if (tier === 'archive' && !tierFilter) {
1288
+ if (count > 0) out('');
1289
+ continue;
1290
+ }
1291
+
1292
+ if (count === 0) { out(''); continue; }
1145
1293
 
1146
1294
  for (const r of rows) {
1147
1295
  out(` #${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${relativeTime(r.created_at_epoch)}`);
@@ -1351,8 +1499,7 @@ function cmdExport(db, args) {
1351
1499
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
1352
1500
  }
1353
1501
 
1354
- const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
1355
- const limit = Math.min(Number.isInteger(rawLimit) ? Math.max(1, rawLimit) : 200, 1000);
1502
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200, max: 1000 });
1356
1503
  const format = flags.format || 'json';
1357
1504
  if (!['json', 'jsonl'].includes(format)) {
1358
1505
  fail(`[mem] Invalid format "${format}". Use: json or jsonl`);
@@ -1829,8 +1976,7 @@ function cmdRegistry(_memDb, args) {
1829
1976
 
1830
1977
  if (action === 'list') {
1831
1978
  const typeFilter = flags.type;
1832
- const rawLimit = parseInt(flags.limit, 10);
1833
- const listLimit = Number.isInteger(rawLimit) && rawLimit > 0 ? rawLimit : 20;
1979
+ const listLimit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
1834
1980
  const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
1835
1981
  const params = typeFilter ? [typeFilter, 'active'] : ['active'];
1836
1982
  const allResources = rdb.prepare(`
@@ -1996,11 +2142,14 @@ Commands:
1996
2142
  --json Output as JSON: {query,total,returned,offset,limit,results:[…]}
1997
2143
 
1998
2144
  recent [N] Show N most recent observations (default 10)
2145
+ --limit N Sibling-parity alias for [N] (max 1000)
1999
2146
  --project P Filter by project
2147
+ --json Output as JSON: {project,limit,total,results:[…]}
2000
2148
 
2001
2149
  recall <file> Show observations related to a file
2002
2150
  --limit N Max results (default 10)
2003
2151
  --include-noise Include hook-llm fallback titles ("Modified X", raw error logs)
2152
+ --json Output as JSON: {file,limit,include_noise,total,results:[…]}
2004
2153
 
2005
2154
  get <id1,id2,...> Get full details by ID
2006
2155
  IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session).
@@ -2019,6 +2168,8 @@ Commands:
2019
2168
  --before N Show N before anchor (default 5)
2020
2169
  --after N Show N after anchor (default 5)
2021
2170
  --project P Filter by project
2171
+ --json Output as JSON: {anchor,anchor_note,before:[…],after:[…]}
2172
+ (or {anchor:null,fallback:"recent",results:[…]} when no anchor)
2022
2173
 
2023
2174
  save "<text>" Save a new observation
2024
2175
  --type T Observation type (default: discovery)
@@ -2076,6 +2227,10 @@ Commands:
2076
2227
  --days N Lookback window (default 30)
2077
2228
  --quality Quality dashboard: lesson rate, LOW_SIGNAL rate, per-type
2078
2229
  hit/lesson %, top-accessed lessons, R-2 watchdog targets
2230
+ --json Output as JSON: nested by section
2231
+ ({totals,recent,type_distribution,top_projects,
2232
+ daily_activity,data_health,tier_distribution})
2233
+ or quality shape when --quality --json combined
2079
2234
 
2080
2235
  context Show current CLAUDE.md context block
2081
2236
  --json Output as structured JSON
@@ -2084,6 +2239,9 @@ Commands:
2084
2239
  --tier T Filter: working|active|archive
2085
2240
  --project P Filter by project
2086
2241
  --limit N Max entries per tier (default 5)
2242
+ --json Output as JSON: {project,limit,tier_filter,
2243
+ totals:{working,active,archive,grand_total},
2244
+ tiers:{working:{count,results:[…]}, …}}
2087
2245
 
2088
2246
  registry <action> Manage tool resource registry
2089
2247
  list List resources [--type skill|agent] [--limit N] (default 20)
@@ -2098,6 +2256,7 @@ Commands:
2098
2256
  search "<query>" Search events [--type T] [--limit N] [--project P]
2099
2257
  recent [N] Most recent events [--type T] [--project P]
2100
2258
  show <id> Show full event row by id
2259
+ delete <id1,id2,…> Delete events by ID (preview by default; use --confirm to execute)
2101
2260
 
2102
2261
  Valid types: bugfix, lesson, bug, discovery, refactor, feature, observation, decision
2103
2262
  --files (plural, comma-split) preferred; --file (singular) kept for back-compat.
@@ -2332,7 +2491,9 @@ export async function run(argv) {
2332
2491
  // that doesn't honor it. Stdout output and exit code are unchanged so existing
2333
2492
  // text-parsing callers keep working — the note lives in stderr for scripts to
2334
2493
  // detect the gap.
2335
- const JSON_SUPPORTED_CMDS = new Set(['search', 'context']);
2494
+ const JSON_SUPPORTED_CMDS = new Set([
2495
+ 'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export',
2496
+ ]);
2336
2497
  if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd)) {
2337
2498
  process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
2338
2499
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.66.0",
3
+ "version": "2.69.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -45,6 +45,7 @@
45
45
  "adopt-cli.mjs",
46
46
  "haiku-client.mjs",
47
47
  "lib/activity.mjs",
48
+ "lib/cli-flags.mjs",
48
49
  "lib/git-state.mjs",
49
50
  "lib/task-reader.mjs",
50
51
  "lib/plan-reader.mjs",
package/source-files.mjs CHANGED
@@ -29,6 +29,7 @@ export const SOURCE_FILES = [
29
29
  // lib/ — statically imported by hook-llm.mjs (activity) + hook-handoff.mjs (git-state, task-reader);
30
30
  // dynamically imported by hook.mjs (startup-dashboard) + mem-cli.mjs (doctor-benchmark, plan-reader).
31
31
  'lib/activity.mjs',
32
+ 'lib/cli-flags.mjs',
32
33
  'lib/task-reader.mjs',
33
34
  'lib/plan-reader.mjs',
34
35
  'lib/git-state.mjs',
package/utils.mjs CHANGED
@@ -324,6 +324,48 @@ export function isSpecificTerm(token) {
324
324
  return token.length >= 4 && !/^\d+$/.test(token);
325
325
  }
326
326
 
327
+ /**
328
+ * Detect prompts whose content is purely workflow/control language with no
329
+ * subject substance — "继续", "提交代码", "/exit", "commit and push", etc.
330
+ *
331
+ * Rationale: `buildAndSaveHandoff` writes user-prompt text into `working_on`
332
+ * verbatim. When a session's only prompt is a meta-trigger, the resumed
333
+ * session sees `Working On: 继续前面的工作` — self-referential garbage. This
334
+ * detector lets the writer filter such prompts before they pollute the field.
335
+ *
336
+ * Strategy: strip a curated set of trigger keywords (zh + en) plus
337
+ * punctuation; if <4 chars of substantive content remain, the prompt is meta.
338
+ * Threshold tuned against real `user_prompts` samples — keeps prompts like
339
+ * "提交代码,发新版本,检查线上有没有错误" (real verification subject) while
340
+ * dropping bare "/exit" / "继续" / "commit".
341
+ *
342
+ * @param {string} text Prompt text
343
+ * @returns {boolean} true if the prompt is meta-trigger only
344
+ */
345
+ export function isMetaTriggerPrompt(text) {
346
+ if (!text || typeof text !== 'string') return true;
347
+ const trimmed = text.trim();
348
+ if (trimmed.length === 0) return true;
349
+
350
+ const stripped = trimmed
351
+ .replace(/继续(前面|之前|刚才|上次)?(的)?(工作|任务|话题|讨论)?/g, '')
352
+ .replace(/提交(代码|了|完|过)?(并)?(发布)?/g, '')
353
+ .replace(/退出/g, '')
354
+ .replace(/发(布|新版本|个新版本)/g, '')
355
+ .replace(/新开(了)?(一个)?(会话|session)?/g, '')
356
+ .replace(/保存(进度|状态|工作|代码)?/g, '')
357
+ .replace(/接着(干|做|来|继续)?/g, '')
358
+ .replace(/上次(到哪了|说到哪了)?/g, '')
359
+ .replace(/总结一下|复盘一下/g, '')
360
+ .replace(/前面(的)?(工作|话题|讨论|内容)/g, '')
361
+ .replace(/\/?(clear|exit)\b/gi, '')
362
+ .replace(/\b(commit|continue|resume|push|save|restart|exit|next)\b/gi, '')
363
+ .replace(/[,,。.!!??::;;()()【】[\]\s/\\-]+/g, '')
364
+ .trim();
365
+
366
+ return stripped.length < 4;
367
+ }
368
+
327
369
  /**
328
370
  * Extract match keywords from text and file paths for handoff intent matching.
329
371
  * @param {string} text Combined text from prompts, observations, etc.