claude-mem-lite 3.70.2 → 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.2",
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.2",
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
 
@@ -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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.70.2",
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.2",
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.2",
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 {'
@@ -49,11 +58,23 @@ function bareProbe(root) {
49
58
  //
50
59
  // Flattening is inlined, NOT lib/binding-probe.mjs::flattenBindingError, because
51
60
  // this function is the fallback for a tree where lib/ failed to import — `helpers`
52
- // is still null on every path that reaches here. Duplicated deliberately; keep the
53
- // two in step.
54
- const why = String(r.stdout || '').replace(/\s+/g, ' ').trim().slice(0, 240)
55
- || (r.error && r.error.message)
56
- || `probe exited ${r.status ?? `on signal ${r.signal}`}`;
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}`}`;
57
78
  process.stderr.write(`[claude-mem-lite] binding probe: ${why}\n`);
58
79
  return false;
59
80
  }
@@ -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`);