claude-mem-lite 3.60.0 → 3.61.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/common.mjs +45 -4
- package/hook.mjs +11 -6
- package/install.mjs +65 -10
- package/lib/binding-probe.mjs +75 -5
- package/lib/upgrade-banner.mjs +31 -0
- package/mem-cli.mjs +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/scripts/binding-probe-cli.mjs +115 -0
- package/scripts/launch.mjs +6 -2
- package/scripts/setup.sh +32 -59
- package/scripts/user-prompt-search.js +62 -3
- package/secret-scrub.mjs +14 -3
- package/server.mjs +75 -4
- package/source-files.mjs +4 -0
- package/tool-schemas.mjs +16 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.61.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.61.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/cli/common.mjs
CHANGED
|
@@ -17,6 +17,36 @@
|
|
|
17
17
|
export function parseArgs(argv) {
|
|
18
18
|
const positional = [];
|
|
19
19
|
const flags = {};
|
|
20
|
+
// Canonical flag name for a raw `--key`. Two normalizations, both aimed at the
|
|
21
|
+
// same failure: a flag nobody reads is DROPPED, and the command then answers the
|
|
22
|
+
// unfiltered question with no signal.
|
|
23
|
+
// 1. `_` → `-`: every reader in the codebase spells multi-word flags with a
|
|
24
|
+
// hyphen (`flags['include-noise']`), so `--include_noise` was inert.
|
|
25
|
+
// 2. MCP field name → CLI flag: v3.59.0 taught the CLI to accept MCP names for
|
|
26
|
+
// the required values (--content/--query/--ids) so a model can map a tool
|
|
27
|
+
// schema onto flags; the FILTER fields were left out, so `--obs_type bugfix`
|
|
28
|
+
// returned rows of every type (verified: `search redis --obs_type bugfix`
|
|
29
|
+
// surfaced the decision row that `--type bugfix` correctly excluded).
|
|
30
|
+
// An explicitly-passed canonical flag always wins over its alias.
|
|
31
|
+
const FLAG_ALIASES = {
|
|
32
|
+
'obs-type': 'type',
|
|
33
|
+
'date-from': 'from',
|
|
34
|
+
'date-to': 'to',
|
|
35
|
+
'date-since': 'since',
|
|
36
|
+
'file-path': 'file',
|
|
37
|
+
};
|
|
38
|
+
const canonicalFlag = (raw) => {
|
|
39
|
+
const hyphenated = raw.replace(/_/g, '-');
|
|
40
|
+
return FLAG_ALIASES[hyphenated] || hyphenated;
|
|
41
|
+
};
|
|
42
|
+
const setFlag = (raw, value) => {
|
|
43
|
+
const key = canonicalFlag(raw);
|
|
44
|
+
// Alias must not clobber an explicit canonical flag; a repeated canonical flag
|
|
45
|
+
// keeps last-wins (pre-existing behavior).
|
|
46
|
+
if (key !== raw.replace(/_/g, '-') && flags[key] !== undefined) return;
|
|
47
|
+
flags[key] = value;
|
|
48
|
+
};
|
|
49
|
+
|
|
20
50
|
let i = 0;
|
|
21
51
|
while (i < argv.length) {
|
|
22
52
|
const arg = argv[i];
|
|
@@ -29,17 +59,17 @@ export function parseArgs(argv) {
|
|
|
29
59
|
// applied — a save landed in the wrong project / type with no error.
|
|
30
60
|
const eq = body.indexOf('=');
|
|
31
61
|
if (eq >= 0) {
|
|
32
|
-
|
|
62
|
+
setFlag(body.slice(0, eq), body.slice(eq + 1));
|
|
33
63
|
i++;
|
|
34
64
|
continue;
|
|
35
65
|
}
|
|
36
66
|
const key = body;
|
|
37
67
|
const next = argv[i + 1];
|
|
38
68
|
if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
39
|
-
|
|
69
|
+
setFlag(key, next);
|
|
40
70
|
i += 2;
|
|
41
71
|
} else {
|
|
42
|
-
|
|
72
|
+
setFlag(key, true);
|
|
43
73
|
i++;
|
|
44
74
|
}
|
|
45
75
|
} else if (arg === '-h') {
|
|
@@ -141,6 +171,11 @@ export const KNOWN_CLI_FLAGS = new Set([
|
|
|
141
171
|
'rerank', 'resource-type', 'retain-days', 'retry', 'run', 'run-all', 'scope', 'session-audit',
|
|
142
172
|
'sidechain', 'since', 'sort', 'source', 'status', 'sweep', 'task', 'tech-stack', 'text', 'tier', 'title',
|
|
143
173
|
'to', 'trigger-patterns', 'type', 'use-cases', 'verbose',
|
|
174
|
+
// Catalogued 2026-08-13 when suggestUnknownFlags started reporting EVERY unknown
|
|
175
|
+
// flag: these are real, code-read flags that the old edit-distance gate happened to
|
|
176
|
+
// stay silent about (`adopt --disable/--enable`, `activity --min-importance`,
|
|
177
|
+
// `save --supersedes`). Verified by running each command and checking for a warning.
|
|
178
|
+
'disable', 'enable', 'min-importance', 'supersedes',
|
|
144
179
|
]);
|
|
145
180
|
|
|
146
181
|
/** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
|
|
@@ -181,7 +216,13 @@ export function suggestUnknownFlags(flags) {
|
|
|
181
216
|
const d = editDistance(key, known);
|
|
182
217
|
if (d < bestDist) { bestDist = d; best = known; }
|
|
183
218
|
}
|
|
184
|
-
|
|
219
|
+
// Report EVERY unknown flag; the suggestion is a bonus when a near-miss exists.
|
|
220
|
+
// Previously an unknown flag with no neighbour within distance 2 produced no
|
|
221
|
+
// output at all — the silent case, and the dangerous one: `--obs_type bugfix`
|
|
222
|
+
// (distance 4 from `type`) parsed, matched no reader, and the command answered
|
|
223
|
+
// the unfiltered question. A dropped filter that looks applied is worse than a
|
|
224
|
+
// typo, because the wider result set reads as the answer.
|
|
225
|
+
result.push({ flag: key, suggestion: best && bestDist <= 2 ? best : null });
|
|
185
226
|
}
|
|
186
227
|
return result;
|
|
187
228
|
}
|
package/hook.mjs
CHANGED
|
@@ -71,7 +71,7 @@ import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection,
|
|
|
71
71
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
72
72
|
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
73
73
|
import { silentAutoAdopt } from './adopt-cli.mjs';
|
|
74
|
-
import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
|
|
74
|
+
import { emitV270UpgradeBanner, hasPreV270Data } from './lib/upgrade-banner.mjs';
|
|
75
75
|
import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
|
|
76
76
|
import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
|
|
77
77
|
// plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
|
|
@@ -1389,11 +1389,16 @@ async function handleSessionStart() {
|
|
|
1389
1389
|
// deferred_work table (was: high-importance observations in v2.69.x).
|
|
1390
1390
|
// Idempotent via marker file; subsequent SessionStarts are silent.
|
|
1391
1391
|
try {
|
|
1392
|
-
// Gate on prior data: a brand-new install never had
|
|
1393
|
-
// semantics, so the migration notice is wrong noise
|
|
1394
|
-
//
|
|
1395
|
-
|
|
1396
|
-
|
|
1392
|
+
// Gate on prior data OLDER THAN v2.70.0: a brand-new install never had
|
|
1393
|
+
// v2.69.x deferred-block semantics, so the migration notice is wrong noise.
|
|
1394
|
+
// "Any observations at all" still misfired for someone who installed today
|
|
1395
|
+
// and saved a few memories before their first SessionStart — age is what
|
|
1396
|
+
// actually identifies an upgrader (see lib/upgrade-banner.mjs).
|
|
1397
|
+
emitV270UpgradeBanner({
|
|
1398
|
+
project,
|
|
1399
|
+
runtimeDir: RUNTIME_DIR,
|
|
1400
|
+
hasPriorData: hasPreV270Data(db, project),
|
|
1401
|
+
});
|
|
1397
1402
|
} catch (e) { debugCatch(e, 'session-start-v270-banner'); }
|
|
1398
1403
|
|
|
1399
1404
|
// Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
|
package/install.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
|
40
40
|
import { RESOURCE_METADATA } from './install-metadata.mjs';
|
|
41
41
|
import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
|
|
42
42
|
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
43
|
-
import { probeBetterSqlite3Binding, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
|
|
43
|
+
import { probeBetterSqlite3Binding, probeBindingInFreshProcess, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
|
|
44
44
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
45
45
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
46
46
|
import { acquireLock } from './lib/proc-lock.mjs';
|
|
@@ -259,6 +259,37 @@ let flags = new Set(process.argv.slice(3));
|
|
|
259
259
|
|
|
260
260
|
function log(msg) { console.log(` ${msg}`); }
|
|
261
261
|
function ok(msg) { console.log(` ✓ ${msg}`); }
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Recursive on-disk size of `dir`, in bytes. Bounded by `maxEntries` so a
|
|
265
|
+
* surprise-large tree can never turn a progress line into a long stat storm —
|
|
266
|
+
* returns `{ bytes, truncated }` and callers render truncated sums as "≥ N MB".
|
|
267
|
+
*
|
|
268
|
+
* @param {string} dir Directory to measure (missing dir → 0 bytes).
|
|
269
|
+
* @param {number} [maxEntries=50000] Stat budget.
|
|
270
|
+
* @returns {{bytes: number, truncated: boolean}}
|
|
271
|
+
*/
|
|
272
|
+
function dirSizeBytes(dir, maxEntries = 50000) {
|
|
273
|
+
let bytes = 0, seen = 0, truncated = false;
|
|
274
|
+
const stack = [dir];
|
|
275
|
+
while (stack.length > 0) {
|
|
276
|
+
const cur = stack.pop();
|
|
277
|
+
let entries;
|
|
278
|
+
try { entries = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
279
|
+
for (const e of entries) {
|
|
280
|
+
if (++seen > maxEntries) { truncated = true; return { bytes, truncated }; }
|
|
281
|
+
const p = join(cur, e.name);
|
|
282
|
+
if (e.isDirectory()) stack.push(p);
|
|
283
|
+
else if (e.isFile()) { try { bytes += statSync(p).size; } catch { /* raced away */ } }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { bytes, truncated };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Render a byte count as a short human string ("148 MB"). */
|
|
290
|
+
function fmtMB(bytes, truncated = false) {
|
|
291
|
+
return `${truncated ? '≥' : ''}${Math.round(bytes / 1048576)} MB`;
|
|
292
|
+
}
|
|
262
293
|
function warn(msg) { console.log(` ⚠ ${msg}`); }
|
|
263
294
|
function fail(msg) { console.log(` ✗ ${msg}`); }
|
|
264
295
|
|
|
@@ -797,6 +828,23 @@ if (process.env.CLAUDE_MEM_SKIP_REPOS) {
|
|
|
797
828
|
repos.get(r.repo).push(r);
|
|
798
829
|
}
|
|
799
830
|
|
|
831
|
+
// Disclose the cost BEFORE spending it. This step is the single largest
|
|
832
|
+
// thing `install` does — N shallow git clones over the network, ~150 MB on
|
|
833
|
+
// disk for the default manifest — and it used to announce itself only after
|
|
834
|
+
// the fact ("Repos: 15 cloned"). A first-time user on a metered link or a
|
|
835
|
+
// small disk had no warning and no visible way out; the opt-out existed but
|
|
836
|
+
// lived only in an env var no output ever mentioned.
|
|
837
|
+
// Count only repos not already on disk — a re-run/update clones nothing, and
|
|
838
|
+
// announcing "cloning 15 repos" every time would be false.
|
|
839
|
+
const repoDirName = (repoUrl) =>
|
|
840
|
+
repoUrl.split('/').slice(-2).join('-').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
841
|
+
const pendingClones = [...repos.keys()]
|
|
842
|
+
.filter(repoUrl => !existsSync(join(managedDir, 'repos', repoDirName(repoUrl)))).length;
|
|
843
|
+
if (pendingClones > 0) {
|
|
844
|
+
log(`Skill/agent registry: cloning ${pendingClones} repo(s) — network + ~150 MB on disk.`);
|
|
845
|
+
log(' Skip with CLAUDE_MEM_SKIP_REPOS=1 (memory features work without it).');
|
|
846
|
+
}
|
|
847
|
+
|
|
800
848
|
let cloned = 0, updated = 0;
|
|
801
849
|
const deadRepos = new Set(); // repos that no longer exist (404)
|
|
802
850
|
|
|
@@ -907,8 +955,10 @@ if (process.env.CLAUDE_MEM_SKIP_REPOS) {
|
|
|
907
955
|
}
|
|
908
956
|
}
|
|
909
957
|
}
|
|
958
|
+
const managedSize = dirSizeBytes(managedDir);
|
|
910
959
|
ok(`Repos: ${cloned} cloned, ${updated} updated, ${repos.size - deadRepos.size} active` +
|
|
911
|
-
(deadRepos.size > 0 ? `, ${deadRepos.size} dead removed` : '')
|
|
960
|
+
(deadRepos.size > 0 ? `, ${deadRepos.size} dead removed` : '') +
|
|
961
|
+
` (${fmtMB(managedSize.bytes, managedSize.truncated)} in ${managedDir})`);
|
|
912
962
|
|
|
913
963
|
// 6b. Init registry DB and record preinstalled entries
|
|
914
964
|
const { ensureRegistryDb } = await importFromInstall('registry.mjs');
|
|
@@ -1396,14 +1446,17 @@ async function doctor() {
|
|
|
1396
1446
|
issues++;
|
|
1397
1447
|
}
|
|
1398
1448
|
|
|
1399
|
-
// Dependencies
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1449
|
+
// Dependencies. Out of process: an in-process open of a STALE .node caches a
|
|
1450
|
+
// dead module handle for the rest of doctor and can SIGSEGV on teardown —
|
|
1451
|
+
// truncating the report of the very run the user started because things are
|
|
1452
|
+
// broken. This is also what makes the native-binding check further down
|
|
1453
|
+
// (which reads the same tree) honest rather than answering from a poisoned
|
|
1454
|
+
// process.
|
|
1455
|
+
const depProbe = probeBindingInFreshProcess(bindingHostDir());
|
|
1456
|
+
if (depProbe.ok) {
|
|
1404
1457
|
ok('better-sqlite3: verified (import + open OK)');
|
|
1405
|
-
}
|
|
1406
|
-
fail(`better-sqlite3: import/init failed (${
|
|
1458
|
+
} else {
|
|
1459
|
+
fail(`better-sqlite3: import/init failed (${String(depProbe.error).split('\n')[0]})`);
|
|
1407
1460
|
issues++;
|
|
1408
1461
|
}
|
|
1409
1462
|
|
|
@@ -1454,7 +1507,9 @@ async function doctor() {
|
|
|
1454
1507
|
// right now". A Node upgrade breaks every DB-touching path at once, so this is
|
|
1455
1508
|
// the single highest-value line in doctor when it fires.
|
|
1456
1509
|
const breakage = readNativeBindingBreakage(join(MEM_DATA_DIR, 'runtime'));
|
|
1457
|
-
|
|
1510
|
+
// Reuses the dependency probe above — same tree, same question, and doctor
|
|
1511
|
+
// should not pay for two child spawns to ask it twice.
|
|
1512
|
+
const bindingProbe = depProbe;
|
|
1458
1513
|
if (!bindingProbe.ok) {
|
|
1459
1514
|
fail(`Native DB binding: unusable (${String(bindingProbe.error).split('\n')[0]}) — run \`node ${join(PROJECT_DIR, 'cli.mjs')} rebuild-binding\``);
|
|
1460
1515
|
issues++;
|
package/lib/binding-probe.mjs
CHANGED
|
@@ -70,20 +70,90 @@ export async function probeBetterSqlite3Binding(installDir) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Probe the binding from a FRESH child process.
|
|
75
|
+
*
|
|
76
|
+
* Why this exists: better-sqlite3 dlopen's its .node lazily and Node caches the
|
|
77
|
+
* module handle process-wide, so a process that has already touched a STALE
|
|
78
|
+
* binary can never load its replacement — the retry dies with "Module did not
|
|
79
|
+
* self-register" (and, under scripts/setup.sh's probe, a segfault on the way
|
|
80
|
+
* out). Verifying a freshly rebuilt binding therefore has to happen somewhere
|
|
81
|
+
* that never saw the old one. Same constraint healAndReexec re-execs for.
|
|
82
|
+
*
|
|
83
|
+
* Synchronous (spawnSync) to match the surrounding execSync rebuild — this runs
|
|
84
|
+
* in installers and hook-adjacent scripts, never on a request path.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} installDir Directory containing node_modules/better-sqlite3
|
|
87
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
88
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
89
|
+
*/
|
|
90
|
+
export function probeBindingInFreshProcess(installDir, { timeoutMs = 30_000 } = {}) {
|
|
91
|
+
// The child catches and prints the MESSAGE on its own stdout. Two reasons it
|
|
92
|
+
// is not just an uncaught throw read off stderr: an uncaught exception's first
|
|
93
|
+
// stderr line is the stack HEADER (`node:internal/modules/cjs/loader:1520`),
|
|
94
|
+
// not the diagnostic — and this string is the highest-value line `doctor`
|
|
95
|
+
// prints — and any node warning emitted before the throw would land on stderr
|
|
96
|
+
// first and hijack it. The child's stdout is captured by spawnSync and never
|
|
97
|
+
// inherited, so writing there cannot reach a hook's JSON envelope.
|
|
98
|
+
// installDir is interpolated as a JSON string literal, so a path containing
|
|
99
|
+
// quotes/backslashes cannot break out of the script.
|
|
100
|
+
const script =
|
|
101
|
+
'try {'
|
|
102
|
+
+ 'const { createRequire } = require("node:module");'
|
|
103
|
+
+ `const D = createRequire(${JSON.stringify(join(installDir, 'package.json'))})("better-sqlite3");`
|
|
104
|
+
+ 'new D(":memory:").close();'
|
|
105
|
+
+ '} catch (e) { process.stdout.write(String((e && e.message) || e)); process.exit(1); }';
|
|
106
|
+
const r = spawnSync(process.execPath, ['-e', script], { stdio: 'pipe', timeout: timeoutMs });
|
|
107
|
+
// BEFORE the status check: spawnSync's `timeout` is SIGTERM-then-wait, not a
|
|
108
|
+
// deadline, so a child that survives the signal can still exit 0 while
|
|
109
|
+
// r.error is ETIMEDOUT. Reading status alone would call that healthy.
|
|
110
|
+
if (r.error) return { ok: false, error: r.error.message };
|
|
111
|
+
if (r.status === 0) return { ok: true };
|
|
112
|
+
// Fallbacks cover the paths where the catch never ran: a native crash (the
|
|
113
|
+
// SIGSEGV above) or a failure to spawn at all — both leave stdout empty.
|
|
114
|
+
const printed = String(r.stdout || '').trim();
|
|
115
|
+
const stderrLine = String(r.stderr || '').split('\n').map((l) => l.trim()).find(Boolean);
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: printed
|
|
119
|
+
|| stderrLine
|
|
120
|
+
|| `binding probe exited ${r.status ?? `on signal ${r.signal}`}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
73
124
|
/**
|
|
74
125
|
* Verify better-sqlite3 binding works in `installDir`; if not, run
|
|
75
126
|
* `npm rebuild better-sqlite3` and re-probe. Returns
|
|
76
127
|
* { ok: true, action: 'verified' | 'rebuilt' } on success or
|
|
77
|
-
* { ok: false, error } if rebuild can't fix it. The `probe` and
|
|
78
|
-
* deps are injectable so this can be unit-tested without a real npm
|
|
128
|
+
* { ok: false, error } if rebuild can't fix it. The `probe`, `verify` and
|
|
129
|
+
* `rebuild` deps are injectable so this can be unit-tested without a real npm
|
|
79
130
|
* subprocess.
|
|
80
131
|
*
|
|
81
132
|
* @param {string} installDir Directory containing node_modules/better-sqlite3
|
|
82
|
-
* @param {{probe?: () => Promise<{ok: boolean, error?: string}>, rebuild?: () => Promise<void>, exec?: (cmd: string, opts: object) => void}} [deps]
|
|
133
|
+
* @param {{probe?: () => Promise<{ok: boolean, error?: string}>, verify?: () => Promise<{ok: boolean, error?: string}> | {ok: boolean, error?: string}, rebuild?: () => Promise<void>, exec?: (cmd: string, opts: object) => void}} [deps]
|
|
83
134
|
* @returns {Promise<{ok: true, action: 'verified' | 'rebuilt'} | {ok: false, error: string}>}
|
|
84
135
|
*/
|
|
85
136
|
export async function ensureBetterSqlite3Working(installDir, deps = {}) {
|
|
86
|
-
|
|
137
|
+
// BOTH probes run out of process by default, because a probe must never
|
|
138
|
+
// poison the process that has to act on its answer. Loading a stale .node
|
|
139
|
+
// caches a dead module handle process-wide (and can SIGSEGV on teardown), so
|
|
140
|
+
// an in-process probe→rebuild→re-probe cycle always ends in "Module did not
|
|
141
|
+
// self-register" — reporting a SUCCESSFUL rebuild as a failure. Measured
|
|
142
|
+
// 2026-08-13 on a real ABI 127-under-137 tree: this function returned
|
|
143
|
+
// {ok:false} while the rebuilt .node loaded fine in a fresh process. Every
|
|
144
|
+
// caller keyed state on that lie — setup.sh wrote .deps-broken over a healthy
|
|
145
|
+
// install, install.mjs::rebuildBinding skipped clearNativeBindingBreakage so
|
|
146
|
+
// the launcher re-spawned npm every 6h forever, and `rebuild-binding` exited 1.
|
|
147
|
+
//
|
|
148
|
+
// Isolating only the SECOND probe is not enough: scripts/launch.mjs imports
|
|
149
|
+
// the MCP server into this very process right after a successful rebuild, so a
|
|
150
|
+
// first probe that dlopened the stale binary would hand the server the dead
|
|
151
|
+
// handle. Cost is one ~40ms spawn on a path that already runs npm.
|
|
152
|
+
//
|
|
153
|
+
// `deps.probe` alone still drives BOTH probes: injected-stub tests keep their
|
|
154
|
+
// existing semantics.
|
|
155
|
+
const probe = deps.probe || (() => probeBindingInFreshProcess(installDir));
|
|
156
|
+
const verify = deps.verify || deps.probe || (() => probeBindingInFreshProcess(installDir));
|
|
87
157
|
// Bounded by default: a node-gyp fallback that stalls (no compiler, a hung
|
|
88
158
|
// registry fetch) must not hang the caller forever — the CLI blocks a user at
|
|
89
159
|
// the terminal, and scripts/setup.sh passes an even tighter 20s cap because it
|
|
@@ -117,7 +187,7 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
|
|
|
117
187
|
return { ok: false, error: `rebuild failed: ${e.message}` };
|
|
118
188
|
}
|
|
119
189
|
|
|
120
|
-
const second = await
|
|
190
|
+
const second = await verify();
|
|
121
191
|
if (second.ok) return { ok: true, action: 'rebuilt' };
|
|
122
192
|
|
|
123
193
|
return { ok: false, error: second.error || first.error };
|
package/lib/upgrade-banner.mjs
CHANGED
|
@@ -7,6 +7,37 @@
|
|
|
7
7
|
import { writeFileSync, existsSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
|
|
10
|
+
// v2.70.0 shipped 2026-05-10 (CHANGELOG "v2.70.0 — first-class deferred work").
|
|
11
|
+
// Only observations that already existed then could have been rendered under the
|
|
12
|
+
// v2.69.x deferred-block semantics this banner describes.
|
|
13
|
+
export const V270_RELEASE_EPOCH = Date.UTC(2026, 4, 10);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when the project holds observations old enough to have lived under the
|
|
17
|
+
* v2.69.x deferred-block semantics.
|
|
18
|
+
*
|
|
19
|
+
* The original guard was `any observations at all`, which still fires for someone
|
|
20
|
+
* who installed today and saved a few memories before their first SessionStart —
|
|
21
|
+
* they get a migration notice about a release 40+ versions back, ending in "Pin to
|
|
22
|
+
* 2.69.x to revert" (advice that would downgrade them past every feature they just
|
|
23
|
+
* installed). Age is the property that actually distinguishes an upgrader.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} db Open better-sqlite3 handle.
|
|
26
|
+
* @param {string} project Project name.
|
|
27
|
+
* @returns {boolean} false on any query failure — suppressing a stale banner is the
|
|
28
|
+
* safe direction (§ fail-quiet: a missed notice costs less than a wrong one).
|
|
29
|
+
*/
|
|
30
|
+
export function hasPreV270Data(db, project) {
|
|
31
|
+
try {
|
|
32
|
+
const row = db.prepare(
|
|
33
|
+
'SELECT 1 AS hit FROM observations WHERE project = ? AND created_at_epoch < ? LIMIT 1'
|
|
34
|
+
).get(project, V270_RELEASE_EPOCH);
|
|
35
|
+
return !!row;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
/**
|
|
11
42
|
* One-shot stderr banner on first SessionStart after v2.70.0 upgrade.
|
|
12
43
|
* Notifies users that the `### Deferred Work` block now reads from the
|
package/mem-cli.mjs
CHANGED
|
@@ -3192,7 +3192,9 @@ export async function run(argv) {
|
|
|
3192
3192
|
// when a flag looks like a misspelling of a real one; stdout + exit code stay untouched,
|
|
3193
3193
|
// so JSON/text consumers are unaffected. Mirrors the unknown-COMMAND suggester in cli.mjs.
|
|
3194
3194
|
for (const { flag, suggestion } of suggestUnknownFlags(parseArgs(cmdArgs).flags)) {
|
|
3195
|
-
process.stderr.write(
|
|
3195
|
+
process.stderr.write(suggestion
|
|
3196
|
+
? `[mem] Unknown flag --${flag}; did you mean --${suggestion}?\n`
|
|
3197
|
+
: `[mem] Unknown flag --${flag} — ignored (it filtered nothing). Run "claude-mem-lite help" for this command's flags.\n`);
|
|
3196
3198
|
}
|
|
3197
3199
|
|
|
3198
3200
|
// adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.61.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.61.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.61.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -158,6 +158,7 @@
|
|
|
158
158
|
"scripts/pre-agent-inject.js",
|
|
159
159
|
"scripts/prompt-search-utils.mjs",
|
|
160
160
|
"scripts/hook-launcher.mjs",
|
|
161
|
+
"scripts/binding-probe-cli.mjs",
|
|
161
162
|
".mcp.json",
|
|
162
163
|
".claude-plugin/plugin.json",
|
|
163
164
|
".claude-plugin/marketplace.json",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/binding-probe-cli.mjs — SessionStart native-binding probe + bounded heal.
|
|
3
|
+
//
|
|
4
|
+
// Contract with scripts/setup.sh: exit 0 = binding usable NOW, non-zero = not.
|
|
5
|
+
// Everything human-readable goes to stderr — SessionStart stdout is a JSON
|
|
6
|
+
// envelope Claude Code parses, so this must never write there.
|
|
7
|
+
//
|
|
8
|
+
// Extracted (v3.60.1) from an inline `node --input-type=module -e '…'` string
|
|
9
|
+
// inside setup.sh. Two load-bearing reasons, both observed rather than assumed:
|
|
10
|
+
// • That form CRASHED. The -e process SIGSEGV'd during exit after a
|
|
11
|
+
// verified-good rebuild (Node v24.18, ~50% of runs, no fatal-error report
|
|
12
|
+
// produced), so a SUCCESSFUL heal exited 139 and setup.sh wrote
|
|
13
|
+
// .deps-broken over a healthy install. install.mjs::rebuildBinding doing
|
|
14
|
+
// the same work as a normal module never reproduced it.
|
|
15
|
+
// • It could not contain an apostrophe. The script was interpolated into a
|
|
16
|
+
// single-quoted bash string, so a single `don't` in a comment truncated the
|
|
17
|
+
// shell command — a footgun that fired during development of this fix.
|
|
18
|
+
//
|
|
19
|
+
// Rebuilds are bounded to 20s because setup.sh runs under the SessionStart hook
|
|
20
|
+
// cap (hooks/hooks.json timeout 30): letting the hook SIGKILL a mid-flight
|
|
21
|
+
// node-gyp would leave a partial .node with no flag written. On lock-miss or
|
|
22
|
+
// timeout, mark broken and defer to the MCP launch path (no cap, same lock).
|
|
23
|
+
|
|
24
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const ROOT = process.env.PROBE_ROOT || join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
|
|
30
|
+
// Fallback for a half-installed tree where lib/ helpers are absent: a bare
|
|
31
|
+
// probe with no rebuild. A WORKING binding must still be able to clear the
|
|
32
|
+
// broken flag, and a helperless broken tree is repaired by the hook-launcher
|
|
33
|
+
// path instead. Out of process like every other probe here — loading a stale
|
|
34
|
+
// .node caches a dead module handle for the rest of THIS process.
|
|
35
|
+
function bareProbe(root) {
|
|
36
|
+
const script =
|
|
37
|
+
'try {'
|
|
38
|
+
+ 'const { createRequire } = require("node:module");'
|
|
39
|
+
+ `const D = createRequire(${JSON.stringify(join(root, 'package.json'))})("better-sqlite3");`
|
|
40
|
+
+ 'new D(":memory:").close();'
|
|
41
|
+
+ '} catch (e) { process.stdout.write(String((e && e.message) || e)); process.exit(1); }';
|
|
42
|
+
const r = spawnSync(process.execPath, ['-e', script], { stdio: 'pipe', timeout: 8000 });
|
|
43
|
+
if (!r.error && r.status === 0) return true;
|
|
44
|
+
// Say WHY. The inline predecessor printed the cause here; dropping it left the
|
|
45
|
+
// user with setup.sh's generic "binding unusable" and nothing to act on.
|
|
46
|
+
const why = String(r.stdout || '').trim().split('\n')[0]
|
|
47
|
+
|| (r.error && r.error.message)
|
|
48
|
+
|| `probe exited ${r.status ?? `on signal ${r.signal}`}`;
|
|
49
|
+
process.stderr.write(`[claude-mem-lite] binding probe: ${why}\n`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let helpers = null;
|
|
54
|
+
try {
|
|
55
|
+
const [probeMod, lockMod, dirMod] = await Promise.all(
|
|
56
|
+
['binding-probe.mjs', 'proc-lock.mjs', 'resolve-data-dir.mjs']
|
|
57
|
+
.map((f) => import(pathToFileURL(join(ROOT, 'lib', f)).href)),
|
|
58
|
+
);
|
|
59
|
+
helpers = { ...probeMod, ...lockMod, ...dirMod };
|
|
60
|
+
} catch {
|
|
61
|
+
process.exit(bareProbe(ROOT) ? 0 : 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Probe first — read-only, no lock needed. A healthy binding exits here.
|
|
65
|
+
// Bounded like every other step: the whole script runs under the SessionStart
|
|
66
|
+
// hook cap (hooks/hooks.json timeout 30) and an unbounded probe would spend the
|
|
67
|
+
// entire budget before the rebuild it exists to trigger.
|
|
68
|
+
const first = helpers.probeBindingInFreshProcess(ROOT, { timeoutMs: 8000 });
|
|
69
|
+
if (first.ok) process.exit(0);
|
|
70
|
+
|
|
71
|
+
// Broken: rebuild ONLY under the shared install.lock. A second MCP launch or an
|
|
72
|
+
// install.mjs repair rebuilding the same node_modules concurrently can tear the
|
|
73
|
+
// .node mid-compile.
|
|
74
|
+
let lockPath;
|
|
75
|
+
try {
|
|
76
|
+
lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock');
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// resolveDataDir THROWS on a non-absolute CLAUDE_MEM_DIR. Unhandled, that
|
|
79
|
+
// prints an 8-line rejection stack onto SessionStart stderr; one line is enough.
|
|
80
|
+
process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const release = helpers.acquireLock(lockPath);
|
|
84
|
+
if (!release) {
|
|
85
|
+
const firstLine = String(first.error).split('\n')[0];
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[claude-mem-lite] binding probe: ${firstLine} (another install/repair in flight — deferring heal)\n`,
|
|
88
|
+
);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
result = await helpers.ensureBetterSqlite3Working(ROOT, {
|
|
95
|
+
// Reuse the probe we already paid for. Without this, ensure() spawns its own
|
|
96
|
+
// default first probe — a second identical child on the critical path, under
|
|
97
|
+
// a hook cap, to re-learn what `first` already says.
|
|
98
|
+
probe: () => first,
|
|
99
|
+
exec: (cmd, opts) => execSync(cmd, { ...opts, timeout: 20000 }),
|
|
100
|
+
verify: () => helpers.probeBindingInFreshProcess(ROOT, { timeoutMs: 8000 }),
|
|
101
|
+
});
|
|
102
|
+
} finally {
|
|
103
|
+
// process.exit skips finally blocks — every exit below this point, so the
|
|
104
|
+
// lock is always released.
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!result.ok) {
|
|
109
|
+
process.stderr.write(`[claude-mem-lite] binding probe: ${result.error}\n`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
if (result.action === 'rebuilt') {
|
|
113
|
+
process.stderr.write('[claude-mem-lite] rebuilt better-sqlite3 binding for current Node ABI\n');
|
|
114
|
+
}
|
|
115
|
+
process.exit(0);
|
package/scripts/launch.mjs
CHANGED
|
@@ -36,7 +36,7 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
|
|
|
36
36
|
// intact but the .node binary stale → server FATALs with "Could not locate
|
|
37
37
|
// the bindings file" on first DB open. Probe + auto-rebuild before launching.
|
|
38
38
|
try {
|
|
39
|
-
const { ensureBetterSqlite3Working,
|
|
39
|
+
const { ensureBetterSqlite3Working, probeBindingInFreshProcess } = await import('../lib/binding-probe.mjs');
|
|
40
40
|
// The rebuild inside ensureBetterSqlite3Working mutates node_modules — the
|
|
41
41
|
// same write class as install/repair/update, and this was the ONE rebuild
|
|
42
42
|
// path outside the shared install.lock: a second MCP launch or a concurrent
|
|
@@ -56,7 +56,11 @@ try {
|
|
|
56
56
|
if (release) {
|
|
57
57
|
verify = await ensureBetterSqlite3Working(ROOT);
|
|
58
58
|
} else {
|
|
59
|
-
|
|
59
|
+
// Out of process, like the rebuild-capable branch above: this process goes
|
|
60
|
+
// on to import the MCP server, and a stale .node loaded here would leave a
|
|
61
|
+
// dead module handle cached for it. Also keeps the exit(1) guidance below
|
|
62
|
+
// reachable — an in-process load of a stale binding can SIGSEGV instead.
|
|
63
|
+
const probe = probeBindingInFreshProcess(ROOT);
|
|
60
64
|
verify = probe.ok
|
|
61
65
|
? { ok: true, action: 'verified' }
|
|
62
66
|
: { ok: false, error: `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)` };
|
package/scripts/setup.sh
CHANGED
|
@@ -136,65 +136,38 @@ fi
|
|
|
136
136
|
# stat, a new plugin-cache version dir (fresh node_modules) re-probes, and
|
|
137
137
|
# a Node upgrade (new ABI) re-probes. While broken, every SessionStart
|
|
138
138
|
# retries the rebuild until it heals.
|
|
139
|
-
#
|
|
139
|
+
# Delegates to scripts/binding-probe-cli.mjs — a real module file, NOT an inline
|
|
140
|
+
# `node -e` string. That inline form SIGSEGV'd during exit after a verified-good
|
|
141
|
+
# rebuild (Node v24.18, ~50% of runs), so a successful heal returned 139 and this
|
|
142
|
+
# branch recorded .deps-broken over a healthy install; it also could not contain
|
|
143
|
+
# an apostrophe without truncating the shell command. See that file's header.
|
|
144
|
+
# Contract: exit 0 = binding usable now.
|
|
140
145
|
probe_binding() {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
// Probe first — read-only, no lock needed. Healthy binding exits here.
|
|
168
|
-
const first = await helpers.probeBetterSqlite3Binding(root);
|
|
169
|
-
if (first.ok) process.exit(0);
|
|
170
|
-
// Broken: rebuild ONLY under the shared install.lock (a second MCP launch
|
|
171
|
-
// or install.mjs repair rebuilding the same node_modules concurrently can
|
|
172
|
-
// tear the .node), and with the exec bounded to 20s — this script runs
|
|
173
|
-
// under the SessionStart hook cap (hooks.json timeout 30), and letting the
|
|
174
|
-
// hook SIGKILL a mid-flight node-gyp leaves a partial .node with no flag
|
|
175
|
-
// written. On lock-miss or timeout: mark broken and defer the heal to the
|
|
176
|
-
// MCP launch path (no hook cap, same lock).
|
|
177
|
-
const lockPath = join(helpers.resolveDataDir(process.env.CLAUDE_MEM_DIR), "runtime", "install.lock");
|
|
178
|
-
const release = helpers.acquireLock(lockPath);
|
|
179
|
-
if (!release) {
|
|
180
|
-
const firstLine = String(first.error).split("\n")[0];
|
|
181
|
-
process.stderr.write(`[claude-mem-lite] binding probe: ${firstLine} (another install/repair in flight — deferring heal)\n`);
|
|
182
|
-
process.exit(1);
|
|
183
|
-
}
|
|
184
|
-
let r;
|
|
185
|
-
try {
|
|
186
|
-
const { execSync } = await import("node:child_process");
|
|
187
|
-
r = await helpers.ensureBetterSqlite3Working(root, {
|
|
188
|
-
exec: (cmd, opts) => execSync(cmd, { ...opts, timeout: 20000 }),
|
|
189
|
-
});
|
|
190
|
-
} finally {
|
|
191
|
-
// process.exit skips finally blocks — exits live BELOW this so the
|
|
192
|
-
// lock is always released.
|
|
193
|
-
release();
|
|
194
|
-
}
|
|
195
|
-
if (!r.ok) { process.stderr.write(`[claude-mem-lite] binding probe: ${r.error}\n`); process.exit(1); }
|
|
196
|
-
if (r.action === "rebuilt") process.stderr.write("[claude-mem-lite] rebuilt better-sqlite3 binding for current Node ABI\n");
|
|
197
|
-
'
|
|
146
|
+
# Absent on a truncated tree: without this guard node prints a full
|
|
147
|
+
# MODULE_NOT_FOUND stack onto SessionStart stderr on every marker-miss.
|
|
148
|
+
[[ -f "$ROOT/scripts/binding-probe-cli.mjs" ]] || return 1
|
|
149
|
+
# stdout muted: this child runs `npm rebuild`, and SessionStart stdout is a
|
|
150
|
+
# JSON envelope Claude Code parses. Nothing writes there today (verified) —
|
|
151
|
+
# this keeps a future non-piped exec from being able to.
|
|
152
|
+
PROBE_ROOT="$ROOT" node "$ROOT/scripts/binding-probe-cli.mjs" >/dev/null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# Ground truth about the binding, independent of how the healer above exited.
|
|
156
|
+
# Belt-and-braces: moving the probe out of the inline `node -e` string removed
|
|
157
|
+
# the observed SIGSEGV, but the healer's exit code is a PROXY for the question
|
|
158
|
+
# that actually matters, and a proxy can lie again (a future crash, a partial
|
|
159
|
+
# state, a kill at the hook cap). So ask the real question in a fresh process:
|
|
160
|
+
# can we open a DB right now? A false .deps-broken here is not cosmetic — it
|
|
161
|
+
# renders a "hooks degraded" banner into the user's session over a healthy
|
|
162
|
+
# install. Costs one ~50ms spawn, and only off the marker fast-path.
|
|
163
|
+
# shellcheck disable=SC2016 # node script single-quoted on purpose; ROOT passed via env, not shell expansion
|
|
164
|
+
binding_usable() {
|
|
165
|
+
VERIFY_ROOT="$ROOT" node -e '
|
|
166
|
+
const { createRequire } = require("node:module");
|
|
167
|
+
const { join } = require("node:path");
|
|
168
|
+
const D = createRequire(join(process.env.VERIFY_ROOT, "package.json"))("better-sqlite3");
|
|
169
|
+
new D(":memory:").close();
|
|
170
|
+
' >/dev/null 2>&1
|
|
198
171
|
}
|
|
199
172
|
|
|
200
173
|
if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
|
|
@@ -202,7 +175,7 @@ if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
|
|
|
202
175
|
BINDING_MARKER="$ROOT/node_modules/.mem-binding-ok-$NODE_ABI"
|
|
203
176
|
if [[ -f "$BINDING_MARKER" ]]; then
|
|
204
177
|
mark_deps_ok
|
|
205
|
-
elif probe_binding; then
|
|
178
|
+
elif probe_binding || binding_usable; then
|
|
206
179
|
rm -f "$ROOT/node_modules/.mem-binding-ok-"* 2>/dev/null || true
|
|
207
180
|
touch "$BINDING_MARKER" 2>/dev/null || true
|
|
208
181
|
mark_deps_ok
|
|
@@ -114,6 +114,60 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
|
|
|
114
114
|
? 0
|
|
115
115
|
: Number(process.env.CLAUDE_MEM_UPS_OR_BM25_MIN || 30);
|
|
116
116
|
|
|
117
|
+
// ─── Corpus-size normalization of the absolute floors (v3.60.2) ─────────────
|
|
118
|
+
//
|
|
119
|
+
// Both floors above are ABSOLUTE magnitudes, but the quantity they gate is not
|
|
120
|
+
// scale-free: FTS5 bm25 carries an IDF term ≈ ln(N/df), so the SAME hit scores
|
|
121
|
+
// higher on a bigger index. Measured on one fixed query + one fixed target row,
|
|
122
|
+
// padding the corpus with distinct filler (2026-08-13 dogfood):
|
|
123
|
+
//
|
|
124
|
+
// totalObs 10 40 100 300
|
|
125
|
+
// top|bm25| 10.0 18.6 24.2 30.7 ← same row, same query
|
|
126
|
+
//
|
|
127
|
+
// The floors were calibrated at `projects--mem, 584 obs` (CHANGELOG v2.43.x /
|
|
128
|
+
// v2.34.3). Comparing a log-N quantity against that constant therefore does not
|
|
129
|
+
// mean "weak match" on a small index — it means "small index". A brand-new
|
|
130
|
+
// install measured 0/8 injections on a realistic first-day corpus (10 memories,
|
|
131
|
+
// 8 recall questions whose correct target ranked #1 in 4/5 scored cases): every
|
|
132
|
+
// one was dropped by the OR floor at |bm25| 3.8–15.2 < 30. The plugin is inert
|
|
133
|
+
// during exactly the window where a new user decides whether it earns its keep.
|
|
134
|
+
//
|
|
135
|
+
// Fix: scale both floors by ln(N+1)/ln(N_REF+1), capped at 1.0 — the same log
|
|
136
|
+
// shape the IDF term has, so the SIGNAL↔NOISE separation the maintainer measured
|
|
137
|
+
// (signal ≥41, noise ≤22 at N_REF) is preserved proportionally at any N. At
|
|
138
|
+
// N ≥ N_REF the factor is exactly 1.0, so every established install keeps
|
|
139
|
+
// byte-identical behavior; only genuinely-new installs relax.
|
|
140
|
+
//
|
|
141
|
+
// N counts the WHOLE observations table, not the project: FTS5 computes IDF over
|
|
142
|
+
// the entire index and `o.project = ?` is a post-MATCH filter. Verified — a
|
|
143
|
+
// 2-row project on a 302-row install scores 31.5, matching the 300-row global
|
|
144
|
+
// baseline, not the 10-row one. So a new project on an established install is
|
|
145
|
+
// (correctly) unaffected by this ramp.
|
|
146
|
+
const FLOOR_REF_CORPUS = Number(process.env.CLAUDE_MEM_UPS_FLOOR_REF_CORPUS || 584);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Scale factor in (0, 1] for the absolute score floors, by total corpus size.
|
|
150
|
+
*
|
|
151
|
+
* Short-circuits with a bounded probe: if a row exists at offset N_REF-1 the
|
|
152
|
+
* corpus is at or above the reference and the factor is 1.0 — no COUNT scan on
|
|
153
|
+
* the large corpora where the answer is always 1.0 anyway.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} db Open better-sqlite3 handle.
|
|
156
|
+
* @returns {number} Multiplier for TOP_REL_FLOOR / OR_TOP_BM25_FLOOR.
|
|
157
|
+
*/
|
|
158
|
+
export function corpusFloorScale(db) {
|
|
159
|
+
if (FLOOR_REF_CORPUS <= 1) return 1;
|
|
160
|
+
try {
|
|
161
|
+
const atRef = db.prepare('SELECT 1 FROM observations LIMIT 1 OFFSET ?').get(FLOOR_REF_CORPUS - 1);
|
|
162
|
+
if (atRef) return 1;
|
|
163
|
+
const { c = 0 } = db.prepare('SELECT count(*) AS c FROM observations').get() || {};
|
|
164
|
+
return Math.min(1, Math.log(c + 1) / Math.log(FLOOR_REF_CORPUS + 1));
|
|
165
|
+
} catch {
|
|
166
|
+
// Any probe failure → behave exactly as before the ramp existed.
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
117
171
|
function isFollowUpSession() {
|
|
118
172
|
try {
|
|
119
173
|
const raw = readFileSync(INJECTED_IDS_FILE, 'utf8');
|
|
@@ -735,9 +789,14 @@ async function main() {
|
|
|
735
789
|
// is a precision signal and routinely produces legitimate AND hits
|
|
736
790
|
// below raw |bm25|=20 that we do not want to drop (see GOOD-narrow
|
|
737
791
|
// probe). Skip gate when OR_TOP_BM25_FLOOR is set to 0 (test hook).
|
|
738
|
-
|
|
792
|
+
// Both absolute floors are normalized by corpus size (corpusFloorScale) —
|
|
793
|
+
// factor 1.0 for any install at/above the calibration corpus, so this is a
|
|
794
|
+
// no-op for established users and a proportional relaxation for new ones.
|
|
795
|
+
const floorScale = corpusFloorScale(db);
|
|
796
|
+
const orFloor = OR_TOP_BM25_FLOOR * floorScale;
|
|
797
|
+
if (ftsMode === 'OR' && orFloor > 0 && ftsRows.length > 0) {
|
|
739
798
|
const topBm25 = Math.abs(ftsRows[0].bm25_raw || 0);
|
|
740
|
-
if (topBm25 <
|
|
799
|
+
if (topBm25 < orFloor) ftsRows = [];
|
|
741
800
|
}
|
|
742
801
|
|
|
743
802
|
// v2.34.3: top-|rel| sanity gate. Per-row filtering above leaves noise
|
|
@@ -746,7 +805,7 @@ async function main() {
|
|
|
746
805
|
// whole FTS set — noise prompts should produce no FTS injection.
|
|
747
806
|
// Query orders by `relevance` ASC; negative values → ftsRows[0] has the
|
|
748
807
|
// largest magnitude (strongest match) in this scoring expression.
|
|
749
|
-
if (ftsRows.length > 0 && Math.abs(ftsRows[0].relevance) < TOP_REL_FLOOR) {
|
|
808
|
+
if (ftsRows.length > 0 && Math.abs(ftsRows[0].relevance) < TOP_REL_FLOOR * floorScale) {
|
|
750
809
|
ftsRows = [];
|
|
751
810
|
}
|
|
752
811
|
|
package/secret-scrub.mjs
CHANGED
|
@@ -30,8 +30,18 @@ export const SECRET_PATTERNS = [
|
|
|
30
30
|
// value is covered (the hex-only assignment pattern below misses non-hex values).
|
|
31
31
|
// 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
|
|
32
32
|
[/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
33
|
-
// 1b. `:` separator →
|
|
34
|
-
|
|
33
|
+
// 1b. `:` separator, PASSWORD nouns → always scrub. The prose lookbehind below
|
|
34
|
+
// was originally applied to the whole noun class, which meant any credential
|
|
35
|
+
// noun preceded by an English word escaped — so a session narrative like
|
|
36
|
+
// "deployed to staging, the db password: hunter2correct" persisted the
|
|
37
|
+
// password in plaintext and re-injected it into every later context block
|
|
38
|
+
// (R5 dogfood, 2026-08-13). Unlike `token`/`bearer`/`secret`, the pinned
|
|
39
|
+
// prose set (#8283) contains no `password|passwd|passphrase` case: writing
|
|
40
|
+
// "<word> password: <6+ chars>" names a credential, it is not conversational
|
|
41
|
+
// usage. Letter-glued non-keywords (`mypassword:`) still miss via `(?:\b|_)`.
|
|
42
|
+
[/((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
43
|
+
// 1c. `:` separator, prose-ambiguous nouns → keep the lookbehind ("the token: alice"):
|
|
44
|
+
[/((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
35
45
|
// access_token / refresh_token are the canonical OAuth2 field names — they were
|
|
36
46
|
// missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
|
|
37
47
|
// underscore-prefix reason.
|
|
@@ -61,7 +71,8 @@ export const SECRET_PATTERNS = [
|
|
|
61
71
|
// (mirrors the unquoted 1a/1b split — a quoted value doesn't turn `:` prose
|
|
62
72
|
// into config, but `<word> password="x"` is still a leak):
|
|
63
73
|
[/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
64
|
-
[/((
|
|
74
|
+
[/((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
75
|
+
[/((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
65
76
|
// (b) structured keys + named env vars are unambiguous config even after a word
|
|
66
77
|
// (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
|
|
67
78
|
[/((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
package/server.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
|
28
28
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
29
|
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
30
30
|
import { formatObsFieldValue } from './cli/common.mjs';
|
|
31
|
+
import { neutralizeContextDelimiters } from './format-utils.mjs';
|
|
31
32
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
32
33
|
|
|
33
34
|
// Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
|
|
@@ -157,14 +158,76 @@ const server = new McpServer(
|
|
|
157
158
|
let lastMcpRequestTime = Date.now();
|
|
158
159
|
let idleCleanupRan = false;
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Defang structural context delimiters in every text block of a tools/call result.
|
|
163
|
+
*
|
|
164
|
+
* A tools/call payload IS model context — unlike CLI stdout there is no human between
|
|
165
|
+
* the DB row and the transcript. Observations are stored raw on purpose (defense lives
|
|
166
|
+
* at the injection boundary, not at save), and every HOOK surface already neutralizes
|
|
167
|
+
* before writing to the model (buildSessionContextLines / formatMemoryLine /
|
|
168
|
+
* formatErrorRecallHints / renderHandoffFromRow / pre-tool-recall). The MCP read tools
|
|
169
|
+
* were the one model-facing family left raw, so a memory carrying a forged
|
|
170
|
+
* `<system-reminder>` replayed verbatim into a mem_search result — reinstating exactly
|
|
171
|
+
* the channel the hook-side defang closes. Applied at the single handler chokepoint so
|
|
172
|
+
* a newly registered tool is covered by construction (§9 parallel-path completeness).
|
|
173
|
+
*
|
|
174
|
+
* Error payloads go through it too: `err.message` can echo caller-supplied text.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} result Tool result ({ content: [{type,text}], … }).
|
|
177
|
+
* @returns {object} Same shape with text blocks neutralized.
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* Fold CLI-flag aliases onto their canonical MCP field names.
|
|
181
|
+
*
|
|
182
|
+
* The schemas declare both spellings (see tool-schemas.mjs); this is where the alias
|
|
183
|
+
* actually takes effect. Canonical wins when both are present — an explicit canonical
|
|
184
|
+
* value is the more specific intent, and silently letting an alias override it would
|
|
185
|
+
* reintroduce the same class of surprise the aliases exist to remove.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} args Raw validated tool arguments.
|
|
188
|
+
* @param {Record<string,string>} pairs alias → canonical field name.
|
|
189
|
+
* @returns {object} New args object (never mutates the caller's).
|
|
190
|
+
*/
|
|
191
|
+
function applyArgAliases(args, pairs) {
|
|
192
|
+
if (!args || typeof args !== 'object') return args;
|
|
193
|
+
let next = args;
|
|
194
|
+
for (const [alias, canonical] of Object.entries(pairs)) {
|
|
195
|
+
if (next[alias] !== undefined && next[canonical] === undefined) {
|
|
196
|
+
if (next === args) next = { ...args };
|
|
197
|
+
next[canonical] = next[alias];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return next;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function defangResult(result) {
|
|
204
|
+
if (!result || !Array.isArray(result.content)) return result;
|
|
205
|
+
return {
|
|
206
|
+
...result,
|
|
207
|
+
content: result.content.map(c =>
|
|
208
|
+
c && c.type === 'text' && typeof c.text === 'string'
|
|
209
|
+
? { ...c, text: neutralizeContextDelimiters(c.text) }
|
|
210
|
+
: c
|
|
211
|
+
),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {Function} fn Tool handler.
|
|
217
|
+
* @param {object} [opts]
|
|
218
|
+
* @param {boolean} [opts.verbatim=false] Skip the defang pass. Only for payloads that
|
|
219
|
+
* must round-trip byte-exact — `mem_export` feeds `restore`, so neutralizing it would
|
|
220
|
+
* silently corrupt backups of any memory that legitimately discusses these tags.
|
|
221
|
+
*/
|
|
222
|
+
function safeHandler(fn, { verbatim = false } = {}) {
|
|
161
223
|
return async (args, extra) => {
|
|
162
224
|
try {
|
|
163
225
|
lastMcpRequestTime = Date.now();
|
|
164
226
|
idleCleanupRan = false;
|
|
165
|
-
|
|
227
|
+
const result = await fn(args, extra);
|
|
228
|
+
return verbatim ? result : defangResult(result);
|
|
166
229
|
} catch (err) {
|
|
167
|
-
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
230
|
+
return defangResult({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
|
|
168
231
|
}
|
|
169
232
|
};
|
|
170
233
|
}
|
|
@@ -258,6 +321,10 @@ export async function handleSearchForTest(db, args, { llm, rerankLlm } = {}) {
|
|
|
258
321
|
|
|
259
322
|
async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
|
|
260
323
|
if (args.project) args = { ...args, project: _resolveProjectShared(db, args.project) };
|
|
324
|
+
// CLI-flag aliases: --source/--from/--to/--since. Folded before any read of the
|
|
325
|
+
// canonical names below, so every downstream filter sees them.
|
|
326
|
+
args = applyArgAliases(args, { source: 'type', from: 'date_from', to: 'date_to', since: 'date_since' });
|
|
327
|
+
|
|
261
328
|
const limit = args.limit ?? 20;
|
|
262
329
|
const offset = args.offset ?? 0;
|
|
263
330
|
// args.or: force OR from the start (CLI `search --or` parity). The default path
|
|
@@ -390,6 +457,8 @@ export async function handleRecentForTest(db, args) {
|
|
|
390
457
|
}
|
|
391
458
|
|
|
392
459
|
async function runRecent(db, args) {
|
|
460
|
+
// CLI-flag aliases: `recent --type` is the OBSERVATION type here, `--since` the window.
|
|
461
|
+
args = applyArgAliases(args, { type: 'obs_type', since: 'date_since' });
|
|
393
462
|
if (args.project) args = { ...args, project: _resolveProjectShared(db, args.project) };
|
|
394
463
|
const limit = args.limit ?? 10;
|
|
395
464
|
const project = args.project || inferProject();
|
|
@@ -1674,7 +1743,9 @@ server.registerTool(
|
|
|
1674
1743
|
description: descriptionOf('mem_export'),
|
|
1675
1744
|
inputSchema: memExportSchema,
|
|
1676
1745
|
},
|
|
1677
|
-
|
|
1746
|
+
// verbatim: the export payload feeds `restore` — defanging it would silently
|
|
1747
|
+
// rewrite backed-up rows whose text legitimately contains these tags.
|
|
1748
|
+
safeHandler(async (args) => runExport(db, args), { verbatim: true })
|
|
1678
1749
|
);
|
|
1679
1750
|
|
|
1680
1751
|
// ─── Tool: mem_recall ────────────────────────────────────────────────────────
|
package/source-files.mjs
CHANGED
|
@@ -250,6 +250,9 @@ export const HOOK_SCRIPT_FILES = [
|
|
|
250
250
|
// as the MCP server. v3.42 audit HIGH-1.
|
|
251
251
|
// - setup.sh: run on plugin SessionStart via hooks.json. Signed for defense-in-depth
|
|
252
252
|
// (no tarball→executed-path propagation today, but it ships in files[] and is executed).
|
|
253
|
+
// - binding-probe-cli.mjs: setup.sh spawns it on every SessionStart that misses the ABI
|
|
254
|
+
// marker, and it runs `npm rebuild` — an unsigned copy would be arbitrary code executed
|
|
255
|
+
// at session start with a build step attached. Same class as setup.sh itself.
|
|
253
256
|
// These ship via package.json files[] directly, not via HOOK_SCRIPT_FILES' copy path, so
|
|
254
257
|
// listing them here changes ONLY what is signed/verified, not what install materializes.
|
|
255
258
|
// Module-internal (spread into RELEASE_SIGNED_FILES below); not exported — no external
|
|
@@ -258,6 +261,7 @@ const LAUNCHER_SCRIPT_FILES = [
|
|
|
258
261
|
'launch.mjs',
|
|
259
262
|
'launch-preflight.mjs',
|
|
260
263
|
'setup.sh',
|
|
264
|
+
'binding-probe-cli.mjs',
|
|
261
265
|
];
|
|
262
266
|
|
|
263
267
|
// The complete set of files the release signature MUST cover: every runtime .mjs
|
package/tool-schemas.mjs
CHANGED
|
@@ -100,6 +100,17 @@ export const memSearchSchema = {
|
|
|
100
100
|
or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
|
|
101
101
|
deep: coerceBool.optional().describe('Tri-state LLM multi-query/HyDE deep search (observations-only). true=force; false=never; omit=AUTO (default ON for mem_search): a normal search that returns weak/few results auto-escalates with ONE Haiku call (query rewritten to keyword/concept/HyDE variants, RRF-fused). Set CLAUDE_MEM_AUTO_DEEP=0 to disable AUTO. Passive recall stays single-query.'),
|
|
102
102
|
rerank: coerceBool.optional().describe('Opt-in: LLM-rerank the deep-search candidates for ranking precision (one extra Haiku call, ~1.4s). Requires deep=true (no effect on AUTO/normal). Reserve for hard, ranking-sensitive queries where the right memory is likely retrieved but mis-ranked — skip for routine search. Default off.'),
|
|
103
|
+
// ── CLI-flag aliases (v3.60.2) ──────────────────────────────────────────────
|
|
104
|
+
// A property the schema doesn't declare is STRIPPED by the validator, so a caller
|
|
105
|
+
// using the CLI vocabulary (`--source` / `--from` / `--to` / `--since`) previously
|
|
106
|
+
// got the UNFILTERED answer with nothing marking the filter as dropped — a wider
|
|
107
|
+
// result set that reads as filtered. Declaring the aliases makes the filter apply;
|
|
108
|
+
// the canonical name wins when both are supplied. Mirror of v3.59.0, which taught
|
|
109
|
+
// the CLI to accept MCP field names.
|
|
110
|
+
source: z.enum(['observations', 'sessions', 'prompts', 'events']).optional().describe('Alias for `type` (CLI `search --source`). Note: CLI `--type` is the OBSERVATION type — that is `obs_type` here.'),
|
|
111
|
+
from: z.string().optional().describe('Alias for `date_from` (CLI `search --from`)'),
|
|
112
|
+
to: z.string().optional().describe('Alias for `date_to` (CLI `search --to`)'),
|
|
113
|
+
since: z.string().optional().describe('Alias for `date_since` (CLI `search --since`)'),
|
|
103
114
|
};
|
|
104
115
|
|
|
105
116
|
export const memRecentSchema = {
|
|
@@ -107,6 +118,11 @@ export const memRecentSchema = {
|
|
|
107
118
|
project: z.string().optional().describe('Filter by project (default: inferred from CWD)'),
|
|
108
119
|
obs_type: OBS_TYPE_ENUM.optional().describe('Filter observation type (e.g. bugfix, decision) — CLI `recent --type` parity'),
|
|
109
120
|
date_since: z.string().optional().describe('Relative lower bound from now: 7d/24h/90m/2w/30s. Only items newer than the window (pair with a high limit for "everything since X")'),
|
|
121
|
+
// CLI-flag aliases — see the note on memSearchSchema. `recent --type` IS the
|
|
122
|
+
// observation type here (unlike `search --type`, which the CLI spells `--source`),
|
|
123
|
+
// so `type` maps to obs_type on this tool.
|
|
124
|
+
type: OBS_TYPE_ENUM.optional().describe('Alias for `obs_type` (CLI `recent --type`)'),
|
|
125
|
+
since: z.string().optional().describe('Alias for `date_since` (CLI `recent --since`)'),
|
|
110
126
|
};
|
|
111
127
|
|
|
112
128
|
// Anchor accepts plain int, "123" string-int, or prefixed token from search output:
|