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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.40.0",
13
+ "version": "2.41.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.40.0",
3
+ "version": "2.41.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
@@ -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 is a baseline-capture tool, routed through mem-cli (DB layer);
18
- // plain `doctor` continues to run the install health-check below.
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 hay = `${row.title || ''} ${row.lesson_learned || ''}`.toLowerCase();
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 : 0.7;
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
- if (aboveThreshold.length === 0) return [];
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;
@@ -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, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
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
- // OBS_BM25, TYPE_DECAY_CASE imported from utils.mjs
23
-
24
- // ─── Argument Parsing ────────────────────────────────────────────────────────
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
- // ─── FTS Check ───────────────────────────────────────────────────────────────
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
- async function cmdDoctor(db, args) {
2356
- if (args.includes('--benchmark')) {
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
- // ─── Activity (T7 v2.31) ─────────────────────────────────────────────────────
2368
- // Separate namespace from observations. Handlers are thin wrappers over
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.40.0",
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
- export const CURRENT_SCHEMA_VERSION = 26;
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 === CURRENT_SCHEMA_VERSION) return db;
134
- } catch { /* table may not exist yet */ }
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
- const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(ftsName);
527
- if (exists) return;
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
- CREATE TRIGGER ${tableName}_ai AFTER INSERT ON ${tableName} BEGIN
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
- CREATE TRIGGER ${tableName}_au AFTER UPDATE ON ${tableName} BEGIN
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, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
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. Use in catch blocks for non-fatal errors.
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 ────────────────────────────────────────────────────────────