claude-mem-lite 2.64.0 → 2.66.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/install.mjs +5 -0
- package/mem-cli.mjs +75 -16
- package/nlp.mjs +19 -3
- package/package.json +1 -1
- package/secret-scrub.mjs +13 -1
- package/server.mjs +6 -1
package/install.mjs
CHANGED
|
@@ -1438,6 +1438,11 @@ async function doctor() {
|
|
|
1438
1438
|
}
|
|
1439
1439
|
|
|
1440
1440
|
console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
|
|
1441
|
+
// Diagnostic-tool exit-code contract: any ✗-level finding must propagate non-zero
|
|
1442
|
+
// so CI / wrapper scripts (`claude-mem-lite doctor || alert`) actually trip. Keeps
|
|
1443
|
+
// ⚠-only states at exit 0 (#8268 already established the visual ⚠ vs counted-issue
|
|
1444
|
+
// separation; this propagates that count to the shell).
|
|
1445
|
+
if (issues > 0) process.exitCode = 1;
|
|
1441
1446
|
}
|
|
1442
1447
|
|
|
1443
1448
|
// ─── Settings helpers ───────────────────────────────────────────────────────
|
package/mem-cli.mjs
CHANGED
|
@@ -39,7 +39,12 @@ function cmdSearch(db, args) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
|
|
42
|
-
|
|
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;
|
|
43
48
|
const type = flags.type || null;
|
|
44
49
|
const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
|
|
45
50
|
if (type && !validObsTypes.has(type)) {
|
|
@@ -53,13 +58,22 @@ function cmdSearch(db, args) {
|
|
|
53
58
|
if (dateTo && flags.to && /^\d{4}-\d{2}-\d{2}$/.test(flags.to)) dateTo += 86400000 - 1;
|
|
54
59
|
if (flags.from && isNaN(dateFrom)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
55
60
|
if (flags.to && isNaN(dateTo)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
61
|
+
// Inverted range silently returns 0 rows; warn so users see the cause, don't error
|
|
62
|
+
// (a deliberate "search for nothing in this window" is not malformed input).
|
|
63
|
+
if (dateFrom !== null && dateTo !== null && dateFrom > dateTo) {
|
|
64
|
+
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
65
|
+
}
|
|
56
66
|
const minImportance = flags.importance !== undefined ? parseInt(flags.importance, 10) : null;
|
|
57
67
|
if (minImportance !== null && (isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
|
|
58
68
|
fail(`[mem] Invalid --importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
59
69
|
return;
|
|
60
70
|
}
|
|
61
71
|
const branch = flags.branch || null;
|
|
62
|
-
const
|
|
72
|
+
const rawOffset = flags.offset !== undefined ? parseInt(flags.offset, 10) : NaN;
|
|
73
|
+
if (flags.offset !== undefined && (!Number.isInteger(rawOffset) || rawOffset < 0)) {
|
|
74
|
+
process.stderr.write(`[mem] Invalid --offset "${flags.offset}" (must be a non-negative integer); using 0\n`);
|
|
75
|
+
}
|
|
76
|
+
const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
|
|
63
77
|
const tier = flags.tier || null;
|
|
64
78
|
if (tier && !['working', 'active', 'archive'].includes(tier)) {
|
|
65
79
|
fail(`[mem] Invalid --tier "${tier}". Use: working, active, archive`);
|
|
@@ -338,7 +352,9 @@ function cmdSearch(db, args) {
|
|
|
338
352
|
}
|
|
339
353
|
|
|
340
354
|
const countLabel = total > paged.length ? `${paged.length} of ${total}` : `${paged.length}`;
|
|
341
|
-
|
|
355
|
+
// Pluralize on total — "Found 1 of 44 result" reads wrong; the population (44) drives
|
|
356
|
+
// grammatical number, not the page slice (1).
|
|
357
|
+
out(`[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${query}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt)' : ''}`);
|
|
342
358
|
for (const r of paged) {
|
|
343
359
|
const timeStr = showTime && r.created_at_epoch ? ` (${relativeTime(r.created_at_epoch)})` : '';
|
|
344
360
|
if (r._source === 'session') {
|
|
@@ -406,7 +422,10 @@ function cmdRecall(db, args) {
|
|
|
406
422
|
|
|
407
423
|
const filename = basename(file);
|
|
408
424
|
const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
|
|
409
|
-
|
|
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;
|
|
410
429
|
const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
|
|
411
430
|
|
|
412
431
|
// Search via observation_files junction table for indexed filename lookups
|
|
@@ -602,8 +621,19 @@ function cmdGet(db, args) {
|
|
|
602
621
|
|
|
603
622
|
function cmdTimeline(db, args) {
|
|
604
623
|
const { positional, flags } = parseArgs(args);
|
|
605
|
-
|
|
606
|
-
|
|
624
|
+
// parseInt('-5') === -5 is truthy, so `|| 5` doesn't rescue negative input.
|
|
625
|
+
// Match cmdSearch's warn-then-default pattern for consistency across CLI flags.
|
|
626
|
+
const parseWindow = (label, raw) => {
|
|
627
|
+
if (raw === undefined) return 5;
|
|
628
|
+
const n = parseInt(raw, 10);
|
|
629
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
630
|
+
process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
|
|
631
|
+
return 5;
|
|
632
|
+
}
|
|
633
|
+
return n;
|
|
634
|
+
};
|
|
635
|
+
const before = parseWindow('before', flags.before);
|
|
636
|
+
const after = parseWindow('after', flags.after);
|
|
607
637
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
608
638
|
|
|
609
639
|
// Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
|
|
@@ -967,6 +997,9 @@ async function cmdStats(db, args) {
|
|
|
967
997
|
const compressedCount = db.prepare(
|
|
968
998
|
`SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
|
|
969
999
|
).get(...baseParams);
|
|
1000
|
+
const supersededOnlyCount = db.prepare(
|
|
1001
|
+
`SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
|
|
1002
|
+
).get(...baseParams);
|
|
970
1003
|
|
|
971
1004
|
// Tier distribution (aligned with MCP mem_stats)
|
|
972
1005
|
const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
|
|
@@ -1005,7 +1038,11 @@ async function cmdStats(db, args) {
|
|
|
1005
1038
|
out(` Compressed: ${compressedCount.c}`);
|
|
1006
1039
|
if (noiseRatio > 0.6) out(' ⚠️ High noise ratio — consider running mem compress');
|
|
1007
1040
|
out('');
|
|
1008
|
-
|
|
1041
|
+
// Tier counts only live (uncompressed, non-superseded) observations — surface the
|
|
1042
|
+
// full decomposition so live + compressed + superseded = Total adds up cleanly.
|
|
1043
|
+
const tierTotal = (tierMap.working ?? 0) + (tierMap.active ?? 0) + (tierMap.archive ?? 0);
|
|
1044
|
+
const supersededLabel = supersededOnlyCount.c > 0 ? ` + ${supersededOnlyCount.c} superseded` : '';
|
|
1045
|
+
out(`Tier distribution (live ${tierTotal}, excludes ${compressedCount.c} compressed${supersededLabel}):`);
|
|
1009
1046
|
out(` 🔴 Working: ${tierMap.working ?? 0} | 🟡 Active: ${tierMap.active ?? 0} | 🔵 Archive: ${tierMap.archive ?? 0}`);
|
|
1010
1047
|
}
|
|
1011
1048
|
|
|
@@ -1297,16 +1334,21 @@ function cmdExport(db, args) {
|
|
|
1297
1334
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
1298
1335
|
if (project) { wheres.push('project = ?'); params.push(project); }
|
|
1299
1336
|
if (flags.type) { wheres.push('type = ?'); params.push(flags.type); }
|
|
1337
|
+
let exportFromEpoch = null;
|
|
1338
|
+
let exportToEpoch = null;
|
|
1300
1339
|
if (flags.from) {
|
|
1301
|
-
|
|
1302
|
-
if (isNaN(
|
|
1303
|
-
wheres.push('created_at_epoch >= ?'); params.push(
|
|
1340
|
+
exportFromEpoch = new Date(flags.from).getTime();
|
|
1341
|
+
if (isNaN(exportFromEpoch)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
1342
|
+
wheres.push('created_at_epoch >= ?'); params.push(exportFromEpoch);
|
|
1304
1343
|
}
|
|
1305
1344
|
if (flags.to) {
|
|
1306
|
-
|
|
1307
|
-
if (isNaN(
|
|
1308
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to))
|
|
1309
|
-
wheres.push('created_at_epoch <= ?'); params.push(
|
|
1345
|
+
exportToEpoch = new Date(flags.to).getTime();
|
|
1346
|
+
if (isNaN(exportToEpoch)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
1347
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch += 86400000 - 1;
|
|
1348
|
+
wheres.push('created_at_epoch <= ?'); params.push(exportToEpoch);
|
|
1349
|
+
}
|
|
1350
|
+
if (exportFromEpoch !== null && exportToEpoch !== null && exportFromEpoch > exportToEpoch) {
|
|
1351
|
+
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
1310
1352
|
}
|
|
1311
1353
|
|
|
1312
1354
|
const rawLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
|
|
@@ -1324,7 +1366,13 @@ function cmdExport(db, args) {
|
|
|
1324
1366
|
`).all(...params, limit);
|
|
1325
1367
|
|
|
1326
1368
|
if (rows.length === 0) {
|
|
1327
|
-
|
|
1369
|
+
// Empty result must respect the requested format so `export … | jq` works:
|
|
1370
|
+
// json → "[]" (valid empty array)
|
|
1371
|
+
// jsonl → 0 lines (valid empty file)
|
|
1372
|
+
// The friendly note goes to stderr so it doesn't poison stdout for callers
|
|
1373
|
+
// piping to a parser.
|
|
1374
|
+
if (format === 'json') out('[]');
|
|
1375
|
+
process.stderr.write('[mem] No observations found matching criteria\n');
|
|
1328
1376
|
return;
|
|
1329
1377
|
}
|
|
1330
1378
|
|
|
@@ -1442,7 +1490,7 @@ function cmdMaintain(db, args) {
|
|
|
1442
1490
|
const { positional, flags } = parseArgs(args);
|
|
1443
1491
|
const action = positional[0];
|
|
1444
1492
|
if (!action || !['scan', 'execute'].includes(action)) {
|
|
1445
|
-
fail(
|
|
1493
|
+
fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,dedup,purge_stale,rebuild_vectors] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
|
|
1446
1494
|
return;
|
|
1447
1495
|
}
|
|
1448
1496
|
|
|
@@ -2278,6 +2326,17 @@ export async function run(argv) {
|
|
|
2278
2326
|
return;
|
|
2279
2327
|
}
|
|
2280
2328
|
|
|
2329
|
+
// --json contract surfacing: only `search` and `context` actually emit JSON;
|
|
2330
|
+
// historically `recent --json | jq` etc. silently produced text, breaking
|
|
2331
|
+
// automation. Emit a one-line stderr note when --json is passed to a command
|
|
2332
|
+
// that doesn't honor it. Stdout output and exit code are unchanged so existing
|
|
2333
|
+
// text-parsing callers keep working — the note lives in stderr for scripts to
|
|
2334
|
+
// detect the gap.
|
|
2335
|
+
const JSON_SUPPORTED_CMDS = new Set(['search', 'context']);
|
|
2336
|
+
if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd)) {
|
|
2337
|
+
process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2281
2340
|
try {
|
|
2282
2341
|
switch (cmd) {
|
|
2283
2342
|
case 'search': cmdSearch(db, cmdArgs); break;
|
package/nlp.mjs
CHANGED
|
@@ -117,10 +117,17 @@ export function extractCjkKeywords(text) {
|
|
|
117
117
|
export function extractCjkLikePatterns(query) {
|
|
118
118
|
if (!query || !/[\u4e00-\u9fff\u3400-\u4dbf]{2,}/.test(query)) return [];
|
|
119
119
|
const keywords = extractCjkKeywords(query);
|
|
120
|
-
// Bigrams for unmatched CJK portions
|
|
120
|
+
// Bigrams for unmatched CJK portions \u2014 but only from pure-CJK whitespace tokens.
|
|
121
|
+
// Mixed-script tokens (e.g. "xyzAbc\u4e0d\u5b58\u5728neverhit") behave as identifier-like
|
|
122
|
+
// literals; LIKE-OR'ing the CJK-suffix bigrams matches unrelated docs containing
|
|
123
|
+
// common fragments. Mirrors the FTS-side guard in sanitizeFtsQuery.
|
|
121
124
|
let remainder = query;
|
|
122
125
|
for (const w of keywords) remainder = remainder.split(w).join(' ');
|
|
123
|
-
const
|
|
126
|
+
const pureCjkOnly = remainder
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.filter(t => /[\u4e00-\u9fff\u3400-\u4dbf]/.test(t) && !/[A-Za-z0-9]/.test(t))
|
|
129
|
+
.join(' ');
|
|
130
|
+
const bigrams = pureCjkOnly ? cjkBigrams(pureCjkOnly).split(' ').filter(Boolean) : [];
|
|
124
131
|
return [...new Set([...keywords, ...bigrams])];
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -255,7 +262,16 @@ export function sanitizeFtsQuery(query) {
|
|
|
255
262
|
// Individual CJK chars ("系","统") are too noisy; bigrams ("系统") capture compound words.
|
|
256
263
|
// Skip bigrams when CJK synonym extraction already produced meaningful tokens —
|
|
257
264
|
// bigrams joined with AND would make the query too restrictive.
|
|
258
|
-
|
|
265
|
+
// Also skip for mixed-script tokens (e.g. "xyzAbc不存在neverhit"): the latin portion
|
|
266
|
+
// is already a strong literal anchor; bigramming the CJK suffix lets short fragments
|
|
267
|
+
// like "存在" match alone after AND→OR fallback, exploding recall onto unrelated docs.
|
|
268
|
+
let bigrams = null;
|
|
269
|
+
if (!cjkExtracted) {
|
|
270
|
+
const pureCjkTokens = tokens.filter(t =>
|
|
271
|
+
/[一-鿿㐀-䶿]/.test(t) && !/[A-Za-z0-9]/.test(t)
|
|
272
|
+
);
|
|
273
|
+
if (pureCjkTokens.length > 0) bigrams = cjkBigrams(pureCjkTokens.join(' '));
|
|
274
|
+
}
|
|
259
275
|
const bigramSet = new Set(bigrams ? bigrams.split(' ').filter(b => b && !CJK_STOP_WORDS.has(b)) : []);
|
|
260
276
|
const hasBigrams = bigramSet.size > 0;
|
|
261
277
|
const finalTokens = [];
|
package/package.json
CHANGED
package/secret-scrub.mjs
CHANGED
|
@@ -7,7 +7,19 @@ export const SECRET_PATTERNS = [
|
|
|
7
7
|
// Key-value assignments: password=xxx, token=xxx, api_key=xxx, secret=xxx, etc.
|
|
8
8
|
// Excludes code-like values: null, undefined, true, false, None, empty, function calls (word()),
|
|
9
9
|
// and short values (<6 chars) that are typically variable names not secrets.
|
|
10
|
-
|
|
10
|
+
//
|
|
11
|
+
// Split into two patterns so prose mentions don't get scrubbed:
|
|
12
|
+
// 1. Bare credential nouns (password|passwd|token|bearer) commonly appear in
|
|
13
|
+
// English prose — "Marker token: xyzpdq", "the bearer: alice". We require
|
|
14
|
+
// the keyword NOT to be preceded by an English-word + horizontal-space
|
|
15
|
+
// (the prose mention shape). Code/config has the keyword at start-of-line,
|
|
16
|
+
// after a separator, or in object-literal context — none of which match
|
|
17
|
+
// "letter-then-space" preceding the keyword.
|
|
18
|
+
// 2. Structured keys (api_key, auth_token, …) keep the original behavior —
|
|
19
|
+
// a separator/compound key is unambiguous config syntax even when
|
|
20
|
+
// preceded by prose ("see auth_token: shhhhhh").
|
|
21
|
+
[/((?<![A-Za-z][ \t])\b(?:password|passwd|token|bearer)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
22
|
+
[/(\b(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
11
23
|
// AWS access keys (AKIA...)
|
|
12
24
|
[/\bAKIA[A-Z0-9]{16}\b/g, '***'],
|
|
13
25
|
// OpenAI / Anthropic keys (sk-...) — specific prefixes have lower length threshold
|
package/server.mjs
CHANGED
|
@@ -1005,6 +1005,9 @@ server.registerTool(
|
|
|
1005
1005
|
const compressedCount = db.prepare(`
|
|
1006
1006
|
SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
|
|
1007
1007
|
`).get(...baseParams);
|
|
1008
|
+
const supersededOnlyCount = db.prepare(`
|
|
1009
|
+
SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}
|
|
1010
|
+
`).get(...baseParams);
|
|
1008
1011
|
|
|
1009
1012
|
// Tier distribution
|
|
1010
1013
|
const tierCtx = { now: Date.now(), currentProject: args.project || inferProject(), currentSessionId: '' };
|
|
@@ -1038,7 +1041,9 @@ server.registerTool(
|
|
|
1038
1041
|
` Compressed: ${compressedCount.c}`,
|
|
1039
1042
|
...(noiseRatio > 0.6 ? [' ⚠️ High noise ratio — consider running mem_compress'] : []),
|
|
1040
1043
|
'',
|
|
1041
|
-
|
|
1044
|
+
// Tier counts only live (uncompressed, non-superseded) observations — surface
|
|
1045
|
+
// the full decomposition so live + compressed + superseded = Total adds up cleanly.
|
|
1046
|
+
`Tier distribution (live ${(tierMap.working ?? 0) + (tierMap.active ?? 0) + (tierMap.archive ?? 0)}, excludes ${compressedCount.c} compressed${supersededOnlyCount.c > 0 ? ` + ${supersededOnlyCount.c} superseded` : ''}):`,
|
|
1042
1047
|
` 🔴 Working: ${tierMap.working ?? 0} | 🟡 Active: ${tierMap.active ?? 0} | 🔵 Archive: ${tierMap.archive ?? 0}`,
|
|
1043
1048
|
];
|
|
1044
1049
|
|