claude-mem-lite 2.39.1 → 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 +21 -229
- package/package.json +8 -1
- package/schema.mjs +66 -13
- package/scripts/pre-tool-recall.js +5 -0
- package/server/fts-check.mjs +33 -0
- package/server.mjs +16 -21
- 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;
|