claude-mem-lite 3.60.1 → 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.1",
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.1",
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
@@ -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');
@@ -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.1",
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.1",
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.1",
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",
@@ -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/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: