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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.6.0",
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.6.0",
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 install.mjs doctor # Diagnose issues
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 install.mjs doctor` before trusting search results.
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 install.mjs doctor # 诊断问题
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 import('./install.mjs');
164
+ const { main } = await loadInstaller();
94
165
  await main([]);
95
166
  }
96
167
  } else if (INSTALL_COMMANDS.has(cmd)) {
97
- const { main } = await import('./install.mjs');
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, 'install.mjs')} repair\``,
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
- try {
2117
- const { checkFTSIntegrity, rebuildFTS } = await import('./schema.mjs');
2118
- const rwDb = new Database(DB_PATH);
2119
- rwDb.pragma('busy_timeout = 3000');
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 { healthy, details } = checkFTSIntegrity(rwDb);
2122
- if (healthy) {
2123
- ok('FTS5 integrity: all indexes healthy');
2124
- } else {
2125
- dwarn('FTS5 integrity issues detected:');
2126
- for (const d of details) log(` ${d}`);
2127
- log(' Attempting FTS5 rebuild...');
2128
- const { rebuilt, errors } = rebuildFTS(rwDb);
2129
- if (rebuilt.length > 0) ok(`FTS5 rebuilt: ${rebuilt.join(', ')}`);
2130
- if (errors.length > 0) {
2131
- fail(`FTS5 rebuild errors: ${errors.join(', ')}`);
2132
- issues++;
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
- } finally {
2136
- rwDb.close();
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, 'install.mjs')} repair)`,
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
- const scriptRemedy = `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'install.mjs')} repair)`;
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
- ok(`DB stats: ${sizeMB}MB, ${obsCount} observations, ${sessCount} sessions`);
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
  }
@@ -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.replace(/%/g, '\\%').replace(/_/g, '\\_');
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}`];
@@ -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
- const fp = prefix.match(/"file_path"\s*:\s*"((?:[^"\\]|\\.){0,4096})"/);
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`).
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.6.0",
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.6.0",
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.6.0",
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 name is matched literally.
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
- function likeLiteral(s) {
79
- return s.replace(/[\\%_]/g, '\\$&');
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
- filePath = e.tool_input?.file_path;
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 { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
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
- filePath = event.tool_input?.file_path;
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.file_path, event.tool_name in
404
- // Edit|Write|NotebookEdit|Read). Distinguishes "Claude Code renamed the field"
405
- // from "event genuinely has no file_path" — without this trace, a CC upstream
406
- // rename silently zeroes injection like code-graph's matcher bug.
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 (toolName && !['Edit', 'Write', 'NotebookEdit', 'Read'].includes(toolName)) {
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
- // Escape LIKE wildcards (still needed below for the events file_paths arms)
501
- const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
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 filePathEscaped = filePath.replace(/%/g, '\\%').replace(/_/g, '\\_');
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, `%"${escaped}"%`, `%"${filePathEscaped}"%`);
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 a bare string, as this script's callers expect.
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
- const { text } = await readHookStdin({
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