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.
- 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 +51 -1
- package/lib/upgrade-banner.mjs +31 -0
- package/mem-cli.mjs +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/user-prompt-search.js +62 -3
- package/secret-scrub.mjs +14 -3
- package/server.mjs +75 -4
- 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
|
@@ -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');
|
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",
|
|
@@ -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/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:
|