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.
- 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 +202 -41
- 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)) {
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
1097
|
-
const
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
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
|
|
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
|
|
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([
|
|
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.
|
|
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.
|