claude-mem-lite 2.40.0 → 2.42.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.42.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.42.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,94 @@
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; only
5
+ // `lib/` leaf utilities may be re-exported through here (currently:
6
+ // parseIdToken). This module is the single source of truth for stdout/stderr
7
+ // framing, arg parsing, ID-token parsing, and relative-time formatting —
8
+ // every command imports from here so the CLI stays consistent.
9
+
10
+ // ─── Argument Parsing ────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Parse argv-style array into { positional, flags }.
14
+ * `--key value` → flags.key = value; `--flag` (no value) → flags.key = true.
15
+ * `-h` → flags.help = true.
16
+ */
17
+ export function parseArgs(argv) {
18
+ const positional = [];
19
+ const flags = {};
20
+ let i = 0;
21
+ while (i < argv.length) {
22
+ const arg = argv[i];
23
+ if (arg.startsWith('--')) {
24
+ const key = arg.slice(2);
25
+ const next = argv[i + 1];
26
+ if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
27
+ flags[key] = next;
28
+ i += 2;
29
+ } else {
30
+ flags[key] = true;
31
+ i++;
32
+ }
33
+ } else if (arg === '-h') {
34
+ flags.help = true;
35
+ i++;
36
+ } else {
37
+ positional.push(arg);
38
+ i++;
39
+ }
40
+ }
41
+ return { positional, flags };
42
+ }
43
+
44
+ // ─── Output Helpers ──────────────────────────────────────────────────────────
45
+
46
+ /** Write a line to stdout. */
47
+ export function out(text) {
48
+ process.stdout.write(text + '\n');
49
+ }
50
+
51
+ /** Write a line to stderr and mark process for non-zero exit. */
52
+ export function fail(text) {
53
+ process.stderr.write(text + '\n');
54
+ process.exitCode = 1;
55
+ }
56
+
57
+ // ─── Time Formatting ─────────────────────────────────────────────────────────
58
+
59
+ /** "just now" / "5m ago" / "3h ago" / "2d ago" relative to now. */
60
+ export function relativeTime(epochMs) {
61
+ const diff = Date.now() - epochMs;
62
+ if (diff < 0) return 'just now';
63
+ const mins = Math.floor(diff / 60000);
64
+ if (mins < 1) return 'just now';
65
+ if (mins < 60) return `${mins}m ago`;
66
+ const hours = Math.floor(mins / 60);
67
+ if (hours < 24) return `${hours}h ago`;
68
+ const days = Math.floor(hours / 24);
69
+ return `${days}d ago`;
70
+ }
71
+
72
+ /** ISO date string → "YYYY-MM-DD" prefix. */
73
+ export function fmtDateShort(iso) {
74
+ if (!iso) return '';
75
+ return iso.slice(0, 10);
76
+ }
77
+
78
+ // ─── ID Token Parsing ────────────────────────────────────────────────────────
79
+ // Re-exported from lib/id-routing.mjs so CLI and MCP (server.mjs) share a single
80
+ // parser — parity per #8050. Keep this re-export for back-compat with the
81
+ // 5 CLI call sites that already import parseIdToken from cli/common.mjs.
82
+ export { parseIdToken } from '../lib/id-routing.mjs';
83
+
84
+ /**
85
+ * Format the shared `probeIdSources` output as CLI hint strings.
86
+ * Example: ["#5419 (obs)", "P#5417 (prompt)"] — callers join with "; ".
87
+ */
88
+ export function formatProbeHints(probe) {
89
+ const hints = [];
90
+ if (probe.obs.length > 0) hints.push(`#${probe.obs.join(', #')} (obs)`);
91
+ if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session)`);
92
+ if (probe.prompt.length > 0) hints.push(`P#${probe.prompt.join(', P#')} (prompt)`);
93
+ return hints;
94
+ }
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;
@@ -1,10 +1,29 @@
1
- // Shared probe for "ID-not-found-in-requested-source" hints.
2
- // Used by CLI cmdGet (mem-cli.mjs) and MCP mem_get (server.mjs) so both
3
- // produce consistent redirect hints if the probe schema drifts, both
4
- // paths update together.
1
+ // Shared probe for "ID-not-found-in-requested-source" hints + shared token
2
+ // parser. Used by CLI (mem-cli.mjs, cli/common.mjs re-export) and MCP
3
+ // (server.mjs) so both paths stay aligned parity per #8050.
5
4
  //
6
5
  // The formatter stays per-call-site because CLI and MCP surface format
7
- // differently (stderr vs response text); only the SQL layer is shared.
6
+ // differently (stderr vs response text); only the SQL + token-parse layers
7
+ // are shared.
8
+
9
+ // ─── ID Token Parsing ────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Parse an ID token as it appears in search output or CLI positional args.
13
+ * Accepts: `123`, `#123`, `P#123` / `p123` (prompt), `S#123` / `s123` (session).
14
+ * @param {unknown} raw
15
+ * @returns {{ source: 'obs'|'session'|'prompt'|null, id: number } | null}
16
+ * source===null means no explicit prefix — caller picks default (typically 'obs').
17
+ */
18
+ export function parseIdToken(raw) {
19
+ const m = /^([PpSs]?)#?(\d+)$/.exec(String(raw).trim());
20
+ if (!m) return null;
21
+ const p = m[1].toUpperCase();
22
+ const id = parseInt(m[2], 10);
23
+ if (!Number.isFinite(id) || id <= 0) return null;
24
+ const source = p === 'P' ? 'prompt' : p === 'S' ? 'session' : null;
25
+ return { source, id };
26
+ }
8
27
 
9
28
  /**
10
29
  * Probe the observations / session_summaries / user_prompts tables for any