claude-mem-lite 3.70.1 → 3.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.70.1",
13
+ "version": "3.71.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.70.1",
3
+ "version": "3.71.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/hook.mjs CHANGED
@@ -48,7 +48,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
48
48
  import { scrubRecord } from './lib/scrub-record.mjs';
49
49
  import { formatHookError } from './lib/native-binding-hint.mjs';
50
50
  import { recordHookError } from './lib/hook-telemetry.mjs';
51
- import { queueHookContext, flushHookStdout } from './lib/hook-stdout.mjs';
51
+ import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
52
52
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
53
53
  import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
54
54
  import { snapshotDb } from './lib/db-backup.mjs';
@@ -1549,7 +1549,12 @@ async function handleSessionStart() {
1549
1549
  let updateCheckDue = false;
1550
1550
  try {
1551
1551
  const banner = getCachedUpdateBanner();
1552
- if (banner) stdoutParts.push(String(banner).trim());
1552
+ // The human channel, not additionalContext: "vX available" is a notice for the
1553
+ // USER. Folding it into additionalContext under suppressOutput:true kept its
1554
+ // content and lost its audience. Claude Code renders a command hook's top-level
1555
+ // systemMessage as its own hook_system_message, independent of the context
1556
+ // block — see lib/hook-stdout.mjs for the bundle evidence.
1557
+ if (banner) queueHookSystemMessage(String(banner));
1553
1558
  updateCheckDue = isUpdateCheckDue();
1554
1559
  } catch (e) { debugCatch(e, 'session-start-update'); }
1555
1560
 
@@ -51,6 +51,29 @@ export function isNativeBindingError(err) {
51
51
  return NATIVE_BINDING_PATTERNS.some((re) => re.test(msg));
52
52
  }
53
53
 
54
+ /**
55
+ * Render a binding error as ONE line without losing the diagnosis.
56
+ *
57
+ * Every surface used to do `String(err).split('\n')[0]`, which is exactly wrong for
58
+ * the ABI-mismatch family this subsystem exists to detect. Node's message puts the
59
+ * filename on line 0 and the `NODE_MODULE_VERSION 127 … requires 137` on lines 2-3,
60
+ * so first-line truncation printed a bare path and dropped the only part that says
61
+ * what is wrong. (The comment in probeBindingInFreshProcess below called that string
62
+ * "the highest-value line doctor prints"; for a stale binding it carried no diagnosis
63
+ * at all.) Collapsing whitespace keeps both the path and the numbers while staying
64
+ * safe for a JSON envelope, a JSONL log record and a one-line hook receipt.
65
+ *
66
+ * @param {unknown} err Error, string, or anything thrown.
67
+ * @param {number} [max] Hard cap; a probe must not be able to flood a receipt.
68
+ * @returns {string}
69
+ */
70
+ export function flattenBindingError(err, max = 240) {
71
+ const raw = err instanceof Error ? err.message : err;
72
+ const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
73
+ if (!s) return 'unknown';
74
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
75
+ }
76
+
54
77
  /**
55
78
  * Probe better-sqlite3's native binding by importing it from `installDir`'s
56
79
  * node_modules and opening an in-memory DB. Returns {ok, error?}.
@@ -26,15 +26,17 @@
26
26
 
27
27
  let parts = [];
28
28
  let queuedEvent = null;
29
+ let systemParts = [];
29
30
 
30
31
  /**
31
32
  * Queue a contribution to this process's single stdout envelope.
32
33
  *
33
34
  * @param {string} hookEventName Event name for hookSpecificOutput.
34
35
  * @param {string} text additionalContext contribution; empty/blank is ignored.
36
+ * @param {{warn?: (msg: string) => void}} [deps]
35
37
  * @returns {void}
36
38
  */
37
- export function queueHookContext(hookEventName, text) {
39
+ export function queueHookContext(hookEventName, text, deps = {}) {
38
40
  if (!hookEventName) return;
39
41
  const body = String(text ?? '').trim();
40
42
  if (!body) return;
@@ -42,11 +44,46 @@ export function queueHookContext(hookEventName, text) {
42
44
  // hookSpecificOutput.hookEventName does not match the event it dispatched.
43
45
  // In practice one process serves one event; keep the first and drop the
44
46
  // stragglers rather than emit an envelope the host rejects outright.
45
- if (queuedEvent && queuedEvent !== hookEventName) return;
47
+ //
48
+ // The drop is NOISY on purpose. It is unreachable today (all call sites are
49
+ // event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
50
+ // so a future caller that omits the argument would both mis-tag its receipt and
51
+ // have it swallowed without a trace. Silently vanishing work is this repo's
52
+ // most-repeated defect class; stderr is safe here because the host never parses it
53
+ // as the envelope.
54
+ if (queuedEvent && queuedEvent !== hookEventName) {
55
+ const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
56
+ warn(`[claude-mem-lite] hook-stdout: dropped a ${hookEventName} contribution — this process `
57
+ + `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
58
+ + 'This is a wiring bug: the contribution is lost.\n');
59
+ return;
60
+ }
46
61
  queuedEvent = hookEventName;
47
62
  parts.push(body);
48
63
  }
49
64
 
65
+ /**
66
+ * Queue a line for the HUMAN, not the model.
67
+ *
68
+ * Claude Code renders a command hook's top-level `systemMessage` as its own
69
+ * `hook_system_message` conversation message, independently of
70
+ * `hookSpecificOutput.additionalContext` — verified in the 2.1.234 bundle
71
+ * (`if (G.systemMessage) { … yield { message: yc({ type: "hook_system_message", … }) } }`)
72
+ * and documented there as "Display a message to the user (all hooks)". One envelope
73
+ * can therefore carry context for the model AND a notice for the user.
74
+ *
75
+ * Needed because v3.70.0's merge folded the update banner into additionalContext with
76
+ * `suppressOutput: true`, which kept its content and lost its audience.
77
+ *
78
+ * @param {string} text Notice for the user; empty/blank is ignored.
79
+ * @returns {void}
80
+ */
81
+ export function queueHookSystemMessage(text) {
82
+ const body = String(text ?? '').trim();
83
+ if (!body) return;
84
+ systemParts.push(body);
85
+ }
86
+
50
87
  /**
51
88
  * Write the queued contributions as one envelope. Idempotent: a second call
52
89
  * with nothing queued writes nothing, so calling it from both the dispatcher
@@ -56,18 +93,25 @@ export function queueHookContext(hookEventName, text) {
56
93
  * @returns {boolean} true when an envelope was written.
57
94
  */
58
95
  export function flushHookStdout(deps = {}) {
59
- if (!queuedEvent || parts.length === 0) return false;
96
+ const hasContext = queuedEvent && parts.length > 0;
97
+ const hasSystem = systemParts.length > 0;
98
+ if (!hasContext && !hasSystem) return false;
60
99
  const write = deps.write || ((s) => process.stdout.write(s));
61
- const payload = JSON.stringify({
62
- suppressOutput: true,
63
- hookSpecificOutput: {
100
+ const envelope = { suppressOutput: true };
101
+ if (hasSystem) envelope.systemMessage = systemParts.join('\n');
102
+ // Omitted entirely when there is no model-facing context: Stop's schema REJECTS a
103
+ // hookSpecificOutput block, and an envelope carrying only a user notice must not
104
+ // invent an event name to hang one on.
105
+ if (hasContext) {
106
+ envelope.hookSpecificOutput = {
64
107
  hookEventName: queuedEvent,
65
108
  additionalContext: parts.join('\n\n'),
66
- },
67
- }) + '\n';
109
+ };
110
+ }
68
111
  parts = [];
69
112
  queuedEvent = null;
70
- write(payload);
113
+ systemParts = [];
114
+ write(JSON.stringify(envelope) + '\n');
71
115
  return true;
72
116
  }
73
117
 
@@ -75,9 +119,10 @@ export function flushHookStdout(deps = {}) {
75
119
  export function resetHookStdout() {
76
120
  parts = [];
77
121
  queuedEvent = null;
122
+ systemParts = [];
78
123
  }
79
124
 
80
125
  /** Test seam: what is queued right now. */
81
126
  export function peekHookStdout() {
82
- return { hookEventName: queuedEvent, parts: [...parts] };
127
+ return { hookEventName: queuedEvent, parts: [...parts], systemParts: [...systemParts] };
83
128
  }
@@ -34,7 +34,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
35
  import { homedir } from 'node:os';
36
36
 
37
- import { probeBindingInFreshProcess, NATIVE_BINDING_REBUILD_CMD } from './binding-probe.mjs';
37
+ import { probeBindingInFreshProcess, NATIVE_BINDING_REBUILD_CMD, flattenBindingError } from './binding-probe.mjs';
38
38
 
39
39
  // Module-private: nothing outside needs these, and a new unused export is a
40
40
  // review signal against the knip baseline recorded in CLAUDE.md.
@@ -216,7 +216,10 @@ export function probeRuntimeRoots(roots, deps = {}) {
216
216
  // not installed exits 0 and heals nothing, so an unowned tree that also cannot
217
217
  // resolve from an ancestor needs an install; a present-but-unloadable tree needs
218
218
  // the rebuild.
219
- const error = String(r.error || 'unknown').split('\n')[0];
219
+ // NOT `.split('\n')[0]`: Node puts the filename on line 0 and the
220
+ // `NODE_MODULE_VERSION 127 … requires 137` on lines 2-3, so first-line truncation
221
+ // showed a bare path for the one fault family this check exists for.
222
+ const error = flattenBindingError(r.error);
220
223
  return ownDeps
221
224
  ? { label, root, ok: false, error, repair: `cd ${root} && ${NATIVE_BINDING_REBUILD_CMD}` }
222
225
  : {
@@ -24,7 +24,7 @@
24
24
  import { join } from 'node:path';
25
25
  import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
26
26
  import { fileURLToPath } from 'node:url';
27
- import { isNativeBindingError } from './binding-probe.mjs';
27
+ import { isNativeBindingError, flattenBindingError } from './binding-probe.mjs';
28
28
 
29
29
  export const NATIVE_BINDING_HINT_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h
30
30
  const MARKER_NAME = 'native-binding-hint-last';
@@ -136,7 +136,7 @@ export function recordNativeBindingBreakage(runtimeDir, { reason = '', event = '
136
136
  const tmp = `${marker}.tmp-${process.pid}`;
137
137
  // First line only: the ABI error is multi-line and the marker is read by the
138
138
  // launcher (pure node:, no parser beyond JSON.parse) and by `doctor`.
139
- writeFileSync(tmp, JSON.stringify({ reason: String(reason).split('\n')[0], event, ts: now }));
139
+ writeFileSync(tmp, JSON.stringify({ reason: flattenBindingError(reason), event, ts: now }));
140
140
  renameSync(tmp, marker);
141
141
  } catch { /* best-effort */ }
142
142
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.70.1",
3
+ "version": "3.71.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.70.1",
9
+ "version": "3.71.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.70.1",
3
+ "version": "3.71.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",
@@ -32,6 +32,15 @@ const ROOT = process.env.PROBE_ROOT || join(dirname(fileURLToPath(import.meta.ur
32
32
  // broken flag, and a helperless broken tree is repaired by the hook-launcher
33
33
  // path instead. Out of process like every other probe here — loading a stale
34
34
  // .node caches a dead module handle for the rest of THIS process.
35
+ // Output-identical twin of lib/binding-probe.mjs::flattenBindingError, kept here
36
+ // because bareProbe runs when lib/ could not be imported. Same 240 cap, same
37
+ // ellipsis, same 'unknown' floor — asserted for parity by the tests.
38
+ function flattenLocal(err, max = 240) {
39
+ const s = String(err ?? '').replace(/\s+/g, ' ').trim();
40
+ if (!s) return 'unknown';
41
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
42
+ }
43
+
35
44
  function bareProbe(root) {
36
45
  const script =
37
46
  'try {'
@@ -42,10 +51,30 @@ function bareProbe(root) {
42
51
  const r = spawnSync(process.execPath, ['-e', script], { stdio: 'pipe', timeout: 8000 });
43
52
  if (!r.error && r.status === 0) return true;
44
53
  // Say WHY. The inline predecessor printed the cause here; dropping it left the
45
- // user with setup.sh's generic "binding unusable" and nothing to act on.
46
- const why = String(r.stdout || '').trim().split('\n')[0]
47
- || (r.error && r.error.message)
48
- || `probe exited ${r.status ?? `on signal ${r.signal}`}`;
54
+ // user with setup.sh's generic "binding unusable" and nothing to act on. Flattened
55
+ // rather than first-lined: Node's ABI message puts the filename on line 0 and the
56
+ // NODE_MODULE_VERSION pair on lines 2-3, so `.split('\n')[0]` said WHERE but never
57
+ // WHY for the exact fault this probe exists to find.
58
+ //
59
+ // Flattening is inlined, NOT lib/binding-probe.mjs::flattenBindingError, because
60
+ // this function is the fallback for a tree where lib/ failed to import — `helpers`
61
+ // is still null on every path that reaches here.
62
+ //
63
+ // The twin must stay byte-identical in OUTPUT, and it did not: the first draft
64
+ // capped with a bare `.slice(0, 240)` while the shared helper appends an ellipsis,
65
+ // so they already disagreed at the one boundary the duplication exists to protect.
66
+ // A comment is not a guard, and this repo's hand-maintained twins have drifted
67
+ // before. tests/binding-error-diagnosis.test.mjs now drives THIS path in a
68
+ // lib/-less tree and asserts parity with the shared helper.
69
+ // Order matters: flattenLocal floors to the string 'unknown', which is truthy, so
70
+ // `flattenLocal(x) || fallback` would make the fallbacks unreachable and swallow a
71
+ // spawn error or an exit code whenever the child printed nothing. Pick the source
72
+ // FIRST, then flatten it.
73
+ const printed = String(r.stdout || '').trim();
74
+ const spawnErr = r.error && r.error.message;
75
+ const why = printed ? flattenLocal(printed)
76
+ : spawnErr ? flattenLocal(spawnErr)
77
+ : `probe exited ${r.status ?? `on signal ${r.signal}`}`;
49
78
  process.stderr.write(`[claude-mem-lite] binding probe: ${why}\n`);
50
79
  return false;
51
80
  }
@@ -82,9 +111,9 @@ try {
82
111
  }
83
112
  const release = helpers.acquireLock(lockPath);
84
113
  if (!release) {
85
- const firstLine = String(first.error).split('\n')[0];
86
114
  process.stderr.write(
87
- `[claude-mem-lite] binding probe: ${firstLine} (another install/repair in flight — deferring heal)\n`,
115
+ `[claude-mem-lite] binding probe: ${helpers.flattenBindingError(first.error)} `
116
+ + '(another install/repair in flight — deferring heal)\n',
88
117
  );
89
118
  process.exit(1);
90
119
  }
@@ -20,9 +20,32 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
20
20
  });
21
21
  process.stderr.write('[claude-mem-lite] Dependencies installed\n');
22
22
  } catch (e) {
23
- // Plugin-cache / multi-user / disk-full installs can fail here. Without this
24
- // catch the user sees a Node stack trace; with it they get an actionable line.
25
- const detail = e.message?.split('\n')[0] || e.code || 'unknown error';
23
+ // Plugin-cache / multi-user / disk-full installs can fail here, and this is not a
24
+ // rare path: Claude Code materializes each new plugin-cache version WITHOUT
25
+ // node_modules, so the guard above opens on the first MCP launch after every
26
+ // plugin update. Without this catch the user sees a Node stack trace.
27
+ //
28
+ // `.split('\n')[0]` is CORRECT here, unlike the four binding-error sites fixed in
29
+ // v3.70.2, and the difference is the `stdio` above: stderr is **inherit**, so
30
+ // npm's own diagnosis (`npm error code EROFS`, `path …`, `rofs EROFS: read-only
31
+ // file system …`) has already streamed straight to the user's terminal by the time
32
+ // we get here — verified by running this file against an unwritable ROOT. With
33
+ // stderr inherited, execSync's `e.message` holds only "Command failed: <cmd>";
34
+ // there is no captured diagnosis to lose. Do NOT "fix" this by piping stderr to
35
+ // recover it: piping is what made a compiling better-sqlite3 look hung under the
36
+ // 5-min bash timeout (bug audit 2026-05), which is why stderr is inherited.
37
+ //
38
+ // A pre-tag review measured `e.message` under `stdio: 'pipe'`, where stderr IS
39
+ // folded into the message, and concluded this line drops the diagnosis. It does
40
+ // not — the stdio differs. Recorded here because the same wrong conclusion is
41
+ // easy to reach from the code alone.
42
+ //
43
+ // `e.status` not `e.code`: execSync failures carry the exit status on `status`,
44
+ // so the old `|| e.code` rung was dead.
45
+ const detail = e?.message?.split('\n')[0]
46
+ || (e?.status != null ? `npm exited ${e.status}` : '')
47
+ || (e?.signal ? `npm killed by ${e.signal}` : '')
48
+ || 'unknown error';
26
49
  process.stderr.write(`[claude-mem-lite] npm install failed in ${ROOT} — ${detail}\n`);
27
50
  process.stderr.write(`[claude-mem-lite] Likely cause: read-only directory, disk full, or network blocked.\n`);
28
51
  process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm install --omit=dev\n`);