claude-mem-lite 2.66.0 → 2.68.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.68.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.68.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)) {
@@ -385,6 +380,7 @@ function cmdRecent(db, args) {
385
380
  }
386
381
  const limit = isValid ? rawLimit : 10;
387
382
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
383
+ const jsonOutput = flags.json === true || flags.json === 'true';
388
384
 
389
385
  const params = [];
390
386
  const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
@@ -392,13 +388,30 @@ function cmdRecent(db, args) {
392
388
  params.push(limit);
393
389
 
394
390
  const rows = db.prepare(`
395
- SELECT id, type, title, subtitle, created_at_epoch, created_at
391
+ SELECT id, type, title, subtitle, importance, created_at_epoch, created_at
396
392
  FROM observations
397
393
  WHERE ${wheres.join(' AND ')}
398
394
  ORDER BY created_at_epoch DESC
399
395
  LIMIT ?
400
396
  `).all(...params);
401
397
 
398
+ if (jsonOutput) {
399
+ out(JSON.stringify({
400
+ project: project || null,
401
+ limit,
402
+ total: rows.length,
403
+ results: rows.map(r => ({
404
+ id: r.id,
405
+ type: r.type,
406
+ title: r.title || r.subtitle || null,
407
+ importance: r.importance ?? null,
408
+ created_at: r.created_at,
409
+ created_at_epoch: r.created_at_epoch,
410
+ })),
411
+ }));
412
+ return;
413
+ }
414
+
402
415
  if (rows.length === 0) {
403
416
  out(`[mem] No recent observations${project ? ` (${project})` : ''}`);
404
417
  return;
@@ -416,24 +429,22 @@ function cmdRecall(db, args) {
416
429
  const { positional, flags } = parseArgs(args);
417
430
  const file = positional.join(' ');
418
431
  if (!file) {
419
- fail('[mem] Usage: claude-mem-lite recall <file> [--limit N] [--include-noise]');
432
+ fail('[mem] Usage: claude-mem-lite recall <file> [--limit N] [--include-noise] [--json]');
420
433
  return;
421
434
  }
422
435
 
423
436
  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;
437
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
429
438
  const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
439
+ const jsonOutput = flags.json === true || flags.json === 'true';
430
440
 
431
441
  // Search via observation_files junction table for indexed filename lookups
432
442
  const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
433
443
  const likePattern = `%${escaped}`;
434
444
  const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
435
445
  const rows = db.prepare(`
436
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
446
+ SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
447
+ o.created_at, o.created_at_epoch, o.project
437
448
  FROM observations o
438
449
  JOIN observation_files of2 ON of2.obs_id = o.id
439
450
  WHERE COALESCE(o.compressed_into, 0) = 0
@@ -443,18 +454,40 @@ function cmdRecall(db, args) {
443
454
  LIMIT ?
444
455
  `).all(filename, likePattern, limit);
445
456
 
457
+ if (rows.length > 0) {
458
+ // Update access_count for recalled observations (aligned with MCP mem_recall)
459
+ const recalledIds = rows.map(r => r.id);
460
+ const recallPh = recalledIds.map(() => '?').join(',');
461
+ try {
462
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
463
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
464
+ }
465
+
466
+ if (jsonOutput) {
467
+ out(JSON.stringify({
468
+ file: filename,
469
+ limit,
470
+ include_noise: includeNoise,
471
+ total: rows.length,
472
+ results: rows.map(r => ({
473
+ id: r.id,
474
+ type: r.type,
475
+ title: r.title || null,
476
+ lesson_learned: r.lesson_learned || null,
477
+ importance: r.importance ?? null,
478
+ project: r.project,
479
+ created_at: r.created_at,
480
+ created_at_epoch: r.created_at_epoch,
481
+ })),
482
+ }));
483
+ return;
484
+ }
485
+
446
486
  if (rows.length === 0) {
447
487
  out(`[mem] No history for "${filename}"`);
448
488
  return;
449
489
  }
450
490
 
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
491
  out(`[mem] History for ${filename} (${rows.length}):`);
459
492
  for (const r of rows) {
460
493
  const title = truncate(r.title || '(untitled)', 80);
@@ -635,6 +668,15 @@ function cmdTimeline(db, args) {
635
668
  const before = parseWindow('before', flags.before);
636
669
  const after = parseWindow('after', flags.after);
637
670
  const project = flags.project ? resolveProject(db, flags.project) : null;
671
+ const jsonOutput = flags.json === true || flags.json === 'true';
672
+
673
+ const toRow = r => ({
674
+ id: r.id,
675
+ type: r.type,
676
+ title: r.title || r.subtitle || null,
677
+ created_at: r.created_at,
678
+ created_at_epoch: r.created_at_epoch,
679
+ });
638
680
 
639
681
  // Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
640
682
  // For prompt/session anchors, resolve to the nearest-in-time observation so timeline semantics
@@ -745,6 +787,18 @@ function cmdTimeline(db, args) {
745
787
  LIMIT ?
746
788
  `).all(...fallbackParams);
747
789
 
790
+ if (jsonOutput) {
791
+ out(JSON.stringify({
792
+ anchor: null,
793
+ anchor_note: queryStr ? `no anchor matched query "${queryStr}"` : null,
794
+ before: [],
795
+ after: [],
796
+ fallback: 'recent',
797
+ results: rows.map(toRow),
798
+ }));
799
+ return;
800
+ }
801
+
748
802
  if (rows.length === 0) {
749
803
  out('[mem] No observations found.');
750
804
  return;
@@ -800,6 +854,16 @@ function cmdTimeline(db, args) {
800
854
  'SELECT id, type, title, subtitle, created_at, created_at_epoch FROM observations WHERE id = ?'
801
855
  ).get(anchorId);
802
856
 
857
+ if (jsonOutput) {
858
+ out(JSON.stringify({
859
+ anchor: toRow(anchor),
860
+ anchor_note: anchorNote,
861
+ before: beforeRows.reverse().map(toRow),
862
+ after: afterRows.map(toRow),
863
+ }));
864
+ return;
865
+ }
866
+
803
867
  const all = [...beforeRows.reverse(), anchor, ...afterRows];
804
868
 
805
869
  out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
@@ -887,12 +951,18 @@ async function renderQualityReport(db, { project, days }) {
887
951
  async function cmdStats(db, args) {
888
952
  const { flags } = parseArgs(args);
889
953
  const project = flags.project ? resolveProject(db, flags.project) : null;
890
- const days = parseInt(flags.days, 10) || 30;
954
+ const days = parseIntFlag(flags.days, { name: '--days', defaultValue: 30, max: 3650 });
955
+ const jsonOutput = flags.json === true || flags.json === 'true';
891
956
  // N-1: --quality routes to a separate quality-focused report (lesson rate,
892
957
  // LOW_SIGNAL rate, per-type hit+lesson %, R-2 watchdog targets). Intended as
893
958
  // the baseline metric dashboard for the future Haiku prompt A/B test.
894
959
  const quality = flags.quality === true || flags.quality === 'true';
895
960
  if (quality) {
961
+ if (jsonOutput) {
962
+ const { computeQualityStats } = await import('./lib/stats-quality.mjs');
963
+ out(JSON.stringify(computeQualityStats(db, { project, days })));
964
+ return;
965
+ }
896
966
  await renderQualityReport(db, { project, days });
897
967
  return;
898
968
  }
@@ -1012,6 +1082,39 @@ async function cmdStats(db, args) {
1012
1082
  `).all(...tdParams, ...baseParams);
1013
1083
  const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
1014
1084
 
1085
+ if (jsonOutput) {
1086
+ out(JSON.stringify({
1087
+ project,
1088
+ days,
1089
+ totals: {
1090
+ observations: obsTotal.c,
1091
+ sessions: sessTotal.c,
1092
+ prompts: promptTotal.c,
1093
+ },
1094
+ recent: {
1095
+ observations: obsRecent.c,
1096
+ sessions: sessRecent.c,
1097
+ },
1098
+ type_distribution: types.map(t => ({ type: t.type, count: t.c })),
1099
+ top_projects: projects.map(p => ({ project: p.project, count: p.c })),
1100
+ daily_activity: daily.map(d => ({ day: d.day, count: d.c })),
1101
+ data_health: {
1102
+ estimated_tokens: tokenEst.t ?? 0,
1103
+ avg_importance: Number((avgImp.v ?? 1).toFixed(2)),
1104
+ low_value_count: lowVal.c,
1105
+ noise_ratio: Number(noiseRatio.toFixed(4)),
1106
+ compressed: compressedCount.c,
1107
+ superseded_only: supersededOnlyCount.c,
1108
+ },
1109
+ tier_distribution: {
1110
+ working: tierMap.working ?? 0,
1111
+ active: tierMap.active ?? 0,
1112
+ archive: tierMap.archive ?? 0,
1113
+ },
1114
+ }));
1115
+ return;
1116
+ }
1117
+
1015
1118
  out(`[mem] Stats${project ? ` (${project})` : ''}:`);
1016
1119
  out(`Total: ${obsTotal.c.toLocaleString()} observations | ${sessTotal.c} sessions | ${promptTotal.c} prompts`);
1017
1120
  out(`Last ${days}d: ${obsRecent.c} observations | ${sessRecent.c} sessions`);
@@ -1093,8 +1196,8 @@ function cmdBrowse(db, args) {
1093
1196
  fail(`[mem] Invalid tier: "${tierFilter}". Use: working, active, or archive`);
1094
1197
  return;
1095
1198
  }
1096
- const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
1097
- const limit = Number.isInteger(rawLimit) ? Math.max(1, rawLimit) : (tierFilter ? 20 : 5);
1199
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: tierFilter ? 20 : 5, max: 1000 });
1200
+ const jsonOutput = flags.json === true || flags.json === 'true';
1098
1201
  const now = Date.now();
1099
1202
 
1100
1203
  const ctx = {
@@ -1108,10 +1211,12 @@ function cmdBrowse(db, args) {
1108
1211
  const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
1109
1212
  const showTiers = tierFilter ? [tierFilter] : tiers;
1110
1213
 
1111
- out(`📊 Memory Dashboard (${project})\n`);
1112
-
1113
- let grandTotal = 0;
1214
+ // Collect data first (for JSON), then format. The text path also prints
1215
+ // tier headers as it walks; refactored to two passes so the JSON shape can
1216
+ // include row arrays alongside totals.
1217
+ const tierData = {};
1114
1218
  const tierCounts = {};
1219
+ let grandTotal = 0;
1115
1220
 
1116
1221
  for (const tier of showTiers) {
1117
1222
  const countRow = db.prepare(`
@@ -1124,24 +1229,62 @@ function cmdBrowse(db, args) {
1124
1229
  tierCounts[tier] = count;
1125
1230
  grandTotal += count;
1126
1231
 
1127
- out(`${tierLabels[tier]} (${count})`);
1128
-
1129
- if (tier === 'archive' && !tierFilter) {
1130
- if (count > 0) out('');
1232
+ // Archive in unfiltered view: keep count but skip row fetch (matches text path).
1233
+ const skipRows = tier === 'archive' && !tierFilter;
1234
+ if (count === 0 || skipRows) {
1235
+ tierData[tier] = { count, rows: [] };
1131
1236
  continue;
1132
1237
  }
1133
1238
 
1134
- if (count === 0) { out(''); continue; }
1135
-
1136
1239
  const rows = db.prepare(`
1137
1240
  SELECT * FROM (
1138
- SELECT id, type, title, created_at_epoch, ${TIER_CASE_SQL} as tier
1241
+ SELECT id, type, title, importance, created_at_epoch, created_at, ${TIER_CASE_SQL} as tier
1139
1242
  FROM observations
1140
1243
  WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
1141
1244
  ) WHERE tier = ?
1142
1245
  ORDER BY created_at_epoch DESC
1143
1246
  LIMIT ?
1144
1247
  `).all(...params, project, tier, limit);
1248
+ tierData[tier] = { count, rows };
1249
+ }
1250
+
1251
+ if (jsonOutput) {
1252
+ const tiersOut = {};
1253
+ for (const tier of showTiers) {
1254
+ tiersOut[tier] = {
1255
+ count: tierData[tier].count,
1256
+ results: tierData[tier].rows.map(r => ({
1257
+ id: r.id,
1258
+ type: r.type,
1259
+ title: r.title || null,
1260
+ importance: r.importance ?? null,
1261
+ created_at: r.created_at,
1262
+ created_at_epoch: r.created_at_epoch,
1263
+ })),
1264
+ };
1265
+ }
1266
+ out(JSON.stringify({
1267
+ project,
1268
+ limit,
1269
+ tier_filter: tierFilter,
1270
+ totals: { ...tierCounts, grand_total: grandTotal },
1271
+ tiers: tiersOut,
1272
+ }));
1273
+ return;
1274
+ }
1275
+
1276
+ out(`📊 Memory Dashboard (${project})\n`);
1277
+
1278
+ for (const tier of showTiers) {
1279
+ const { count, rows } = tierData[tier];
1280
+ out(`${tierLabels[tier]} (${count})`);
1281
+
1282
+ if (tier === 'archive' && !tierFilter) {
1283
+ if (count > 0) out('');
1284
+ continue;
1285
+ }
1286
+
1287
+ if (count === 0) { out(''); continue; }
1145
1288
 
1146
1289
  for (const r of rows) {
1147
1290
  out(` #${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${relativeTime(r.created_at_epoch)}`);
@@ -1351,8 +1494,7 @@ function cmdExport(db, args) {
1351
1494
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
1352
1495
  }
1353
1496
 
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);
1497
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200, max: 1000 });
1356
1498
  const format = flags.format || 'json';
1357
1499
  if (!['json', 'jsonl'].includes(format)) {
1358
1500
  fail(`[mem] Invalid format "${format}". Use: json or jsonl`);
@@ -1829,8 +1971,7 @@ function cmdRegistry(_memDb, args) {
1829
1971
 
1830
1972
  if (action === 'list') {
1831
1973
  const typeFilter = flags.type;
1832
- const rawLimit = parseInt(flags.limit, 10);
1833
- const listLimit = Number.isInteger(rawLimit) && rawLimit > 0 ? rawLimit : 20;
1974
+ const listLimit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
1834
1975
  const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
1835
1976
  const params = typeFilter ? [typeFilter, 'active'] : ['active'];
1836
1977
  const allResources = rdb.prepare(`
@@ -1997,10 +2138,12 @@ Commands:
1997
2138
 
1998
2139
  recent [N] Show N most recent observations (default 10)
1999
2140
  --project P Filter by project
2141
+ --json Output as JSON: {project,limit,total,results:[…]}
2000
2142
 
2001
2143
  recall <file> Show observations related to a file
2002
2144
  --limit N Max results (default 10)
2003
2145
  --include-noise Include hook-llm fallback titles ("Modified X", raw error logs)
2146
+ --json Output as JSON: {file,limit,include_noise,total,results:[…]}
2004
2147
 
2005
2148
  get <id1,id2,...> Get full details by ID
2006
2149
  IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session).
@@ -2019,6 +2162,8 @@ Commands:
2019
2162
  --before N Show N before anchor (default 5)
2020
2163
  --after N Show N after anchor (default 5)
2021
2164
  --project P Filter by project
2165
+ --json Output as JSON: {anchor,anchor_note,before:[…],after:[…]}
2166
+ (or {anchor:null,fallback:"recent",results:[…]} when no anchor)
2022
2167
 
2023
2168
  save "<text>" Save a new observation
2024
2169
  --type T Observation type (default: discovery)
@@ -2076,6 +2221,10 @@ Commands:
2076
2221
  --days N Lookback window (default 30)
2077
2222
  --quality Quality dashboard: lesson rate, LOW_SIGNAL rate, per-type
2078
2223
  hit/lesson %, top-accessed lessons, R-2 watchdog targets
2224
+ --json Output as JSON: nested by section
2225
+ ({totals,recent,type_distribution,top_projects,
2226
+ daily_activity,data_health,tier_distribution})
2227
+ or quality shape when --quality --json combined
2079
2228
 
2080
2229
  context Show current CLAUDE.md context block
2081
2230
  --json Output as structured JSON
@@ -2084,6 +2233,9 @@ Commands:
2084
2233
  --tier T Filter: working|active|archive
2085
2234
  --project P Filter by project
2086
2235
  --limit N Max entries per tier (default 5)
2236
+ --json Output as JSON: {project,limit,tier_filter,
2237
+ totals:{working,active,archive,grand_total},
2238
+ tiers:{working:{count,results:[…]}, …}}
2087
2239
 
2088
2240
  registry <action> Manage tool resource registry
2089
2241
  list List resources [--type skill|agent] [--limit N] (default 20)
@@ -2098,6 +2250,7 @@ Commands:
2098
2250
  search "<query>" Search events [--type T] [--limit N] [--project P]
2099
2251
  recent [N] Most recent events [--type T] [--project P]
2100
2252
  show <id> Show full event row by id
2253
+ delete <id1,id2,…> Delete events by ID (preview by default; use --confirm to execute)
2101
2254
 
2102
2255
  Valid types: bugfix, lesson, bug, discovery, refactor, feature, observation, decision
2103
2256
  --files (plural, comma-split) preferred; --file (singular) kept for back-compat.
@@ -2332,7 +2485,9 @@ export async function run(argv) {
2332
2485
  // that doesn't honor it. Stdout output and exit code are unchanged so existing
2333
2486
  // text-parsing callers keep working — the note lives in stderr for scripts to
2334
2487
  // detect the gap.
2335
- const JSON_SUPPORTED_CMDS = new Set(['search', 'context']);
2488
+ const JSON_SUPPORTED_CMDS = new Set([
2489
+ 'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export',
2490
+ ]);
2336
2491
  if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd)) {
2337
2492
  process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
2338
2493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.66.0",
3
+ "version": "2.68.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.