claude-mem-lite 2.40.0 → 2.41.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 +113 -0
- package/cli/common.mjs +105 -0
- package/cli/doctor.mjs +41 -0
- package/cli/fts-check.mjs +34 -0
- package/cli.mjs +4 -3
- package/hook-memory.mjs +59 -7
- package/lib/err-sampler.mjs +65 -0
- package/lib/metrics.mjs +161 -0
- package/mem-cli.mjs +11 -227
- package/package.json +8 -1
- package/schema.mjs +66 -13
- package/server/fts-check.mjs +33 -0
- package/server.mjs +4 -17
- package/source-files.mjs +8 -0
- package/utils.mjs +19 -1
package/cli/activity.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// cli/activity.mjs — `claude-mem-lite activity <save|search|recent|show>`.
|
|
2
|
+
// Extracted from mem-cli.mjs (v2.41, god-module split). Thin wrapper over
|
|
3
|
+
// lib/activity.mjs pure functions.
|
|
4
|
+
//
|
|
5
|
+
// Events namespace is separate from observations (schema.mjs v2.31 T6): the
|
|
6
|
+
// activity table stores bugfix/lesson/bug/discovery/refactor/feature/observation/
|
|
7
|
+
// decision events with their own FTS5 index. All mutations go through
|
|
8
|
+
// lib/activity.mjs::saveEvent, which enforces the type CHECK and populates
|
|
9
|
+
// events_fts via triggers.
|
|
10
|
+
|
|
11
|
+
import { inferProject } from '../utils.mjs';
|
|
12
|
+
import { resolveProject } from '../project-utils.mjs';
|
|
13
|
+
import { parseArgs, out, fail } from './common.mjs';
|
|
14
|
+
|
|
15
|
+
function formatActivityResults(rows) {
|
|
16
|
+
if (!rows || rows.length === 0) return '(no events)';
|
|
17
|
+
return rows.map(r => `#${r.id} [${r.event_type}] ${r.title}`).join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function cmdActivity(db, args) {
|
|
21
|
+
const sub = args[0];
|
|
22
|
+
if (!sub) {
|
|
23
|
+
fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show> ...');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { positional, flags } = parseArgs(args.slice(1));
|
|
28
|
+
const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES } = await import('../lib/activity.mjs');
|
|
29
|
+
const VALID_EVENT_TYPES = new Set(EVENT_TYPES);
|
|
30
|
+
const project = flags.project ? resolveProject(db, flags.project) : inferProject();
|
|
31
|
+
|
|
32
|
+
if (sub === 'save') {
|
|
33
|
+
const type = flags.type || 'observation';
|
|
34
|
+
if (!VALID_EVENT_TYPES.has(type)) {
|
|
35
|
+
fail(`[mem] activity save: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const title = flags.title || positional.join(' ').trim();
|
|
39
|
+
if (!title) {
|
|
40
|
+
fail('[mem] activity save: --title or positional text required');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const body = flags.body || null;
|
|
44
|
+
// Accept both --file (singular, backward compat) and --files (plural,
|
|
45
|
+
// comma-split, preferred — matches cmdSave). Merge both sources.
|
|
46
|
+
const filesFromPlural = flags.files && typeof flags.files === 'string'
|
|
47
|
+
? flags.files.split(',').map(s => s.trim()).filter(Boolean)
|
|
48
|
+
: [];
|
|
49
|
+
const filesFromSingular = flags.file && typeof flags.file === 'string' ? [flags.file] : [];
|
|
50
|
+
const file_paths_merged = [...filesFromSingular, ...filesFromPlural];
|
|
51
|
+
const file_paths = file_paths_merged.length > 0 ? file_paths_merged : null;
|
|
52
|
+
const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
|
|
53
|
+
if (flags.importance !== undefined && (isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
|
|
54
|
+
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const id = saveEvent(db, {
|
|
58
|
+
project,
|
|
59
|
+
event_type: type,
|
|
60
|
+
title,
|
|
61
|
+
body,
|
|
62
|
+
importance: rawImp,
|
|
63
|
+
file_paths,
|
|
64
|
+
});
|
|
65
|
+
out(JSON.stringify({ ok: true, id }));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (sub === 'search') {
|
|
70
|
+
const q = positional.join(' ');
|
|
71
|
+
if (!q) {
|
|
72
|
+
fail('[mem] activity search: query required');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const type = flags.type || null;
|
|
76
|
+
if (type !== null && !VALID_EVENT_TYPES.has(type)) {
|
|
77
|
+
fail(`[mem] activity search: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const limit = flags.limit !== undefined ? parseInt(flags.limit, 10) : 10;
|
|
81
|
+
const rows = searchEvents(db, q, { project, type, limit });
|
|
82
|
+
out(formatActivityResults(rows));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (sub === 'recent') {
|
|
87
|
+
// Accept either `activity recent 5` or `activity recent --limit 5`.
|
|
88
|
+
const posLimit = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
|
|
89
|
+
const flagLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
|
|
90
|
+
const limit = Number.isFinite(posLimit) ? posLimit : (Number.isFinite(flagLimit) ? flagLimit : 20);
|
|
91
|
+
const type = flags.type || null;
|
|
92
|
+
if (type !== null && !VALID_EVENT_TYPES.has(type)) {
|
|
93
|
+
fail(`[mem] activity recent: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const rows = recentEvents(db, { project, type, limit });
|
|
97
|
+
out(formatActivityResults(rows));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (sub === 'show') {
|
|
102
|
+
const id = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
|
|
103
|
+
if (!Number.isFinite(id)) {
|
|
104
|
+
fail('[mem] activity show: numeric id required');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const row = getEvent(db, id);
|
|
108
|
+
out(row ? JSON.stringify(row, null, 2) : 'Not found');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fail(`[mem] Unknown activity subcommand: ${sub}`);
|
|
113
|
+
}
|
package/cli/common.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// cli/common.mjs — shared helpers used by every per-command file under cli/.
|
|
2
|
+
// Extracted from mem-cli.mjs (v2.41) as first step in the god-module split.
|
|
3
|
+
//
|
|
4
|
+
// Scope: pure utilities only. No DB, no imports from other cli/ files. This
|
|
5
|
+
// module is the single source of truth for stdout/stderr framing, arg parsing,
|
|
6
|
+
// ID-token parsing, and relative-time formatting — every command imports from
|
|
7
|
+
// here so the CLI stays consistent.
|
|
8
|
+
|
|
9
|
+
// ─── Argument Parsing ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse argv-style array into { positional, flags }.
|
|
13
|
+
* `--key value` → flags.key = value; `--flag` (no value) → flags.key = true.
|
|
14
|
+
* `-h` → flags.help = true.
|
|
15
|
+
*/
|
|
16
|
+
export function parseArgs(argv) {
|
|
17
|
+
const positional = [];
|
|
18
|
+
const flags = {};
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < argv.length) {
|
|
21
|
+
const arg = argv[i];
|
|
22
|
+
if (arg.startsWith('--')) {
|
|
23
|
+
const key = arg.slice(2);
|
|
24
|
+
const next = argv[i + 1];
|
|
25
|
+
if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
26
|
+
flags[key] = next;
|
|
27
|
+
i += 2;
|
|
28
|
+
} else {
|
|
29
|
+
flags[key] = true;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
} else if (arg === '-h') {
|
|
33
|
+
flags.help = true;
|
|
34
|
+
i++;
|
|
35
|
+
} else {
|
|
36
|
+
positional.push(arg);
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { positional, flags };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Output Helpers ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Write a line to stdout. */
|
|
46
|
+
export function out(text) {
|
|
47
|
+
process.stdout.write(text + '\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write a line to stderr and mark process for non-zero exit. */
|
|
51
|
+
export function fail(text) {
|
|
52
|
+
process.stderr.write(text + '\n');
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Time Formatting ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** "just now" / "5m ago" / "3h ago" / "2d ago" relative to now. */
|
|
59
|
+
export function relativeTime(epochMs) {
|
|
60
|
+
const diff = Date.now() - epochMs;
|
|
61
|
+
if (diff < 0) return 'just now';
|
|
62
|
+
const mins = Math.floor(diff / 60000);
|
|
63
|
+
if (mins < 1) return 'just now';
|
|
64
|
+
if (mins < 60) return `${mins}m ago`;
|
|
65
|
+
const hours = Math.floor(mins / 60);
|
|
66
|
+
if (hours < 24) return `${hours}h ago`;
|
|
67
|
+
const days = Math.floor(hours / 24);
|
|
68
|
+
return `${days}d ago`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** ISO date string → "YYYY-MM-DD" prefix. */
|
|
72
|
+
export function fmtDateShort(iso) {
|
|
73
|
+
if (!iso) return '';
|
|
74
|
+
return iso.slice(0, 10);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── ID Token Parsing ────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse an ID token from a command positional argument.
|
|
81
|
+
* Accepts: `123`, `#123`, `P#123` / `p123` (prompt), `S#123` / `s123` (session).
|
|
82
|
+
* @returns {{ source: 'obs'|'session'|'prompt'|null, id: number } | null}
|
|
83
|
+
* source===null means no explicit prefix — caller picks default (typically 'obs').
|
|
84
|
+
*/
|
|
85
|
+
export function parseIdToken(raw) {
|
|
86
|
+
const m = /^([PpSs]?)#?(\d+)$/.exec(String(raw).trim());
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const p = m[1].toUpperCase();
|
|
89
|
+
const id = parseInt(m[2], 10);
|
|
90
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
91
|
+
const source = p === 'P' ? 'prompt' : p === 'S' ? 'session' : null;
|
|
92
|
+
return { source, id };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format the shared `probeIdSources` output as CLI hint strings.
|
|
97
|
+
* Example: ["#5419 (obs)", "P#5417 (prompt)"] — callers join with "; ".
|
|
98
|
+
*/
|
|
99
|
+
export function formatProbeHints(probe) {
|
|
100
|
+
const hints = [];
|
|
101
|
+
if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs)`);
|
|
102
|
+
if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session)`);
|
|
103
|
+
if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt)`);
|
|
104
|
+
return hints;
|
|
105
|
+
}
|
package/cli/doctor.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// cli/doctor.mjs — `claude-mem-lite doctor --benchmark|--metrics`.
|
|
2
|
+
// Extracted from mem-cli.mjs (v2.41, god-module split).
|
|
3
|
+
//
|
|
4
|
+
// `doctor` without flags is handled upstream by cli.mjs (routed to install.mjs
|
|
5
|
+
// for install health checks). With --benchmark or --metrics it is routed to
|
|
6
|
+
// mem-cli which delegates to this handler.
|
|
7
|
+
|
|
8
|
+
import { inferProject } from '../utils.mjs';
|
|
9
|
+
import { out } from './common.mjs';
|
|
10
|
+
|
|
11
|
+
export async function cmdDoctor(db, args) {
|
|
12
|
+
if (args.includes('--benchmark')) {
|
|
13
|
+
const { runBenchmark } = await import('../lib/doctor-benchmark.mjs');
|
|
14
|
+
const project = inferProject();
|
|
15
|
+
const result = runBenchmark(db, { project });
|
|
16
|
+
out(JSON.stringify(result, null, 2));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (args.includes('--metrics')) {
|
|
20
|
+
// v2.41: aggregate CLAUDE_MEM_METRICS=1 JSONL rows from last N days.
|
|
21
|
+
// Read-side has no env gate — you can inspect whatever was recorded even
|
|
22
|
+
// when metrics are currently off. Default window 7 days; --days N override.
|
|
23
|
+
const { aggregateMetrics, formatSummary, DEFAULT_WINDOW_DAYS } = await import('../lib/metrics.mjs');
|
|
24
|
+
const { DB_DIR } = await import('../schema.mjs');
|
|
25
|
+
const daysIdx = args.indexOf('--days');
|
|
26
|
+
let days = DEFAULT_WINDOW_DAYS;
|
|
27
|
+
if (daysIdx >= 0 && args[daysIdx + 1]) {
|
|
28
|
+
const parsed = parseInt(args[daysIdx + 1], 10);
|
|
29
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 90) days = parsed;
|
|
30
|
+
}
|
|
31
|
+
const agg = aggregateMetrics(DB_DIR, days);
|
|
32
|
+
if (args.includes('--json')) {
|
|
33
|
+
out(JSON.stringify(agg, null, 2));
|
|
34
|
+
} else {
|
|
35
|
+
out(formatSummary(agg, days));
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
out('[mem] doctor: supported flags: --benchmark, --metrics [--days N] [--json]');
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// cli/fts-check.mjs — `claude-mem-lite fts-check <check|rebuild>`.
|
|
2
|
+
// Extracted from mem-cli.mjs (v2.41, god-module split).
|
|
3
|
+
|
|
4
|
+
import { checkFTSIntegrity, rebuildFTS } from '../schema.mjs';
|
|
5
|
+
import { parseArgs, out, fail } from './common.mjs';
|
|
6
|
+
|
|
7
|
+
export function cmdFtsCheck(db, args) {
|
|
8
|
+
const { positional } = parseArgs(args);
|
|
9
|
+
const action = positional[0];
|
|
10
|
+
if (!action || !['check', 'rebuild'].includes(action)) {
|
|
11
|
+
fail('[mem] Usage: mem fts-check <check|rebuild>');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (action === 'check') {
|
|
16
|
+
const result = checkFTSIntegrity(db);
|
|
17
|
+
if (result.healthy) {
|
|
18
|
+
out('[mem] FTS5 indexes are healthy — all integrity checks passed.');
|
|
19
|
+
} else {
|
|
20
|
+
out(`[mem] FTS5 issues found:`);
|
|
21
|
+
for (const d of result.details) out(` ${d}`);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (action === 'rebuild') {
|
|
27
|
+
const result = rebuildFTS(db);
|
|
28
|
+
if (result.errors.length > 0) {
|
|
29
|
+
out(`[mem] Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`);
|
|
30
|
+
} else {
|
|
31
|
+
out(`[mem] Successfully rebuilt: ${result.rebuilt.join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -13,9 +13,10 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
13
13
|
} else if (cmd === '--help' || cmd === '-h') {
|
|
14
14
|
const { run } = await import('./mem-cli.mjs');
|
|
15
15
|
await run(['help']);
|
|
16
|
-
} else if (cmd === 'doctor' && process.argv.slice(3).includes('--benchmark')) {
|
|
17
|
-
// doctor --benchmark
|
|
18
|
-
//
|
|
16
|
+
} else if (cmd === 'doctor' && (process.argv.slice(3).includes('--benchmark') || process.argv.slice(3).includes('--metrics'))) {
|
|
17
|
+
// doctor --benchmark / --metrics are DB/metrics inspection tools — routed
|
|
18
|
+
// through mem-cli (DB layer). Plain `doctor` continues to run the install
|
|
19
|
+
// health-check below.
|
|
19
20
|
const { run } = await import('./mem-cli.mjs');
|
|
20
21
|
await run(process.argv.slice(2));
|
|
21
22
|
} else if (CLI_COMMANDS.has(cmd)) {
|
package/hook-memory.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Search past observations for relevant memories to inject as context at user-prompt time.
|
|
3
3
|
|
|
4
4
|
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
|
|
5
|
+
import { recordMetric } from './lib/metrics.mjs';
|
|
6
|
+
import { DB_DIR } from './schema.mjs';
|
|
5
7
|
|
|
6
8
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
7
9
|
const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
|
|
@@ -29,6 +31,17 @@ function getCoverageThreshold() {
|
|
|
29
31
|
const n = parseFloat(raw);
|
|
30
32
|
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
|
|
31
33
|
}
|
|
34
|
+
|
|
35
|
+
// v2.41: cross-project boost (applied to decisions/discoveries from other
|
|
36
|
+
// projects). Default 0.7 = 30% penalty vs same-project hits — tuned for multi-
|
|
37
|
+
// project installs where transferable insights are the minority of matches.
|
|
38
|
+
// Env override `MEM_CROSS_PROJECT_BOOST` ∈ [0, 1]; clamped, invalid → default.
|
|
39
|
+
function getCrossProjectBoost() {
|
|
40
|
+
const raw = process.env.MEM_CROSS_PROJECT_BOOST;
|
|
41
|
+
if (raw === undefined || raw === '') return 0.7;
|
|
42
|
+
const n = parseFloat(raw);
|
|
43
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.7;
|
|
44
|
+
}
|
|
32
45
|
function extractQueryTerms(text) {
|
|
33
46
|
if (!text) return [];
|
|
34
47
|
const ascii = tokenizeHandoff(text).filter(t => !HANDOFF_STOP_WORDS.has(t));
|
|
@@ -36,9 +49,18 @@ function extractQueryTerms(text) {
|
|
|
36
49
|
try { cjk = extractCjkKeywords(text) || []; } catch { /* CJK extraction best-effort */ }
|
|
37
50
|
return [...new Set([...ascii, ...cjk.map(t => String(t).toLowerCase())])];
|
|
38
51
|
}
|
|
52
|
+
// v2.41: hay spans every FTS column whose BM25 weight is >=5 in OBS_BM25
|
|
53
|
+
// (title=10, subtitle=5, narrative=5, lesson_learned=8). Pre-v2.41 was only
|
|
54
|
+
// title + lesson_learned — rows that matched on narrative but happened to
|
|
55
|
+
// omit the term from title/lesson were dropped by the 0.4 threshold even
|
|
56
|
+
// though FTS ranked them strongly. Narrative is clipped to its first 400 chars
|
|
57
|
+
// because coverage is a membership check, not a frequency count; the tail
|
|
58
|
+
// rarely adds new terms and the worst-case string concatenation stays small.
|
|
59
|
+
const COVERAGE_NARRATIVE_PREFIX = 400;
|
|
39
60
|
function candidateCoverage(row, queryTerms) {
|
|
40
61
|
if (queryTerms.length === 0) return 1.0;
|
|
41
|
-
const
|
|
62
|
+
const narrativeHead = (row.narrative || '').slice(0, COVERAGE_NARRATIVE_PREFIX);
|
|
63
|
+
const hay = `${row.title || ''} ${row.subtitle || ''} ${row.lesson_learned || ''} ${narrativeHead}`.toLowerCase();
|
|
42
64
|
let hits = 0;
|
|
43
65
|
for (const t of queryTerms) {
|
|
44
66
|
if (/[^ -~]/.test(t)) {
|
|
@@ -68,6 +90,23 @@ const MAX_FILE_RECALL = 2;
|
|
|
68
90
|
export function searchRelevantMemories(db, userPrompt, project, excludeIds = []) {
|
|
69
91
|
if (!db || !userPrompt || userPrompt.length < 5) return [];
|
|
70
92
|
|
|
93
|
+
// v2.41 metrics: record timing + candidate/filter/return counts per call.
|
|
94
|
+
// Gated by CLAUDE_MEM_METRICS=1 — no-op when disabled (zero hot-path cost).
|
|
95
|
+
const _t0 = Date.now();
|
|
96
|
+
let _candidates = 0, _aboveThreshold = 0, _returned = 0, _orFired = false;
|
|
97
|
+
const _emit = () => {
|
|
98
|
+
try {
|
|
99
|
+
recordMetric(DB_DIR, {
|
|
100
|
+
event: 'inject',
|
|
101
|
+
durationMs: Date.now() - _t0,
|
|
102
|
+
candidates: _candidates,
|
|
103
|
+
aboveThreshold: _aboveThreshold,
|
|
104
|
+
returned: _returned,
|
|
105
|
+
orFallback: _orFired,
|
|
106
|
+
});
|
|
107
|
+
} catch { /* metric record must not crash the caller */ }
|
|
108
|
+
};
|
|
109
|
+
|
|
71
110
|
try {
|
|
72
111
|
const ftsQuery = sanitizeFtsQuery(userPrompt);
|
|
73
112
|
if (!ftsQuery) return [];
|
|
@@ -84,7 +123,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
84
123
|
// in JS (scored.sort). SELECT exposes both raw BM25 (for sort) and the
|
|
85
124
|
// penalty factor (for the final JS score).
|
|
86
125
|
const selectStmt = db.prepare(`
|
|
87
|
-
SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
|
|
126
|
+
SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
|
|
88
127
|
${OBS_BM25} as relevance,
|
|
89
128
|
${noisePenaltyClause('o')} as noise_penalty
|
|
90
129
|
FROM observations_fts
|
|
@@ -121,7 +160,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
121
160
|
let crossUsedOr = false;
|
|
122
161
|
try {
|
|
123
162
|
const crossStmt = db.prepare(`
|
|
124
|
-
SELECT o.id, o.type, o.title, o.importance, o.lesson_learned, o.project,
|
|
163
|
+
SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
|
|
125
164
|
${OBS_BM25} as relevance,
|
|
126
165
|
${noisePenaltyClause('o')} as noise_penalty
|
|
127
166
|
FROM observations_fts
|
|
@@ -146,14 +185,21 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
146
185
|
}
|
|
147
186
|
} catch (e) { debugCatch(e, 'crossProjectSearch'); }
|
|
148
187
|
|
|
149
|
-
// Merge and score: same-project full weight, cross-project 0.7x
|
|
188
|
+
// Merge and score: same-project full weight, cross-project (default 0.7x).
|
|
189
|
+
// v2.41: cross-project penalty is env-overridable via MEM_CROSS_PROJECT_BOOST
|
|
190
|
+
// (0..1). Default 0.7 — tuned for typical multi-project installs where
|
|
191
|
+
// transferable decisions/discoveries are a minority of matches. Set to 1.0
|
|
192
|
+
// for single-project users (no effective penalty); set lower to tighten
|
|
193
|
+
// same-project focus in noisy cross-project environments.
|
|
194
|
+
//
|
|
150
195
|
// OR-fallback results get 0.4x penalty — they matched individual words, not the full intent
|
|
151
196
|
// v26 P0: noise_penalty (from SQL) shrinks high-inject/low-cite rows.
|
|
197
|
+
const crossPenalty = getCrossProjectBoost();
|
|
152
198
|
const allRows = [...rows.map(r => ({ ...r, _or: usedOrFallback })), ...crossRows.map(r => ({ ...r, _or: crossUsedOr }))];
|
|
153
199
|
const scored = allRows
|
|
154
200
|
.filter(r => !excludeSet.has(r.id))
|
|
155
201
|
.map(r => {
|
|
156
|
-
const crossProjectPenalty = r.project === project ? 1.0 :
|
|
202
|
+
const crossProjectPenalty = r.project === project ? 1.0 : crossPenalty;
|
|
157
203
|
const orFallbackPenalty = r._or ? 0.4 : 1.0;
|
|
158
204
|
const noisePenalty = typeof r.noise_penalty === 'number' ? r.noise_penalty : 1.0;
|
|
159
205
|
return {
|
|
@@ -176,8 +222,11 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
176
222
|
).get(project)?.c || 0;
|
|
177
223
|
const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
|
|
178
224
|
const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
|
|
225
|
+
_candidates = scored.length;
|
|
226
|
+
_orFired = usedOrFallback || crossUsedOr;
|
|
179
227
|
const aboveThreshold = scored.filter(r => r.score >= threshold);
|
|
180
|
-
|
|
228
|
+
_aboveThreshold = aboveThreshold.length;
|
|
229
|
+
if (aboveThreshold.length === 0) { _emit(); return []; }
|
|
181
230
|
|
|
182
231
|
// v27: term-coverage filter — drop candidates whose title+lesson_learned
|
|
183
232
|
// covers <threshold of the query's significant terms. Skipped for
|
|
@@ -189,7 +238,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
189
238
|
const queryTerms = extractQueryTerms(userPrompt);
|
|
190
239
|
if (queryTerms.length >= COVERAGE_MIN_QUERY_TERMS) {
|
|
191
240
|
coverageFiltered = aboveThreshold.filter(r => candidateCoverage(r, queryTerms) >= coverageThreshold);
|
|
192
|
-
if (coverageFiltered.length === 0) return [];
|
|
241
|
+
if (coverageFiltered.length === 0) { _emit(); return []; }
|
|
193
242
|
}
|
|
194
243
|
}
|
|
195
244
|
|
|
@@ -208,9 +257,12 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
208
257
|
try { bumpStmt.run(now, r.id); } catch {}
|
|
209
258
|
}
|
|
210
259
|
|
|
260
|
+
_returned = result.length;
|
|
261
|
+
_emit();
|
|
211
262
|
return result;
|
|
212
263
|
} catch (e) {
|
|
213
264
|
debugCatch(e, 'searchRelevantMemories');
|
|
265
|
+
_emit();
|
|
214
266
|
return [];
|
|
215
267
|
}
|
|
216
268
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// lib/err-sampler.mjs — sampled append-only log of swallowed errors.
|
|
2
|
+
//
|
|
3
|
+
// Rationale: debugCatch previously only surfaced errors when CLAUDE_MEM_DEBUG
|
|
4
|
+
// was on. In production, silent catches have caused real bugs to slip by —
|
|
5
|
+
// e.g. rebuildVector writing to the wrong column name (`computed_at` vs
|
|
6
|
+
// `created_at_epoch`) was caught by the `catch` in optimize/vector and stayed
|
|
7
|
+
// invisible until R-7 surfaced it (see hook-optimize.mjs header comment and
|
|
8
|
+
// #7556 in project memory).
|
|
9
|
+
//
|
|
10
|
+
// Sampling is per-call Math.random() — no state, no rate limiter. At sample
|
|
11
|
+
// rate 0.01 each debugCatch has a 1% chance to append one JSONL line. Callers
|
|
12
|
+
// don't need to know about it; debugCatch imports this lazily so the fs-less
|
|
13
|
+
// hot path doesn't pay for the module.
|
|
14
|
+
//
|
|
15
|
+
// Gated entirely by CLAUDE_MEM_CATCH_SAMPLE env (0..1). Default off. All
|
|
16
|
+
// failures inside the sampler are swallowed — never crash the caller.
|
|
17
|
+
|
|
18
|
+
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 86400000;
|
|
22
|
+
|
|
23
|
+
function today() {
|
|
24
|
+
// UTC date string; sharding daily keeps files bounded even at high sample rates.
|
|
25
|
+
return new Date(Date.now()).toISOString().slice(0, 10);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseSampleRate(raw) {
|
|
29
|
+
if (raw === undefined || raw === '') return 0;
|
|
30
|
+
const n = parseFloat(raw);
|
|
31
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sample one caught error into the daily JSONL log.
|
|
36
|
+
* @param {Error|unknown} e Caught error
|
|
37
|
+
* @param {string} ctx Context label passed to debugCatch
|
|
38
|
+
* @param {string} dbDir ~/.claude-mem-lite (or CLAUDE_MEM_DIR)
|
|
39
|
+
*/
|
|
40
|
+
export function maybeSampleError(e, ctx, dbDir) {
|
|
41
|
+
try {
|
|
42
|
+
const rate = parseSampleRate(process.env.CLAUDE_MEM_CATCH_SAMPLE);
|
|
43
|
+
if (rate <= 0) return;
|
|
44
|
+
if (Math.random() >= rate) return;
|
|
45
|
+
if (!dbDir) return;
|
|
46
|
+
|
|
47
|
+
const errDir = join(dbDir, 'errors');
|
|
48
|
+
if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true, mode: 0o700 });
|
|
49
|
+
|
|
50
|
+
const line = JSON.stringify({
|
|
51
|
+
ts: new Date().toISOString(),
|
|
52
|
+
ctx: String(ctx || '').slice(0, 120),
|
|
53
|
+
msg: String(e?.message ?? e ?? '').slice(0, 500),
|
|
54
|
+
stack: typeof e?.stack === 'string' ? e.stack.split('\n').slice(0, 6).join('\n') : undefined,
|
|
55
|
+
}) + '\n';
|
|
56
|
+
|
|
57
|
+
appendFileSync(join(errDir, `${today()}.jsonl`), line, { mode: 0o600 });
|
|
58
|
+
} catch { /* sampler must never throw */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Exposed for tests — returns the resolved sample rate in [0,1]. */
|
|
62
|
+
export function _sampleRate() { return parseSampleRate(process.env.CLAUDE_MEM_CATCH_SAMPLE); }
|
|
63
|
+
|
|
64
|
+
/** Exposed for tests — return daily retention cutoff in ms (14 days). */
|
|
65
|
+
export const SAMPLE_LOG_RETENTION_MS = 14 * DAY_MS;
|
package/lib/metrics.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// lib/metrics.mjs — optional time-series metric sink.
|
|
2
|
+
//
|
|
3
|
+
// Rationale: mem_stats --quality gives a snapshot, not a trend. If search
|
|
4
|
+
// latency degrades or the coverage filter's pass-rate shifts over time, there
|
|
5
|
+
// is no record. Users operating claude-mem-lite in earnest need to see trends.
|
|
6
|
+
//
|
|
7
|
+
// Design:
|
|
8
|
+
// • CLAUDE_MEM_METRICS=1 enables; default OFF (no fs writes, no overhead).
|
|
9
|
+
// • Per-event JSONL rows appended to `$DB_DIR/metrics/YYYY-MM-DD.jsonl`.
|
|
10
|
+
// • Schema is open-ended: { ts, event, durationMs?, ...payload } — each
|
|
11
|
+
// call-site picks its own payload keys. Readers filter by `event`.
|
|
12
|
+
// • Read path walks last N days (default 7), parses per-line, aggregates
|
|
13
|
+
// p50/p95/p99 latencies and count-only metrics per event.
|
|
14
|
+
//
|
|
15
|
+
// All writes best-effort (mkdirSync + appendFileSync wrapped in try). Reads
|
|
16
|
+
// skip malformed lines silently. The module imports nothing heavy so hook
|
|
17
|
+
// cold-start pays near-zero when metrics are disabled.
|
|
18
|
+
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
|
|
22
|
+
const DAY_MS = 86400000;
|
|
23
|
+
|
|
24
|
+
function today() { return new Date(Date.now()).toISOString().slice(0, 10); }
|
|
25
|
+
|
|
26
|
+
function metricsEnabled() {
|
|
27
|
+
return process.env.CLAUDE_MEM_METRICS === '1';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Append one metric row to the daily JSONL. No-op when env disabled.
|
|
32
|
+
* @param {string} dbDir Claude-mem data dir (DB_DIR).
|
|
33
|
+
* @param {object} payload { event: 'inject'|'search'|'save'|..., durationMs?, ...custom }
|
|
34
|
+
*/
|
|
35
|
+
export function recordMetric(dbDir, payload) {
|
|
36
|
+
if (!metricsEnabled()) return;
|
|
37
|
+
if (!dbDir || !payload || !payload.event) return;
|
|
38
|
+
try {
|
|
39
|
+
const dir = join(dbDir, 'metrics');
|
|
40
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const row = { ts: new Date().toISOString(), ...payload };
|
|
42
|
+
appendFileSync(join(dir, `${today()}.jsonl`), JSON.stringify(row) + '\n', { mode: 0o600 });
|
|
43
|
+
} catch { /* metrics sink must never crash the caller */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Helper: time a synchronous function and record the duration.
|
|
48
|
+
* Returns the function's return value; on throw, still records durationMs
|
|
49
|
+
* + error key, then re-throws.
|
|
50
|
+
*
|
|
51
|
+
* @template T
|
|
52
|
+
* @param {string} dbDir
|
|
53
|
+
* @param {string} event
|
|
54
|
+
* @param {() => T} fn
|
|
55
|
+
* @param {object} [extra] Extra keys merged into the metric row
|
|
56
|
+
* @returns {T}
|
|
57
|
+
*/
|
|
58
|
+
export function timed(dbDir, event, fn, extra = {}) {
|
|
59
|
+
if (!metricsEnabled()) return fn();
|
|
60
|
+
const t0 = Date.now();
|
|
61
|
+
try {
|
|
62
|
+
const out = fn();
|
|
63
|
+
recordMetric(dbDir, { event, durationMs: Date.now() - t0, ...extra });
|
|
64
|
+
return out;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
recordMetric(dbDir, { event, durationMs: Date.now() - t0, error: String(e?.message || 'unknown'), ...extra });
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Count of days to aggregate by default in readMetrics / aggregate. */
|
|
72
|
+
export const DEFAULT_WINDOW_DAYS = 7;
|
|
73
|
+
|
|
74
|
+
function* iterDailyFiles(dbDir, days) {
|
|
75
|
+
const dir = join(dbDir, 'metrics');
|
|
76
|
+
if (!existsSync(dir)) return;
|
|
77
|
+
const cutoff = Date.now() - days * DAY_MS;
|
|
78
|
+
const files = readdirSync(dir).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
const ymd = f.slice(0, 10);
|
|
81
|
+
const fileMs = Date.parse(ymd + 'T00:00:00Z');
|
|
82
|
+
if (!Number.isFinite(fileMs) || fileMs < cutoff) continue;
|
|
83
|
+
yield join(dir, f);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stream-read metric rows from the last `days` days.
|
|
89
|
+
* @yields {object}
|
|
90
|
+
*/
|
|
91
|
+
export function* readMetrics(dbDir, days = DEFAULT_WINDOW_DAYS) {
|
|
92
|
+
for (const path of iterDailyFiles(dbDir, days)) {
|
|
93
|
+
let raw;
|
|
94
|
+
try { raw = readFileSync(path, 'utf8'); } catch { continue; }
|
|
95
|
+
for (const line of raw.split('\n')) {
|
|
96
|
+
if (!line) continue;
|
|
97
|
+
try { yield JSON.parse(line); } catch { /* skip malformed */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function percentile(sorted, q) {
|
|
103
|
+
if (sorted.length === 0) return null;
|
|
104
|
+
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
|
|
105
|
+
return sorted[idx];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Aggregate metrics into per-event summaries.
|
|
110
|
+
* Output shape: { [event]: { count, p50, p95, p99, errors, firstTs, lastTs } }
|
|
111
|
+
*/
|
|
112
|
+
export function aggregateMetrics(dbDir, days = DEFAULT_WINDOW_DAYS) {
|
|
113
|
+
const byEvent = new Map();
|
|
114
|
+
for (const row of readMetrics(dbDir, days)) {
|
|
115
|
+
const ev = row.event;
|
|
116
|
+
if (!ev) continue;
|
|
117
|
+
let bucket = byEvent.get(ev);
|
|
118
|
+
if (!bucket) {
|
|
119
|
+
bucket = { count: 0, durations: [], errors: 0, firstTs: row.ts, lastTs: row.ts };
|
|
120
|
+
byEvent.set(ev, bucket);
|
|
121
|
+
}
|
|
122
|
+
bucket.count++;
|
|
123
|
+
if (typeof row.durationMs === 'number' && Number.isFinite(row.durationMs)) {
|
|
124
|
+
bucket.durations.push(row.durationMs);
|
|
125
|
+
}
|
|
126
|
+
if (row.error) bucket.errors++;
|
|
127
|
+
if (row.ts && row.ts < bucket.firstTs) bucket.firstTs = row.ts;
|
|
128
|
+
if (row.ts && row.ts > bucket.lastTs) bucket.lastTs = row.ts;
|
|
129
|
+
}
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const [ev, b] of byEvent) {
|
|
132
|
+
b.durations.sort((a, z) => a - z);
|
|
133
|
+
out[ev] = {
|
|
134
|
+
count: b.count,
|
|
135
|
+
errors: b.errors,
|
|
136
|
+
p50: percentile(b.durations, 0.5),
|
|
137
|
+
p95: percentile(b.durations, 0.95),
|
|
138
|
+
p99: percentile(b.durations, 0.99),
|
|
139
|
+
firstTs: b.firstTs,
|
|
140
|
+
lastTs: b.lastTs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Human-readable summary table for `doctor` / `stats` output.
|
|
148
|
+
* Returns a string block with one line per event.
|
|
149
|
+
*/
|
|
150
|
+
export function formatSummary(aggregated, days = DEFAULT_WINDOW_DAYS) {
|
|
151
|
+
const events = Object.keys(aggregated).sort();
|
|
152
|
+
if (events.length === 0) return `[metrics] no data in the last ${days}d (is CLAUDE_MEM_METRICS=1 set?)`;
|
|
153
|
+
const header = `[metrics] last ${days}d — event · count · p50 / p95 / p99 (ms) · errors`;
|
|
154
|
+
const lines = [header];
|
|
155
|
+
for (const ev of events) {
|
|
156
|
+
const s = aggregated[ev];
|
|
157
|
+
const lat = s.p50 !== null ? `${s.p50} / ${s.p95} / ${s.p99}` : '(no latency)';
|
|
158
|
+
lines.push(` ${ev.padEnd(16)} · n=${String(s.count).padStart(5)} · ${lat} · err=${s.errors}`);
|
|
159
|
+
}
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// No MCP SDK or heavy deps — only imports schema.mjs and utils.mjs
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
-
import { ensureDb, DB_PATH, REGISTRY_DB_PATH
|
|
6
|
+
import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
7
|
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, DEFAULT_DECAY_HALF_LIFE_MS, getCurrentBranch, notLowSignalTitleClause, LOW_SIGNAL_TITLE } from './utils.mjs';
|
|
8
8
|
import { extractCjkLikePatterns } from './nlp.mjs';
|
|
9
9
|
import { resolveProject } from './project-utils.mjs';
|
|
@@ -19,88 +19,10 @@ import { probeOtherSources as probeIdSources } from './lib/id-routing.mjs';
|
|
|
19
19
|
import { basename } from 'path';
|
|
20
20
|
import { readFileSync } from 'fs';
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
function parseArgs(argv) {
|
|
27
|
-
const positional = [];
|
|
28
|
-
const flags = {};
|
|
29
|
-
let i = 0;
|
|
30
|
-
while (i < argv.length) {
|
|
31
|
-
const arg = argv[i];
|
|
32
|
-
if (arg.startsWith('--')) {
|
|
33
|
-
const key = arg.slice(2);
|
|
34
|
-
const next = argv[i + 1];
|
|
35
|
-
if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
36
|
-
flags[key] = next;
|
|
37
|
-
i += 2;
|
|
38
|
-
} else {
|
|
39
|
-
flags[key] = true;
|
|
40
|
-
i++;
|
|
41
|
-
}
|
|
42
|
-
} else if (arg === '-h') {
|
|
43
|
-
flags.help = true;
|
|
44
|
-
i++;
|
|
45
|
-
} else {
|
|
46
|
-
positional.push(arg);
|
|
47
|
-
i++;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return { positional, flags };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ─── Output Helpers ──────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
function out(text) {
|
|
56
|
-
process.stdout.write(text + '\n');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function fail(text) {
|
|
60
|
-
process.stderr.write(text + '\n');
|
|
61
|
-
process.exitCode = 1;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function relativeTime(epochMs) {
|
|
65
|
-
const diff = Date.now() - epochMs;
|
|
66
|
-
if (diff < 0) return 'just now';
|
|
67
|
-
const mins = Math.floor(diff / 60000);
|
|
68
|
-
if (mins < 1) return 'just now';
|
|
69
|
-
if (mins < 60) return `${mins}m ago`;
|
|
70
|
-
const hours = Math.floor(mins / 60);
|
|
71
|
-
if (hours < 24) return `${hours}h ago`;
|
|
72
|
-
const days = Math.floor(hours / 24);
|
|
73
|
-
return `${days}d ago`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function fmtDateShort(iso) {
|
|
77
|
-
if (!iso) return '';
|
|
78
|
-
return iso.slice(0, 10); // YYYY-MM-DD
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Parse an ID token from a command positional argument.
|
|
82
|
-
// Accepts: `123`, `#123`, `P#123` / `p123` (prompt), `S#123` / `s123` (session).
|
|
83
|
-
// Returns { source: 'obs'|'session'|'prompt'|null, id: number } or null if unparseable.
|
|
84
|
-
// source===null means no explicit prefix — caller picks default (typically 'obs').
|
|
85
|
-
function parseIdToken(raw) {
|
|
86
|
-
const m = /^([PpSs]?)#?(\d+)$/.exec(String(raw).trim());
|
|
87
|
-
if (!m) return null;
|
|
88
|
-
const p = m[1].toUpperCase();
|
|
89
|
-
const id = parseInt(m[2], 10);
|
|
90
|
-
if (!Number.isFinite(id) || id <= 0) return null;
|
|
91
|
-
const source = p === 'P' ? 'prompt' : p === 'S' ? 'session' : null;
|
|
92
|
-
return { source, id };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Format the shared `probeIdSources` output as CLI hint strings.
|
|
96
|
-
// Example: ["#5419 (obs)", "P#5417 (prompt)"] — callers join with "; ".
|
|
97
|
-
function formatProbeHints(probe) {
|
|
98
|
-
const hints = [];
|
|
99
|
-
if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs)`);
|
|
100
|
-
if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session)`);
|
|
101
|
-
if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt)`);
|
|
102
|
-
return hints;
|
|
103
|
-
}
|
|
22
|
+
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
23
|
+
// router + remaining-command bodies during the incremental split. Future work:
|
|
24
|
+
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
25
|
+
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
|
|
104
26
|
|
|
105
27
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
106
28
|
|
|
@@ -1869,36 +1791,8 @@ function cmdMaintain(db, args) {
|
|
|
1869
1791
|
out(`[mem] ${results.join('\n[mem] ')}`);
|
|
1870
1792
|
}
|
|
1871
1793
|
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1874
|
-
function cmdFtsCheck(db, args) {
|
|
1875
|
-
const { positional } = parseArgs(args);
|
|
1876
|
-
const action = positional[0];
|
|
1877
|
-
if (!action || !['check', 'rebuild'].includes(action)) {
|
|
1878
|
-
fail('[mem] Usage: mem fts-check <check|rebuild>');
|
|
1879
|
-
return;
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
if (action === 'check') {
|
|
1883
|
-
const result = checkFTSIntegrity(db);
|
|
1884
|
-
if (result.healthy) {
|
|
1885
|
-
out('[mem] FTS5 indexes are healthy — all integrity checks passed.');
|
|
1886
|
-
} else {
|
|
1887
|
-
out(`[mem] FTS5 issues found:`);
|
|
1888
|
-
for (const d of result.details) out(` ${d}`);
|
|
1889
|
-
}
|
|
1890
|
-
return;
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
if (action === 'rebuild') {
|
|
1894
|
-
const result = rebuildFTS(db);
|
|
1895
|
-
if (result.errors.length > 0) {
|
|
1896
|
-
out(`[mem] Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`);
|
|
1897
|
-
} else {
|
|
1898
|
-
out(`[mem] Successfully rebuilt: ${result.rebuilt.join(', ')}`);
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1794
|
+
// cmdFtsCheck extracted to cli/fts-check.mjs (v2.41 split).
|
|
1795
|
+
import { cmdFtsCheck } from './cli/fts-check.mjs';
|
|
1902
1796
|
|
|
1903
1797
|
// ─── Registry ─────────────────────────────────────────────────────────────────
|
|
1904
1798
|
|
|
@@ -2352,121 +2246,11 @@ async function cmdOptimize(db, args) {
|
|
|
2352
2246
|
if (results.smartCompress) out(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
|
|
2353
2247
|
}
|
|
2354
2248
|
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
const { runBenchmark } = await import('./lib/doctor-benchmark.mjs');
|
|
2358
|
-
const project = inferProject();
|
|
2359
|
-
const result = runBenchmark(db, { project });
|
|
2360
|
-
out(JSON.stringify(result, null, 2));
|
|
2361
|
-
return;
|
|
2362
|
-
}
|
|
2363
|
-
out('[mem] doctor: supported flags: --benchmark');
|
|
2364
|
-
process.exitCode = 1;
|
|
2365
|
-
}
|
|
2249
|
+
// cmdDoctor extracted to cli/doctor.mjs (v2.41 split).
|
|
2250
|
+
import { cmdDoctor } from './cli/doctor.mjs';
|
|
2366
2251
|
|
|
2367
|
-
//
|
|
2368
|
-
|
|
2369
|
-
// lib/activity.mjs pure functions; imported lazily to match the doctor pattern.
|
|
2370
|
-
|
|
2371
|
-
function formatActivityResults(rows) {
|
|
2372
|
-
if (!rows || rows.length === 0) return '(no events)';
|
|
2373
|
-
return rows.map(r => `#${r.id} [${r.event_type}] ${r.title}`).join('\n');
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
|
-
async function cmdActivity(db, args) {
|
|
2377
|
-
const sub = args[0];
|
|
2378
|
-
if (!sub) {
|
|
2379
|
-
fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show> ...');
|
|
2380
|
-
return;
|
|
2381
|
-
}
|
|
2382
|
-
|
|
2383
|
-
const { positional, flags } = parseArgs(args.slice(1));
|
|
2384
|
-
const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES } = await import('./lib/activity.mjs');
|
|
2385
|
-
const VALID_EVENT_TYPES = new Set(EVENT_TYPES);
|
|
2386
|
-
const project = flags.project ? resolveProject(db, flags.project) : inferProject();
|
|
2387
|
-
|
|
2388
|
-
if (sub === 'save') {
|
|
2389
|
-
const type = flags.type || 'observation';
|
|
2390
|
-
if (!VALID_EVENT_TYPES.has(type)) {
|
|
2391
|
-
fail(`[mem] activity save: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
2392
|
-
return;
|
|
2393
|
-
}
|
|
2394
|
-
const title = flags.title || positional.join(' ').trim();
|
|
2395
|
-
if (!title) {
|
|
2396
|
-
fail('[mem] activity save: --title or positional text required');
|
|
2397
|
-
return;
|
|
2398
|
-
}
|
|
2399
|
-
const body = flags.body || null;
|
|
2400
|
-
// Accept both --file (singular, backward compat) and --files (plural,
|
|
2401
|
-
// comma-split, preferred — matches cmdSave). Merge both sources.
|
|
2402
|
-
const filesFromPlural = flags.files && typeof flags.files === 'string'
|
|
2403
|
-
? flags.files.split(',').map(s => s.trim()).filter(Boolean)
|
|
2404
|
-
: [];
|
|
2405
|
-
const filesFromSingular = flags.file && typeof flags.file === 'string' ? [flags.file] : [];
|
|
2406
|
-
const file_paths_merged = [...filesFromSingular, ...filesFromPlural];
|
|
2407
|
-
const file_paths = file_paths_merged.length > 0 ? file_paths_merged : null;
|
|
2408
|
-
const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
|
|
2409
|
-
if (flags.importance !== undefined && (isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
|
|
2410
|
-
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
2411
|
-
return;
|
|
2412
|
-
}
|
|
2413
|
-
const id = saveEvent(db, {
|
|
2414
|
-
project,
|
|
2415
|
-
event_type: type,
|
|
2416
|
-
title,
|
|
2417
|
-
body,
|
|
2418
|
-
importance: rawImp,
|
|
2419
|
-
file_paths,
|
|
2420
|
-
});
|
|
2421
|
-
out(JSON.stringify({ ok: true, id }));
|
|
2422
|
-
return;
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
if (sub === 'search') {
|
|
2426
|
-
const q = positional.join(' ');
|
|
2427
|
-
if (!q) {
|
|
2428
|
-
fail('[mem] activity search: query required');
|
|
2429
|
-
return;
|
|
2430
|
-
}
|
|
2431
|
-
const type = flags.type || null;
|
|
2432
|
-
if (type !== null && !VALID_EVENT_TYPES.has(type)) {
|
|
2433
|
-
fail(`[mem] activity search: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
2434
|
-
return;
|
|
2435
|
-
}
|
|
2436
|
-
const limit = flags.limit !== undefined ? parseInt(flags.limit, 10) : 10;
|
|
2437
|
-
const rows = searchEvents(db, q, { project, type, limit });
|
|
2438
|
-
out(formatActivityResults(rows));
|
|
2439
|
-
return;
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
|
-
if (sub === 'recent') {
|
|
2443
|
-
// Accept either `activity recent 5` or `activity recent --limit 5`.
|
|
2444
|
-
const posLimit = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
|
|
2445
|
-
const flagLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
|
|
2446
|
-
const limit = Number.isFinite(posLimit) ? posLimit : (Number.isFinite(flagLimit) ? flagLimit : 20);
|
|
2447
|
-
const type = flags.type || null;
|
|
2448
|
-
if (type !== null && !VALID_EVENT_TYPES.has(type)) {
|
|
2449
|
-
fail(`[mem] activity recent: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
|
|
2450
|
-
return;
|
|
2451
|
-
}
|
|
2452
|
-
const rows = recentEvents(db, { project, type, limit });
|
|
2453
|
-
out(formatActivityResults(rows));
|
|
2454
|
-
return;
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
if (sub === 'show') {
|
|
2458
|
-
const id = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
|
|
2459
|
-
if (!Number.isFinite(id)) {
|
|
2460
|
-
fail('[mem] activity show: numeric id required');
|
|
2461
|
-
return;
|
|
2462
|
-
}
|
|
2463
|
-
const row = getEvent(db, id);
|
|
2464
|
-
out(row ? JSON.stringify(row, null, 2) : 'Not found');
|
|
2465
|
-
return;
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
|
-
fail(`[mem] Unknown activity subcommand: ${sub}`);
|
|
2469
|
-
}
|
|
2252
|
+
// cmdActivity (T7 v2.31) extracted to cli/activity.mjs (v2.41 split).
|
|
2253
|
+
import { cmdActivity } from './cli/activity.mjs';
|
|
2470
2254
|
|
|
2471
2255
|
// ─── Main Entry Point ────────────────────────────────────────────────────────
|
|
2472
2256
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.41.0",
|
|
4
4
|
"description": "Lightweight persistent memory system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -52,6 +52,13 @@
|
|
|
52
52
|
"lib/low-signal-patterns.mjs",
|
|
53
53
|
"lib/citation-tracker.mjs",
|
|
54
54
|
"lib/id-routing.mjs",
|
|
55
|
+
"lib/err-sampler.mjs",
|
|
56
|
+
"lib/metrics.mjs",
|
|
57
|
+
"cli/common.mjs",
|
|
58
|
+
"cli/fts-check.mjs",
|
|
59
|
+
"cli/doctor.mjs",
|
|
60
|
+
"cli/activity.mjs",
|
|
61
|
+
"server/fts-check.mjs",
|
|
55
62
|
"registry.mjs",
|
|
56
63
|
"registry-retriever.mjs",
|
|
57
64
|
"registry-indexer.mjs",
|
package/schema.mjs
CHANGED
|
@@ -13,7 +13,15 @@ export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
|
|
|
13
13
|
export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
14
14
|
|
|
15
15
|
// Increment when schema changes (tables, columns, indexes, FTS, migrations)
|
|
16
|
-
|
|
16
|
+
//
|
|
17
|
+
// v27 (v2.41): observations_au / session_summaries_au / user_prompts_au
|
|
18
|
+
// triggers scoped to FTS-indexed columns via `AFTER UPDATE OF <cols>`. Before
|
|
19
|
+
// v27 the _au triggers fired on ANY row UPDATE — access_count / injection_count
|
|
20
|
+
// / last_accessed_at bumps (#8100 separation notwithstanding) caused wasted
|
|
21
|
+
// FTS delete+reinsert cycles and amplified SQLITE_CORRUPT_VTAB blast radius
|
|
22
|
+
// (project_non_obvious.md). Migration drops the old triggers once and lets
|
|
23
|
+
// ensureFTS recreate them with the scoped form.
|
|
24
|
+
export const CURRENT_SCHEMA_VERSION = 27;
|
|
17
25
|
|
|
18
26
|
const CORE_SCHEMA = `
|
|
19
27
|
CREATE TABLE IF NOT EXISTS sdk_sessions (
|
|
@@ -127,11 +135,27 @@ const MIGRATIONS = [
|
|
|
127
135
|
* The DB should have foreign_keys OFF before calling (enabled after dedup migration).
|
|
128
136
|
*/
|
|
129
137
|
export function initSchema(db) {
|
|
130
|
-
// Fast path: skip all migrations if schema is already at current version
|
|
138
|
+
// Fast path: skip all migrations if schema is already at current version.
|
|
139
|
+
// Forward-incompat guard: if persisted version is NEWER than this build's
|
|
140
|
+
// CURRENT_SCHEMA_VERSION, a newer claude-mem-lite wrote it; the current
|
|
141
|
+
// (older) binary would silently re-apply old migrations over a newer layout.
|
|
142
|
+
// Throw loudly instead — `claude-mem-lite doctor` / reinstall is the path.
|
|
131
143
|
try {
|
|
132
144
|
const row = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
|
|
133
|
-
if (row && row.version ===
|
|
134
|
-
|
|
145
|
+
if (row && typeof row.version === 'number') {
|
|
146
|
+
if (row.version === CURRENT_SCHEMA_VERSION) return db;
|
|
147
|
+
if (row.version > CURRENT_SCHEMA_VERSION) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
|
|
150
|
+
`A newer version wrote this DB; upgrade claude-mem-lite (npm i -g claude-mem-lite@latest) or point CLAUDE_MEM_DIR to a fresh directory.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// schema_version table absent = first init — proceed to create it.
|
|
156
|
+
// Real forward-incompat throw above must propagate.
|
|
157
|
+
if (e.message?.startsWith('DB schema is v')) throw e;
|
|
158
|
+
}
|
|
135
159
|
|
|
136
160
|
// Create core tables
|
|
137
161
|
db.exec(CORE_SCHEMA);
|
|
@@ -241,6 +265,25 @@ export function initSchema(db) {
|
|
|
241
265
|
}
|
|
242
266
|
} catch { /* non-critical — ensureFTS will create if missing */ }
|
|
243
267
|
|
|
268
|
+
// v27 migration: drop legacy _au triggers that fire on ANY row UPDATE so
|
|
269
|
+
// ensureFTS reinstates them with `AFTER UPDATE OF <fts_cols>`. Trigger fires
|
|
270
|
+
// only when FTS-indexed columns change after this migration — access_count
|
|
271
|
+
// / injection_count / last_accessed_at bumps no longer thrash the FTS index.
|
|
272
|
+
// Conditional per #7647: only drop when the stored DDL lacks the scoped
|
|
273
|
+
// `UPDATE OF` clause (handles re-run + fresh-DB cases).
|
|
274
|
+
for (const [trg, tbl] of [
|
|
275
|
+
['observations_au', 'observations'],
|
|
276
|
+
['session_summaries_au', 'session_summaries'],
|
|
277
|
+
['user_prompts_au', 'user_prompts'],
|
|
278
|
+
]) {
|
|
279
|
+
try {
|
|
280
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?`).get(trg);
|
|
281
|
+
if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
|
|
282
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tbl}_au`);
|
|
283
|
+
}
|
|
284
|
+
} catch { /* non-critical — ensureFTS will recreate */ }
|
|
285
|
+
}
|
|
286
|
+
|
|
244
287
|
// FTS5 full-text search tables + triggers (idempotent)
|
|
245
288
|
ensureFTS(db, 'observations_fts', 'observations', OBS_FTS_COLUMNS);
|
|
246
289
|
ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes', 'remaining_items']);
|
|
@@ -523,10 +566,8 @@ export function checkFTSIntegrity(db) {
|
|
|
523
566
|
}
|
|
524
567
|
|
|
525
568
|
export function ensureFTS(db, ftsName, tableName, columns) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// Validate identifiers to prevent SQL injection
|
|
569
|
+
// Validate identifiers to prevent SQL injection (done upfront; both
|
|
570
|
+
// branches below use these identifiers in string-interpolated SQL)
|
|
530
571
|
const idRe = /^[a-z][a-z0-9_]*$/;
|
|
531
572
|
if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every(c => idRe.test(c))) {
|
|
532
573
|
throw new Error(`Invalid identifier in ensureFTS: ${ftsName}, ${tableName}`);
|
|
@@ -535,18 +576,30 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
535
576
|
const colList = columns.join(', ');
|
|
536
577
|
const newVals = columns.map(c => `new.${c}`).join(', ');
|
|
537
578
|
const oldVals = columns.map(c => `old.${c}`).join(', ');
|
|
538
|
-
db.exec(`
|
|
539
|
-
CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id');
|
|
540
579
|
|
|
541
|
-
|
|
580
|
+
const ftsExists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(ftsName);
|
|
581
|
+
if (!ftsExists) {
|
|
582
|
+
db.exec(`CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Triggers created / recreated independently of FTS table existence so that
|
|
586
|
+
// schema migrations (e.g. v27 scope-to-FTS-columns) can drop the old _au
|
|
587
|
+
// trigger and this function reinstates it with the current template on the
|
|
588
|
+
// next ensureDb(). Per #7647: keep rebuild conditional — IF NOT EXISTS gates
|
|
589
|
+
// writes when the current definition already matches.
|
|
590
|
+
db.exec(`
|
|
591
|
+
CREATE TRIGGER IF NOT EXISTS ${tableName}_ai AFTER INSERT ON ${tableName} BEGIN
|
|
542
592
|
INSERT INTO ${ftsName}(rowid, ${colList}) VALUES (new.id, ${newVals});
|
|
543
593
|
END;
|
|
544
594
|
|
|
545
|
-
CREATE TRIGGER ${tableName}_ad AFTER DELETE ON ${tableName} BEGIN
|
|
595
|
+
CREATE TRIGGER IF NOT EXISTS ${tableName}_ad AFTER DELETE ON ${tableName} BEGIN
|
|
546
596
|
INSERT INTO ${ftsName}(${ftsName}, rowid, ${colList}) VALUES('delete', old.id, ${oldVals});
|
|
547
597
|
END;
|
|
548
598
|
|
|
549
|
-
|
|
599
|
+
-- v27: AFTER UPDATE OF <fts_cols> — trigger fires only when an FTS-indexed
|
|
600
|
+
-- column changes. Prevents access_count / injection_count / last_accessed_at
|
|
601
|
+
-- UPDATEs from firing wasteful FTS delete+reinsert (project_non_obvious.md).
|
|
602
|
+
CREATE TRIGGER IF NOT EXISTS ${tableName}_au AFTER UPDATE OF ${colList} ON ${tableName} BEGIN
|
|
550
603
|
INSERT INTO ${ftsName}(${ftsName}, rowid, ${colList}) VALUES('delete', old.id, ${oldVals});
|
|
551
604
|
INSERT INTO ${ftsName}(rowid, ${colList}) VALUES (new.id, ${newVals});
|
|
552
605
|
END;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// server/fts-check.mjs — MCP `mem_fts_check` handler.
|
|
2
|
+
// Extracted from server.mjs (v2.41, god-module split).
|
|
3
|
+
//
|
|
4
|
+
// Pure delegate to schema.mjs helpers; Zod filters args.action before we get
|
|
5
|
+
// here (see memFtsCheckSchema). No shared state beyond the `db` handle passed
|
|
6
|
+
// in from the server-process module scope.
|
|
7
|
+
|
|
8
|
+
import { checkFTSIntegrity, rebuildFTS } from '../schema.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Handle `mem_fts_check({ action })`. Returns an MCP content wrapper.
|
|
12
|
+
* @param {import('better-sqlite3').Database} db
|
|
13
|
+
* @param {{ action: 'check' | 'rebuild' }} args
|
|
14
|
+
*/
|
|
15
|
+
export function handleMemFtsCheck(db, args) {
|
|
16
|
+
if (args.action === 'check') {
|
|
17
|
+
const result = checkFTSIntegrity(db);
|
|
18
|
+
return {
|
|
19
|
+
content: [{
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: result.healthy
|
|
22
|
+
? 'FTS5 indexes are healthy — all integrity checks passed.'
|
|
23
|
+
: `FTS5 issues found:\n${result.details.join('\n')}`,
|
|
24
|
+
}],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// args.action === 'rebuild' (Zod enum enforces one of the two)
|
|
28
|
+
const result = rebuildFTS(db);
|
|
29
|
+
const summary = result.errors.length > 0
|
|
30
|
+
? `Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`
|
|
31
|
+
: `Successfully rebuilt: ${result.rebuilt.join(', ')}`;
|
|
32
|
+
return { content: [{ type: 'text', text: summary }] };
|
|
33
|
+
}
|
package/server.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
|
8
8
|
import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause, LOW_SIGNAL_TITLE } from './utils.mjs';
|
|
9
9
|
import { extractCjkLikePatterns } from './nlp.mjs';
|
|
10
10
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
11
|
-
import { ensureDb, DB_PATH, REGISTRY_DB_PATH
|
|
11
|
+
import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
|
|
12
12
|
import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
13
13
|
import { effectiveQuiet } from './hook-shared.mjs';
|
|
14
14
|
import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
@@ -2107,6 +2107,8 @@ server.registerTool(
|
|
|
2107
2107
|
);
|
|
2108
2108
|
|
|
2109
2109
|
// ─── Tool: mem_fts_check ─────────────────────────────────────────────────────
|
|
2110
|
+
// Handler extracted to server/fts-check.mjs (v2.41 split).
|
|
2111
|
+
import { handleMemFtsCheck } from './server/fts-check.mjs';
|
|
2110
2112
|
|
|
2111
2113
|
server.registerTool(
|
|
2112
2114
|
'mem_fts_check',
|
|
@@ -2114,22 +2116,7 @@ server.registerTool(
|
|
|
2114
2116
|
description: descriptionOf('mem_fts_check'),
|
|
2115
2117
|
inputSchema: memFtsCheckSchema,
|
|
2116
2118
|
},
|
|
2117
|
-
safeHandler(async (args) =>
|
|
2118
|
-
// T3-P2-C: Zod `action: z.enum(['check','rebuild'])` filters any other value before we
|
|
2119
|
-
// reach this handler, so there's no "Unknown action" fallback to write.
|
|
2120
|
-
if (args.action === 'check') {
|
|
2121
|
-
const result = checkFTSIntegrity(db);
|
|
2122
|
-
return { content: [{ type: 'text', text: result.healthy
|
|
2123
|
-
? 'FTS5 indexes are healthy — all integrity checks passed.'
|
|
2124
|
-
: `FTS5 issues found:\n${result.details.join('\n')}` }] };
|
|
2125
|
-
}
|
|
2126
|
-
// args.action === 'rebuild'
|
|
2127
|
-
const result = rebuildFTS(db);
|
|
2128
|
-
const summary = result.errors.length > 0
|
|
2129
|
-
? `Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`
|
|
2130
|
-
: `Successfully rebuilt: ${result.rebuilt.join(', ')}`;
|
|
2131
|
-
return { content: [{ type: 'text', text: summary }] };
|
|
2132
|
-
})
|
|
2119
|
+
safeHandler(async (args) => handleMemFtsCheck(db, args))
|
|
2133
2120
|
);
|
|
2134
2121
|
|
|
2135
2122
|
// ─── Tool: mem_browse ────────────────────────────────────────────────────────
|
package/source-files.mjs
CHANGED
|
@@ -39,6 +39,14 @@ export const SOURCE_FILES = [
|
|
|
39
39
|
'lib/low-signal-patterns.mjs',
|
|
40
40
|
'lib/citation-tracker.mjs',
|
|
41
41
|
'lib/id-routing.mjs',
|
|
42
|
+
'lib/err-sampler.mjs',
|
|
43
|
+
'lib/metrics.mjs',
|
|
44
|
+
// v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
|
|
45
|
+
'cli/common.mjs',
|
|
46
|
+
'cli/fts-check.mjs',
|
|
47
|
+
'cli/doctor.mjs',
|
|
48
|
+
'cli/activity.mjs',
|
|
49
|
+
'server/fts-check.mjs',
|
|
42
50
|
// v2.32 invited-memory: memdir primitives + adopt/unadopt CLI
|
|
43
51
|
'memdir.mjs',
|
|
44
52
|
'adopt-content.mjs',
|
package/utils.mjs
CHANGED
|
@@ -235,7 +235,11 @@ export function debugLog(level, context, msg) {
|
|
|
235
235
|
|
|
236
236
|
/**
|
|
237
237
|
* Log a caught error at ERROR level (includes stack trace when available).
|
|
238
|
-
* Gated by CLAUDE_MEM_DEBUG
|
|
238
|
+
* Gated by CLAUDE_MEM_DEBUG for stderr output. Separately, if
|
|
239
|
+
* `CLAUDE_MEM_CATCH_SAMPLE` (float 0..1) is set, a random fraction of caught
|
|
240
|
+
* errors get appended to `$DB_DIR/errors/YYYY-MM-DD.jsonl` — observable
|
|
241
|
+
* residue for otherwise-silent swallowed errors (see lib/err-sampler.mjs).
|
|
242
|
+
* Use in catch blocks for non-fatal errors.
|
|
239
243
|
* @param {Error|unknown} e The caught error
|
|
240
244
|
* @param {string} context Module or function name for attribution
|
|
241
245
|
*/
|
|
@@ -244,6 +248,20 @@ export function debugCatch(e, context) {
|
|
|
244
248
|
const ts = new Date().toISOString();
|
|
245
249
|
console.error(`[claude-mem-lite] [${ts}] [ERROR] ${context}:`, e?.stack || e?.message || e);
|
|
246
250
|
}
|
|
251
|
+
// Sampled-to-disk surface for post-mortem. Lazy-loaded so fs-less paths
|
|
252
|
+
// don't pay the module cost; wrapped in try so sampler faults never crash
|
|
253
|
+
// the caller (debugCatch is the error-handler-of-last-resort path).
|
|
254
|
+
if (process.env.CLAUDE_MEM_CATCH_SAMPLE) {
|
|
255
|
+
(async () => {
|
|
256
|
+
try {
|
|
257
|
+
const [{ maybeSampleError }, { DB_DIR }] = await Promise.all([
|
|
258
|
+
import('./lib/err-sampler.mjs'),
|
|
259
|
+
import('./schema.mjs'),
|
|
260
|
+
]);
|
|
261
|
+
maybeSampleError(e, context, DB_DIR);
|
|
262
|
+
} catch { /* sampler dynamic-import fault must not propagate */ }
|
|
263
|
+
})();
|
|
264
|
+
}
|
|
247
265
|
}
|
|
248
266
|
|
|
249
267
|
// ─── JSON Parsing ────────────────────────────────────────────────────────────
|