claude-mem-lite 6.6.0 → 6.7.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/README.md +10 -2
- package/README.zh-CN.md +7 -1
- package/cli.mjs +73 -2
- package/install.mjs +90 -24
- package/lib/file-edge-match.mjs +34 -1
- package/lib/hook-stdin.mjs +5 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/project-utils.mjs +13 -4
- package/scripts/post-tool-recall.js +6 -1
- package/scripts/pre-tool-recall.js +56 -13
- package/scripts/user-prompt-search.js +24 -6
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.7.0",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
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)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.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/README.md
CHANGED
|
@@ -549,7 +549,8 @@ node install.mjs install # Install and configure
|
|
|
549
549
|
node install.mjs uninstall # Remove (keep data)
|
|
550
550
|
node install.mjs uninstall --purge # Remove and delete all data
|
|
551
551
|
node install.mjs status # Show current status
|
|
552
|
-
node
|
|
552
|
+
node cli.mjs doctor # Diagnose issues (cli.mjs, not install.mjs — see note)
|
|
553
|
+
node cli.mjs repair # Recover a broken install from the latest signed release
|
|
553
554
|
node install.mjs cleanup-hooks # Remove only stale claude-mem-lite hooks from settings.json
|
|
554
555
|
node install.mjs update # Force-check for updates and install them (direct install / npx mode)
|
|
555
556
|
|
|
@@ -559,6 +560,13 @@ npx claude-mem-lite uninstall # Remove (keep data)
|
|
|
559
560
|
npx claude-mem-lite doctor # Diagnose issues
|
|
560
561
|
```
|
|
561
562
|
|
|
563
|
+
> `doctor` and `repair` are spelled `cli.mjs`, not `install.mjs`, on purpose. Those two are
|
|
564
|
+
> the commands you reach for when the install is already broken, and `install.mjs` resolves
|
|
565
|
+
> around a dozen static imports before its first line runs — one missing file and it exits
|
|
566
|
+
> with a Node stack instead of telling you which file. `cli.mjs` has no static local imports
|
|
567
|
+
> and catches that, naming the file and a repair command. Everything else in the list is
|
|
568
|
+
> unaffected either way.
|
|
569
|
+
|
|
562
570
|
Notes:
|
|
563
571
|
- Plugin mode only reports available updates; it does not self-update plugin files.
|
|
564
572
|
To upgrade an installed plugin to the latest published version, run **inside Claude Code**:
|
|
@@ -594,7 +602,7 @@ git fetch --tags && git checkout v3.62.0
|
|
|
594
602
|
# 4. To leave the pin later: git checkout main, then the normal update flow.
|
|
595
603
|
```
|
|
596
604
|
|
|
597
|
-
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node
|
|
605
|
+
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node cli.mjs doctor` before trusting search results.
|
|
598
606
|
|
|
599
607
|
### doctor
|
|
600
608
|
|
package/README.zh-CN.md
CHANGED
|
@@ -461,7 +461,8 @@ node install.mjs install # 安装并配置
|
|
|
461
461
|
node install.mjs uninstall # 移除(保留数据)
|
|
462
462
|
node install.mjs uninstall --purge # 移除并删除所有数据
|
|
463
463
|
node install.mjs status # 显示当前状态
|
|
464
|
-
node
|
|
464
|
+
node cli.mjs doctor # 诊断问题(用 cli.mjs 而非 install.mjs,见下方说明)
|
|
465
|
+
node cli.mjs repair # 从最新签名发布恢复损坏的安装
|
|
465
466
|
node install.mjs cleanup-hooks # 只清理 settings.json 中残留的 claude-mem-lite hooks
|
|
466
467
|
node install.mjs update # 强制检查并安装更新(direct install / npx 模式)
|
|
467
468
|
|
|
@@ -472,6 +473,11 @@ npx claude-mem-lite doctor # 诊断问题
|
|
|
472
473
|
```
|
|
473
474
|
|
|
474
475
|
说明:
|
|
476
|
+
- `doctor` 与 `repair` 写成 `cli.mjs` 而不是 `install.mjs`,是有意的。这两条恰恰是安装
|
|
477
|
+
已经坏掉时才会用到的命令,而 `install.mjs` 在执行第一行代码之前要解析十几个静态
|
|
478
|
+
import——少一个文件就直接吐一段 Node 栈,而不是告诉你少了哪个文件。`cli.mjs` 没有
|
|
479
|
+
任何本地静态 import,会捕获这种失败并说出缺失的文件和修复命令。列表中其余命令两种
|
|
480
|
+
写法都一样。
|
|
475
481
|
- 插件模式只提示可用更新,不会自更新插件文件。
|
|
476
482
|
- direct install / npx 模式保留自动更新,并使用 staged replacement;若依赖安装失败会回滚。
|
|
477
483
|
- 如果你禁用了插件,但 `~/.claude/settings.json` 里还有旧的 mem hooks,可运行 `node install.mjs cleanup-hooks`。
|
package/cli.mjs
CHANGED
|
@@ -30,6 +30,77 @@ const CLI_COMMANDS = new Set([
|
|
|
30
30
|
// Kept as a named set so a stale script or muscle-memory invocation gets the reason rather
|
|
31
31
|
// than a bare "Unknown command" plus a misleading edit-distance suggestion.
|
|
32
32
|
const REMOVED_COMMANDS = new Set(['registry', 'import', 'enrich']);
|
|
33
|
+
|
|
34
|
+
// D#26 / R12 P1-1, second half. `doctor` and `repair` exist to tell a user which
|
|
35
|
+
// file their install is missing. Until now, on exactly that install, they did not
|
|
36
|
+
// run: install.mjs's ~13 static imports resolve BEFORE its first line executes, so
|
|
37
|
+
// one absent module killed the command with a bare ERR_MODULE_NOT_FOUND and zero
|
|
38
|
+
// bytes of stdout. A half-finished update, a trimmed tarball (this repo has
|
|
39
|
+
// shipped three) or a hand-deleted file all land there — CLAUDE.md's "a recovery
|
|
40
|
+
// path must not import the thing it recovers", on the startup edge.
|
|
41
|
+
//
|
|
42
|
+
// A static import cannot be caught inside the module that declares it, so the
|
|
43
|
+
// catch lives one entry up. THIS file is the host because it is the published
|
|
44
|
+
// `bin` and because its own static closure is one file — itself; every route
|
|
45
|
+
// below is an `await import()`. Whatever this prints must therefore rely on
|
|
46
|
+
// nothing but the language, the same charter scripts/hook-launcher.mjs follows:
|
|
47
|
+
// no local import may appear here, or the fallback shares the fate it reports on.
|
|
48
|
+
//
|
|
49
|
+
// The remedy is deliberately NOT `install.mjs repair` — that is the file that
|
|
50
|
+
// would not load. It has to come from outside the broken tree.
|
|
51
|
+
// THREE shapes, not one. The first cut caught only ERR_MODULE_NOT_FOUND, and pre-ship
|
|
52
|
+
// review pointed out that an interrupted write leaves a file PRESENT and truncated far
|
|
53
|
+
// more often than it leaves it absent: that arrives as a SyntaxError, and a truncated
|
|
54
|
+
// install.mjs arrives as neither — the module loads and simply has no `main`, which
|
|
55
|
+
// died at the call site with "main is not a function". All three are the same fact
|
|
56
|
+
// about the world (this install's files are not intact) and get the same answer.
|
|
57
|
+
// Anything else is rethrown: this is a classifier, not a swallow.
|
|
58
|
+
function explainBrokenInstall(what) {
|
|
59
|
+
const w = (s) => process.stderr.write(`[claude-mem-lite] ${s}\n`);
|
|
60
|
+
w(`This install is incomplete — ${what}`);
|
|
61
|
+
w('That is why this command cannot run: these files load before any of their code executes.');
|
|
62
|
+
w('Repair: npm install -g claude-mem-lite@latest --force');
|
|
63
|
+
w('Or, in Claude Code: /plugin uninstall claude-mem-lite && /plugin install claude-mem-lite@sdsrss');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Absolute path out of an error that carries one, or null.
|
|
69
|
+
*
|
|
70
|
+
* ERR_MODULE_NOT_FOUND carries `url`. An ESM SyntaxError carries NOTHING —
|
|
71
|
+
* measured on Node 26: `url` and `code` both undefined, message a bare
|
|
72
|
+
* "Unexpected end of input", every stack frame a node-internal loader. So the
|
|
73
|
+
* caller must be able to say its piece without a filename rather than printing
|
|
74
|
+
* "undefined", and naming the damaged file would take a `node --check` scan of
|
|
75
|
+
* the install, which is a bigger thing than this line.
|
|
76
|
+
*/
|
|
77
|
+
function fileFromError(e) {
|
|
78
|
+
const m = String(e?.url || e?.stack || e?.message || '').match(/file:\/\/(\/[^\s:)'"]+)/);
|
|
79
|
+
if (m) return m[1];
|
|
80
|
+
const quoted = String(e?.message || '').match(/'([^']+\.mjs)'/);
|
|
81
|
+
return quoted ? quoted[1] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadInstaller() {
|
|
85
|
+
let mod;
|
|
86
|
+
try {
|
|
87
|
+
mod = await import('./install.mjs');
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (e?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
90
|
+
explainBrokenInstall(`it is missing: ${fileFromError(e) || 'a module'}`);
|
|
91
|
+
}
|
|
92
|
+
// Never reprint the parser's own output: the stack is what this exists to replace.
|
|
93
|
+
if (e instanceof SyntaxError) {
|
|
94
|
+
const at = fileFromError(e);
|
|
95
|
+
explainBrokenInstall(`this file is damaged or truncated: ${at || 'one of its modules'}`);
|
|
96
|
+
}
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
if (typeof mod?.main !== 'function') {
|
|
100
|
+
explainBrokenInstall('install.mjs loaded but exports no `main` — it is truncated');
|
|
101
|
+
}
|
|
102
|
+
return mod;
|
|
103
|
+
}
|
|
33
104
|
const INSTALL_COMMANDS = new Set([
|
|
34
105
|
'install',
|
|
35
106
|
'uninstall',
|
|
@@ -90,11 +161,11 @@ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
|
|
|
90
161
|
const { run } = await import('./mem-cli.mjs');
|
|
91
162
|
await run(['help']);
|
|
92
163
|
} else {
|
|
93
|
-
const { main } = await
|
|
164
|
+
const { main } = await loadInstaller();
|
|
94
165
|
await main([]);
|
|
95
166
|
}
|
|
96
167
|
} else if (INSTALL_COMMANDS.has(cmd)) {
|
|
97
|
-
const { main } = await
|
|
168
|
+
const { main } = await loadInstaller();
|
|
98
169
|
await main(process.argv.slice(2));
|
|
99
170
|
} else if (REMOVED_COMMANDS.has(cmd)) {
|
|
100
171
|
// Released-artifact discoverability signal for the skill-registry removal. Deliberately
|
package/install.mjs
CHANGED
|
@@ -1799,6 +1799,17 @@ async function doctor() {
|
|
|
1799
1799
|
// another tree's schema.mjs would poison the process that must report the answer. It is
|
|
1800
1800
|
// also why this check is USEFUL TODAY rather than only after the next upgrade — doctor
|
|
1801
1801
|
// runs from whichever tree the user invoked, so new code here can diagnose an old cache.
|
|
1802
|
+
// Hoisted out of the branch below because two LATER checks have to honour it. The
|
|
1803
|
+
// verdict is "this install cannot safely touch that file"; a verdict nothing
|
|
1804
|
+
// downstream reads is a sentence, not a gate (R12 audit, partition C P2-5).
|
|
1805
|
+
//
|
|
1806
|
+
// Two variables, because the two consumers ask different questions. `dbWriteBlocked`
|
|
1807
|
+
// is about SAFETY — may this process open the file read-write — and carries its own
|
|
1808
|
+
// reason so the line it gates is true. `dbUnusableHere` is about HONESTY — may this
|
|
1809
|
+
// screen put a ✓ on a store this install cannot use. A read that succeeds on a
|
|
1810
|
+
// too-new file is still a real number; a checkmark on it is not.
|
|
1811
|
+
let dbWriteBlocked = null;
|
|
1812
|
+
let dbUnusableHere = false;
|
|
1802
1813
|
if (!existsSync(DB_PATH)) {
|
|
1803
1814
|
ok('DB schema: no database yet — nothing to compare');
|
|
1804
1815
|
} else if (rootProbes.length === 0) {
|
|
@@ -1810,6 +1821,31 @@ async function doctor() {
|
|
|
1810
1821
|
const compat = probeSchemaCompat(shape.runtimeRoots, DB_PATH);
|
|
1811
1822
|
const behind = compat.filter((c) => c.status === 'skew');
|
|
1812
1823
|
const unknown = compat.filter((c) => c.status === 'unknown');
|
|
1824
|
+
// Ask about the tree THIS process runs from, not about the machine.
|
|
1825
|
+
// probeSchemaCompat probes every code home on purpose — its docblock says so:
|
|
1826
|
+
// "so a report can NAME the one that is behind instead of asserting something
|
|
1827
|
+
// global about 'the install'". The first cut of this gate read `behind.length > 0`
|
|
1828
|
+
// and then printed "this install", which is the assertion that docblock exists to
|
|
1829
|
+
// prevent. On the shape this whole check was built for — a current npm-global CLI
|
|
1830
|
+
// beside a stale plugin cache, reached routinely per the rationale above — the
|
|
1831
|
+
// running process can read and write the store perfectly well, and gating on any
|
|
1832
|
+
// home withheld checkFTSIntegrity + rebuildFTS, doctor's ONLY non-destructive DB
|
|
1833
|
+
// repair, from the machine most likely to need it. Found in pre-ship review.
|
|
1834
|
+
//
|
|
1835
|
+
// `unknown` blocks the write too, and says so in its own words: the probe could
|
|
1836
|
+
// not get both numbers, and "I could not tell" is not "safe to write". The three
|
|
1837
|
+
// outcomes stay three, the way the schema check above already keeps them.
|
|
1838
|
+
const self = compat.find((c) => c.root === PROJECT_DIR);
|
|
1839
|
+
if (!self) {
|
|
1840
|
+
dbWriteBlocked = 'could not identify the install this command is running from';
|
|
1841
|
+
} else if (self.status === 'skew') {
|
|
1842
|
+
dbWriteBlocked =
|
|
1843
|
+
`this database (v${self.dbVersion}) is newer than the install you are running, ` +
|
|
1844
|
+
`which supports up to v${self.supported}`;
|
|
1845
|
+
dbUnusableHere = true;
|
|
1846
|
+
} else if (self.status === 'unknown') {
|
|
1847
|
+
dbWriteBlocked = 'could not determine whether the install you are running can read this database';
|
|
1848
|
+
}
|
|
1813
1849
|
if (behind.length === 0 && unknown.length === 0) {
|
|
1814
1850
|
ok(`DB schema: v${compat[0]?.dbVersion} — readable by all ${compat.length} install(s)`);
|
|
1815
1851
|
}
|
|
@@ -1900,8 +1936,12 @@ async function doctor() {
|
|
|
1900
1936
|
} catch {
|
|
1901
1937
|
/* unreadable marker → bare warning */
|
|
1902
1938
|
}
|
|
1939
|
+
// cli.mjs, matching hook-launcher.mjs's CLI_REPAIR and the two remedies further
|
|
1940
|
+
// down: this check FIRES because something about the install is already
|
|
1941
|
+
// misbehaving, which is the worst moment to hand out the one entry that cannot
|
|
1942
|
+
// survive a missing module. Pre-ship review of v6.7.0 caught this one left behind.
|
|
1903
1943
|
dwarn(
|
|
1904
|
-
`Hook self-heal: a recent hook fire degraded to exit-0${detail} — run \`node ${join(PROJECT_DIR, '
|
|
1944
|
+
`Hook self-heal: a recent hook fire degraded to exit-0${detail} — run \`node ${join(PROJECT_DIR, 'cli.mjs')} repair\``,
|
|
1905
1945
|
);
|
|
1906
1946
|
} else {
|
|
1907
1947
|
ok('Hook self-heal: no recent silent hook breakage');
|
|
@@ -2113,30 +2153,44 @@ async function doctor() {
|
|
|
2113
2153
|
if (fts) {
|
|
2114
2154
|
ok('FTS5 index: present');
|
|
2115
2155
|
// FTS5 integrity check (requires read-write access for INSERT INTO fts VALUES('integrity-check'))
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2156
|
+
if (dbWriteBlocked) {
|
|
2157
|
+
// Everything past this point wants a WRITE handle on a file this install
|
|
2158
|
+
// has just been told it is too old to write — which is the exact way a
|
|
2159
|
+
// store gets locked out for good. The rebuild below is gated by sitting
|
|
2160
|
+
// inside this else, not by a second condition that could drift from it.
|
|
2161
|
+
//
|
|
2162
|
+
// dwarn rather than silence, and the REASON is interpolated rather than
|
|
2163
|
+
// assumed: "I could not look" and "I looked and it is fine" have to stay
|
|
2164
|
+
// distinguishable, and so does "I did not look, and here is which of the
|
|
2165
|
+
// three reasons applies" — a line that names the wrong reason ends the
|
|
2166
|
+
// reader's search just as a false green does.
|
|
2167
|
+
dwarn(`FTS5 integrity: not checked — ${dbWriteBlocked}`);
|
|
2168
|
+
} else {
|
|
2120
2169
|
try {
|
|
2121
|
-
const {
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2170
|
+
const { checkFTSIntegrity, rebuildFTS } = await import('./schema.mjs');
|
|
2171
|
+
const rwDb = new Database(DB_PATH);
|
|
2172
|
+
rwDb.pragma('busy_timeout = 3000');
|
|
2173
|
+
try {
|
|
2174
|
+
const { healthy, details } = checkFTSIntegrity(rwDb);
|
|
2175
|
+
if (healthy) {
|
|
2176
|
+
ok('FTS5 integrity: all indexes healthy');
|
|
2177
|
+
} else {
|
|
2178
|
+
dwarn('FTS5 integrity issues detected:');
|
|
2179
|
+
for (const d of details) log(` ${d}`);
|
|
2180
|
+
log(' Attempting FTS5 rebuild...');
|
|
2181
|
+
const { rebuilt, errors } = rebuildFTS(rwDb);
|
|
2182
|
+
if (rebuilt.length > 0) ok(`FTS5 rebuilt: ${rebuilt.join(', ')}`);
|
|
2183
|
+
if (errors.length > 0) {
|
|
2184
|
+
fail(`FTS5 rebuild errors: ${errors.join(', ')}`);
|
|
2185
|
+
issues++;
|
|
2186
|
+
}
|
|
2133
2187
|
}
|
|
2188
|
+
} finally {
|
|
2189
|
+
rwDb.close();
|
|
2134
2190
|
}
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2191
|
+
} catch (e) {
|
|
2192
|
+
dwarn('FTS5 integrity check failed: ' + e.message);
|
|
2137
2193
|
}
|
|
2138
|
-
} catch (e) {
|
|
2139
|
-
dwarn('FTS5 integrity check failed: ' + e.message);
|
|
2140
2194
|
}
|
|
2141
2195
|
} else {
|
|
2142
2196
|
dwarn('FTS5 index: missing (will be created on server start)');
|
|
@@ -2340,7 +2394,7 @@ async function doctor() {
|
|
|
2340
2394
|
warn(
|
|
2341
2395
|
`Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
|
|
2342
2396
|
`imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
|
|
2343
|
-
`(or: node ${join(INSTALL_DIR, '
|
|
2397
|
+
`(or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`,
|
|
2344
2398
|
);
|
|
2345
2399
|
issues++;
|
|
2346
2400
|
}
|
|
@@ -2361,7 +2415,11 @@ async function doctor() {
|
|
|
2361
2415
|
const skipScripts = !shape.managed && !!shape.activePluginVersion;
|
|
2362
2416
|
const { checkHookScriptDrift, HOOK_SCRIPT_ENTRY_POINTS } = await import('./lib/doctor-drift.mjs');
|
|
2363
2417
|
const h = skipScripts ? null : checkHookScriptDrift(INSTALL_DIR, HOOK_SCRIPT_FILES);
|
|
2364
|
-
|
|
2418
|
+
// cli.mjs, not install.mjs: the reader of this line has an install that is
|
|
2419
|
+
// missing files, and install.mjs is the one entry that cannot survive that —
|
|
2420
|
+
// its static imports resolve before its first statement. cli.mjs has no static
|
|
2421
|
+
// local imports and catches the failure (D#26). Same route, same command.
|
|
2422
|
+
const scriptRemedy = `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`;
|
|
2365
2423
|
if (skipScripts) {
|
|
2366
2424
|
ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
|
|
2367
2425
|
} else if (!h.present) {
|
|
@@ -2499,7 +2557,15 @@ async function doctor() {
|
|
|
2499
2557
|
// Align with stats / MCP mem_stats: session_summaries, not sdk_sessions
|
|
2500
2558
|
const sessCount = db.prepare('SELECT COUNT(*) as cnt FROM session_summaries').get()?.cnt || 0;
|
|
2501
2559
|
db.close();
|
|
2502
|
-
|
|
2560
|
+
const stats = `DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`;
|
|
2561
|
+
// The read succeeds on a too-new file — the tables are still there — so this
|
|
2562
|
+
// line printed a ✓ about a store the screen had already called unusable two
|
|
2563
|
+
// checks up. The numbers are real and worth showing; the checkmark is not.
|
|
2564
|
+
// Keyed on dbUnusableHere, not on dbWriteBlocked: "I could not confirm this
|
|
2565
|
+
// install can read the DB" is not grounds to tell the user it cannot.
|
|
2566
|
+
if (dbUnusableHere)
|
|
2567
|
+
dwarn(`${stats} — but the install you are running cannot use this database (see DB schema above)`);
|
|
2568
|
+
else ok(stats);
|
|
2503
2569
|
} catch (e) {
|
|
2504
2570
|
dwarn('DB stats: ' + e.message);
|
|
2505
2571
|
}
|
package/lib/file-edge-match.mjs
CHANGED
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
// so the split is inlined below rather than imported. utils.mjs used to export the
|
|
45
45
|
// same two lines as `basenameAnySep`; that copy was deleted in the same round once
|
|
46
46
|
// its only consumer went, so this file is now the sole home.
|
|
47
|
+
//
|
|
48
|
+
// The one import below does not cost that: project-utils.mjs is a leaf over
|
|
49
|
+
// `node:path`, and pre-tool-recall.js already imports it for inferProject.
|
|
50
|
+
import { likeLiteral } from '../project-utils.mjs';
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* SQL boolean expression for the four-arm match. Placeholder order matches
|
|
@@ -72,10 +76,39 @@ export function basenameAnySep(p) {
|
|
|
72
76
|
return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* This module had its own `likeLiteral` for one commit, and the comment justifying
|
|
81
|
+
* the duplication was wrong twice running — first "THE only copy in the repo" (it
|
|
82
|
+
* was not; project-utils.mjs has had one since R10 P2-3), then "project-utils.mjs
|
|
83
|
+
* pulls a DB handle's worth of graph behind it" (it does not: it imports
|
|
84
|
+
* `node:path` and nothing else, its own header calls it a leaf, and
|
|
85
|
+
* scripts/pre-tool-recall.js — the cold-start script the excuse was built around —
|
|
86
|
+
* already imports it). Both were caught in review rather than by measuring first.
|
|
87
|
+
*
|
|
88
|
+
* So it is imported, per this file's own rule about basenameAnySep one docblock up:
|
|
89
|
+
* a second copy is exactly what produced R12 B-1.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* LIKE needle for one element of a JSON-array TEXT column (`events.file_paths`),
|
|
94
|
+
* to be wrapped as `%"<needle>"%` and run under `ESCAPE '\'`.
|
|
95
|
+
*
|
|
96
|
+
* TWO escapes compose here and the order is not interchangeable. The column holds
|
|
97
|
+
* `JSON.stringify(paths)`, so a win32 separator is already TWO characters on disk
|
|
98
|
+
* (`C:\\proj`); the LIKE escape then has to double each of those again to mean two
|
|
99
|
+
* literal backslashes. Skipping the JSON step is the trap: it looks fixed, because
|
|
100
|
+
* on POSIX `JSON.stringify` of a path is the identity, so every non-Windows test
|
|
101
|
+
* stays green either way. Measured on `:memory:` against a real stored row —
|
|
102
|
+
* shipped = 0 rows, LIKE-escape alone = 0 rows, JSON-then-LIKE = 1 row.
|
|
103
|
+
*/
|
|
104
|
+
export function jsonArrayLikeNeedle(s) {
|
|
105
|
+
return likeLiteral(JSON.stringify(String(s ?? '')).slice(1, -1));
|
|
106
|
+
}
|
|
107
|
+
|
|
75
108
|
/** Bind values for fileMatchClause, in placeholder order. */
|
|
76
109
|
export function fileMatchParams(filePath) {
|
|
77
110
|
const fname = basenameAnySep(filePath);
|
|
78
|
-
const escaped = fname
|
|
111
|
+
const escaped = likeLiteral(fname);
|
|
79
112
|
// `%\\` before the basename: under ESCAPE '\', a literal backslash is
|
|
80
113
|
// written '\\' — so the JS string carries two backslash characters.
|
|
81
114
|
return [filePath, fname, `%/${escaped}`, `%\\\\${escaped}`];
|
package/lib/hook-stdin.mjs
CHANGED
|
@@ -67,7 +67,11 @@ export function salvageTruncatedHookEvent(prefix) {
|
|
|
67
67
|
// one, which still exits 0 but writes a `pre-recall:top` telemetry row: the same
|
|
68
68
|
// hook-error noise the caller split `pre-recall:json` away from. 4096 is far above any
|
|
69
69
|
// real path (PATH_MAX is 4096 on Linux, 1024 on macOS), so no reachable payload is lost.
|
|
70
|
-
|
|
70
|
+
// Both spellings, because the caller accepts both: NotebookEdit carries
|
|
71
|
+
// `notebook_path` and never `file_path`, so a truncated notebook payload would
|
|
72
|
+
// otherwise salvage nothing while the untruncated one recalls fine — the same
|
|
73
|
+
// half-covered shape as R12 B-2, one path over.
|
|
74
|
+
const fp = prefix.match(/"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.){0,4096})"/);
|
|
71
75
|
if (!fp) return null;
|
|
72
76
|
let filePath;
|
|
73
77
|
// The captured group is still JSON-escaped (Windows paths arrive as `C:\\x`).
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.
|
|
9
|
+
"version": "6.7.0",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.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",
|
package/project-utils.mjs
CHANGED
|
@@ -72,11 +72,20 @@ export function projectNameFromDir(p) {
|
|
|
72
72
|
return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Escape LIKE metacharacters so a caller-supplied
|
|
75
|
+
/** Escape LIKE metacharacters so a caller-supplied value is matched literally.
|
|
76
76
|
* R10 P2-3: unescaped, `--project '%'` matched every project and ORDER BY COUNT(*)
|
|
77
|
-
* returned the biggest one; `_` matched any single character the same way.
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
* returned the biggest one; `_` matched any single character the same way.
|
|
78
|
+
*
|
|
79
|
+
* THE only copy in the repo, and it lives here because this module is a leaf over
|
|
80
|
+
* `node:path` alone — the ~30 ms cold-start hook scripts already import it, so the
|
|
81
|
+
* shared home costs them nothing. `lib/file-edge-match.mjs` imports it rather than
|
|
82
|
+
* keeping the second copy it had: that second copy escaped `%` and `_` and not the
|
|
83
|
+
* escape character, which is the whole of R12 B-1. Backslash first is not optional —
|
|
84
|
+
* under `ESCAPE '\'` SQLite reads `\` + any character as that character taken
|
|
85
|
+
* literally, so an un-doubled backslash is consumed and the pattern silently stops
|
|
86
|
+
* matching the string it was built from. */
|
|
87
|
+
export function likeLiteral(s) {
|
|
88
|
+
return String(s ?? '').replace(/[\\%_]/g, '\\$&');
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
/**
|
|
@@ -54,7 +54,12 @@ async function main() {
|
|
|
54
54
|
let filePath, sessionId;
|
|
55
55
|
try {
|
|
56
56
|
const e = JSON.parse(input);
|
|
57
|
-
|
|
57
|
+
// Both spellings — this file's PostToolUse matcher includes NotebookEdit, whose
|
|
58
|
+
// schema has `notebook_path` and no `file_path`. The PreToolUse twin got this in
|
|
59
|
+
// v6.7.0 and this leg did not, which is the repo's most repeated failure shape:
|
|
60
|
+
// a fix that closes ONE of the inputs reaching the same line. Caught in pre-ship
|
|
61
|
+
// review of that very round.
|
|
62
|
+
filePath = e.tool_input?.file_path ?? e.tool_input?.notebook_path;
|
|
58
63
|
sessionId = e.session_id || null;
|
|
59
64
|
} catch {
|
|
60
65
|
return;
|
|
@@ -20,7 +20,12 @@ import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
|
20
20
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
21
21
|
import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
|
|
22
22
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
fileMatchClause,
|
|
25
|
+
fileMatchParams,
|
|
26
|
+
basenameAnySep,
|
|
27
|
+
jsonArrayLikeNeedle,
|
|
28
|
+
} from '../lib/file-edge-match.mjs';
|
|
24
29
|
import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
25
30
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
26
31
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
@@ -96,6 +101,23 @@ import { DEDUP_STALE_MS as CROSS_HOOK_DEDUP_MS } from './prompt-search-utils.mjs
|
|
|
96
101
|
// failure ALGO-4 exists to fix. The cap is right (an unbounded LIMIT is worse), the
|
|
97
102
|
// reassurance was wrong.
|
|
98
103
|
const CROSS_HOOK_DEDUP_SLACK_MAX = 5;
|
|
104
|
+
// The tools this script claims to handle. FOUR surfaces carry this list — the
|
|
105
|
+
// comment said three until review counted again, which is the second time in one
|
|
106
|
+
// round that an enumeration here was written from memory:
|
|
107
|
+
// 1. hooks/hooks.json's PreToolUse matcher for this script
|
|
108
|
+
// 2. its settings.json twin at install.mjs:1064
|
|
109
|
+
// 3. this constant
|
|
110
|
+
// 4. benchmark/efficacy-harness.mjs, which builds its own settings.json
|
|
111
|
+
// (install.mjs:975 looks like a fifth and is not — different matcher, for
|
|
112
|
+
// post-tool-recall.js.)
|
|
113
|
+
//
|
|
114
|
+
// Drift between any two is invisible at runtime. Two guards chain to cover 1-3:
|
|
115
|
+
// tests/hooks-pretool-whitelist-sync.test.mjs pins hooks.json against this
|
|
116
|
+
// constant, and tests/audit-silent-20260814.test.mjs diffs hooks.json against the
|
|
117
|
+
// install.mjs twin — hooks.json is the hub and neither spoke can drift alone.
|
|
118
|
+
// Surface 4 is covered by NEITHER: it is a benchmark harness, so drift there
|
|
119
|
+
// silently changes what the benchmark measures rather than what users get.
|
|
120
|
+
const HANDLED_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Read'];
|
|
99
121
|
// v2.33.1: cooldown path is session-scoped so same-file-twice within one
|
|
100
122
|
// session never re-injects (was: global file, 5-min window). Cross-session:
|
|
101
123
|
// fresh file, fresh nudges — this is intended. No session_id → fall back to
|
|
@@ -379,7 +401,12 @@ try {
|
|
|
379
401
|
try {
|
|
380
402
|
const event = JSON.parse(input);
|
|
381
403
|
toolInput = event.tool_input;
|
|
382
|
-
|
|
404
|
+
// NotebookEdit is in our matcher but has no `file_path`: its schema is
|
|
405
|
+
// { notebook_path, cell_id, cell_type, edit_mode, new_source } with
|
|
406
|
+
// additionalProperties:false. Reading only `file_path` made this hook a
|
|
407
|
+
// no-op on every .ipynb edit (R12 audit, partition B-2). utils.mjs's
|
|
408
|
+
// `case 'NotebookEdit'` already knew the shape differs; this leg did not.
|
|
409
|
+
filePath = event.tool_input?.file_path ?? event.tool_input?.notebook_path;
|
|
383
410
|
sessionId = event.session_id || null;
|
|
384
411
|
toolName = event.tool_name || null;
|
|
385
412
|
const off = event.tool_input?.offset;
|
|
@@ -400,17 +427,31 @@ try {
|
|
|
400
427
|
}
|
|
401
428
|
|
|
402
429
|
// Upstream-shape probe: hook ran but neither field nor input shape matches the
|
|
403
|
-
// contract we encode (event.tool_input
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
430
|
+
// contract we encode (a path field on event.tool_input, event.tool_name in
|
|
431
|
+
// HANDLED_TOOLS). Distinguishes "Claude Code renamed the field" from "event
|
|
432
|
+
// genuinely has no path" — without this trace, a CC upstream rename silently
|
|
433
|
+
// zeroes injection like code-graph's matcher bug.
|
|
434
|
+
//
|
|
435
|
+
// The whitelist used to double as a SILENCE list, and that inverted the probe:
|
|
436
|
+
// a rename can only ever show up on a tool we handle, so the one population
|
|
437
|
+
// carrying the signal was the one population it declined to record. That is how
|
|
438
|
+
// NotebookEdit ran 100% dead and unobservable (R12 audit, partition B-2) — the
|
|
439
|
+
// probe written to catch exactly this had the tool in its whitelist. Each
|
|
440
|
+
// outcome now gets its own scope so the populations stay separable in the log.
|
|
407
441
|
if (!filePath) {
|
|
408
|
-
if (
|
|
442
|
+
if (!toolName) {
|
|
443
|
+
recordHookError('pre-recall:no-toolname', new Error('event missing tool_name'), RUNTIME_DIR);
|
|
444
|
+
} else if (HANDLED_TOOLS.includes(toolName)) {
|
|
445
|
+
recordHookError(
|
|
446
|
+
'pre-recall:no-path-field',
|
|
447
|
+
new Error(`tool_name=${toolName} carried no known path field`),
|
|
448
|
+
RUNTIME_DIR,
|
|
449
|
+
{ toolName, inputKeys: Object.keys(toolInput || {}).slice(0, 12) },
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
409
452
|
recordHookError('pre-recall:unknown-tool', new Error(`tool_name=${toolName}`), RUNTIME_DIR, {
|
|
410
453
|
toolName,
|
|
411
454
|
});
|
|
412
|
-
} else if (!toolName) {
|
|
413
|
-
recordHookError('pre-recall:no-toolname', new Error('event missing tool_name'), RUNTIME_DIR);
|
|
414
455
|
}
|
|
415
456
|
process.exit(0);
|
|
416
457
|
}
|
|
@@ -497,8 +538,10 @@ try {
|
|
|
497
538
|
// Windows-shaped payload. Fixing the observations leg alone would have left this
|
|
498
539
|
// hook recalling lessons but no events.
|
|
499
540
|
const fname = basenameAnySep(filePath);
|
|
500
|
-
//
|
|
501
|
-
|
|
541
|
+
// Needle for the events leg's JSON-array column — see jsonArrayLikeNeedle for
|
|
542
|
+
// why the JSON escape has to run before the LIKE one. The observations leg
|
|
543
|
+
// below matches a plain column and gets its params from fileMatchParams.
|
|
544
|
+
const basenameNeedle = jsonArrayLikeNeedle(fname);
|
|
502
545
|
// P0 (D#78): path-boundary match — editing utils.mjs must NOT pull lessons
|
|
503
546
|
// stored under bash-utils.mjs (the old '%<basename>' suffix LIKE did).
|
|
504
547
|
// Clause + params come from lib/file-edge-match.mjs, byte-shared with the
|
|
@@ -608,7 +651,7 @@ try {
|
|
|
608
651
|
// patterns match both basename and full-path entries. JSON quoting
|
|
609
652
|
// (`"<name>"`) prevents partial-match false positives like "foo.mjs"
|
|
610
653
|
// matching "myfoo.mjs".
|
|
611
|
-
const
|
|
654
|
+
const fullPathNeedle = jsonArrayLikeNeedle(filePath);
|
|
612
655
|
// v2.34.6: Read also tightens the events query — only rows with a non-empty
|
|
613
656
|
// body (= lesson equivalent). Edit path keeps a wider net, but P0 (D#78)
|
|
614
657
|
// closes the parallel-path drift vs the observations query: a bodyless row
|
|
@@ -643,7 +686,7 @@ try {
|
|
|
643
686
|
LIMIT ${eventsLimit}
|
|
644
687
|
`,
|
|
645
688
|
)
|
|
646
|
-
.all(project, cutoff, `%"${
|
|
689
|
+
.all(project, cutoff, `%"${basenameNeedle}"%`, `%"${fullPathNeedle}"%`);
|
|
647
690
|
} catch {
|
|
648
691
|
/* events table may not exist on pre-v2.31 DBs — silent */
|
|
649
692
|
}
|
|
@@ -596,14 +596,15 @@ function searchRecent(db, project, limit) {
|
|
|
596
596
|
// 256 KB full-payload tier hook.mjs uses — both tiers live in utils.mjs (G19) — because the
|
|
597
597
|
// payload here is a user PROMPT, not a tool response. `rejectOnTimeout` matches the previous
|
|
598
598
|
// behaviour: the caller treats a timeout as "skip the injection".
|
|
599
|
-
// Returns
|
|
599
|
+
// Returns readHookStdin's `{ text, truncated }` whole: the caller records `truncated`
|
|
600
|
+
// in its telemetry, and that flag is the difference between "the user sent malformed
|
|
601
|
+
// JSON" and "we cut their prompt in half" (R12 B-3). It used to return a bare string.
|
|
600
602
|
async function readStdin() {
|
|
601
|
-
|
|
603
|
+
return readHookStdin({
|
|
602
604
|
timeoutMs: 2000,
|
|
603
605
|
maxBytes: MAX_UPS_PROMPT_BYTES,
|
|
604
606
|
rejectOnTimeout: true,
|
|
605
607
|
});
|
|
606
|
-
return text;
|
|
607
608
|
}
|
|
608
609
|
|
|
609
610
|
// ─── Format Output ──────────────────────────────────────────────────────────
|
|
@@ -653,17 +654,34 @@ async function main() {
|
|
|
653
654
|
// Prevent recursion from background claude -p calls
|
|
654
655
|
if (process.env.CLAUDE_MEM_HOOK_RUNNING) return;
|
|
655
656
|
|
|
657
|
+
// Both swallows below record first. They were this file's only silent ones, and
|
|
658
|
+
// they sit on the *entry* of the face: past MAX_UPS_PROMPT_BYTES the read hands
|
|
659
|
+
// back a truncated prefix, JSON.parse throws, the face goes dark, and every
|
|
660
|
+
// health surface — `stats`, `doctor` — reads zero errors. Measured three arms
|
|
661
|
+
// back-to-back at 318 B / 61 760 B / 72 000 B: only the third one vanished, so
|
|
662
|
+
// what is lost is the prompt that pasted a large log, which is exactly the
|
|
663
|
+
// prompt an error-signature recall is most useful on (R12 audit, partition B-3).
|
|
656
664
|
let raw;
|
|
665
|
+
// No initializer: the parse catch below is reachable only after the destructure
|
|
666
|
+
// above succeeded, so every read of this is an assigned one.
|
|
667
|
+
let truncated;
|
|
657
668
|
try {
|
|
658
|
-
raw = await readStdin();
|
|
659
|
-
} catch {
|
|
669
|
+
({ text: raw, truncated } = await readStdin());
|
|
670
|
+
} catch (e) {
|
|
671
|
+
recordHookError('ups:stdin', e, RUNTIME_DIR, { stage: 'read' });
|
|
660
672
|
return;
|
|
661
673
|
}
|
|
662
674
|
|
|
663
675
|
let hookData;
|
|
664
676
|
try {
|
|
665
677
|
hookData = JSON.parse(raw);
|
|
666
|
-
} catch {
|
|
678
|
+
} catch (e) {
|
|
679
|
+
recordHookError('ups:stdin', e, RUNTIME_DIR, {
|
|
680
|
+
stage: 'parse',
|
|
681
|
+
inputLen: raw?.length ?? 0,
|
|
682
|
+
truncated,
|
|
683
|
+
capBytes: MAX_UPS_PROMPT_BYTES,
|
|
684
|
+
});
|
|
667
685
|
return;
|
|
668
686
|
}
|
|
669
687
|
// JSON.parse('null'/'42'/'"x"') succeeds with a non-object; dereferencing .prompt on
|