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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.60.0",
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.60.0",
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
- flags[body.slice(0, eq)] = body.slice(eq + 1);
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
- flags[key] = next;
69
+ setFlag(key, next);
40
70
  i += 2;
41
71
  } else {
42
- flags[key] = true;
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
- if (best && bestDist <= 2) result.push({ flag: key, suggestion: best });
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 v2.69.x deferred-block
1393
- // semantics, so the migration notice is wrong noise (it fired for every
1394
- // fresh install since v2.70). Only genuine upgraders with observations see it.
1395
- const obsCount = db.prepare('SELECT COUNT(*) AS c FROM observations WHERE project = ?').get(project)?.c || 0;
1396
- emitV270UpgradeBanner({ project, runtimeDir: RUNTIME_DIR, hasPriorData: obsCount > 0 });
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
- try {
1401
- const Database = (await import('better-sqlite3')).default;
1402
- const probe = new Database(':memory:');
1403
- probe.close();
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
- } catch (e) {
1406
- fail(`better-sqlite3: import/init failed (${e.message})`);
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
- const bindingProbe = await probeBetterSqlite3Binding(bindingHostDir());
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++;
@@ -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 `rebuild`
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
- const probe = deps.probe || (() => probeBetterSqlite3Binding(installDir));
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 probe();
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 };
@@ -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(`[mem] Unknown flag --${flag}; did you mean --${suggestion}?\n`);
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/ —
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.60.0",
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.60.0",
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.60.0",
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);
@@ -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, probeBetterSqlite3Binding } = await import('../lib/binding-probe.mjs');
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
- const probe = await probeBetterSqlite3Binding(ROOT);
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
- # shellcheck disable=SC2016 # node script single-quoted on purpose; ROOT passed via env, not shell expansion
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
- PROBE_ROOT="$ROOT" node --input-type=module -e '
142
- // NOTE: this whole script sits in a single-quoted bash string — no
143
- // apostrophes anywhere in it.
144
- const { pathToFileURL } = await import("node:url");
145
- const { join } = await import("node:path");
146
- const root = process.env.PROBE_ROOT;
147
- const libUrl = (f) => pathToFileURL(join(root, "lib", f)).href;
148
- let helpers = null;
149
- try {
150
- const [probeMod, lockMod, dirMod] = await Promise.all(
151
- ["binding-probe.mjs", "proc-lock.mjs", "resolve-data-dir.mjs"].map((f) => import(libUrl(f))));
152
- helpers = { ...probeMod, ...lockMod, ...dirMod };
153
- } catch {
154
- // Probe helpers missing (half-installed tree) fall back to a bare
155
- // probe with no rebuild: a WORKING binding must still clear the flag,
156
- // and a helperless broken tree is repaired by the hook-launcher path.
157
- const { createRequire } = await import("node:module");
158
- try {
159
- const D = createRequire(join(root, "package.json"))("better-sqlite3");
160
- new D(":memory:").close();
161
- process.exit(0);
162
- } catch (e) {
163
- process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
164
- process.exit(1);
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
- if (ftsMode === 'OR' && OR_TOP_BM25_FLOOR > 0 && ftsRows.length > 0) {
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 < OR_TOP_BM25_FLOOR) ftsRows = [];
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 → keep the prose lookbehind ("the token: alice" is prose):
34
- [/((?<![A-Za-z][ \t])(?:\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, 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
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
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
- function safeHandler(fn) {
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
- return await fn(args, extra);
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
- safeHandler(async (args) => runExport(db, args))
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: