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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/cli/activity.mjs +47 -0
- package/hook-context.mjs +25 -1
- package/hook-handoff.mjs +27 -3
- package/lib/cli-flags.mjs +48 -0
- package/mem-cli.mjs +195 -40
- package/package.json +2 -1
- package/source-files.mjs +1 -0
- package/utils.mjs +42 -0
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
1097
|
-
const
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
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
|
|
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
|
|
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([
|
|
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.
|
|
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.
|