claude-mem-lite 3.61.1 → 3.63.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.61.1",
13
+ "version": "3.63.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.61.1",
3
+ "version": "3.63.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
@@ -529,6 +529,32 @@ Notes:
529
529
  - Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
530
530
  - If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
531
531
 
532
+ #### Trust model per install path
533
+
534
+ The three install paths do **not** carry the same supply-chain guarantees — pick the one that matches your threat model:
535
+
536
+ | Path | Update mechanism | Ed25519 release-signature verification |
537
+ |------|------------------|----------------------------------------|
538
+ | npm / npx / git-clone direct install | auto-update from GitHub Releases | **Yes** — every runtime file (140 entries incl. hook scripts, MCP launcher, plugin declaration files) is hash-pinned in a signed manifest; verification is fail-closed |
539
+ | `/plugin install` (marketplace) | manual `/plugin marketplace update` + reinstall | **No** — Claude Code installs from a git clone of the marketplace repo; the plugin's own signature chain is not consulted on this path. You are trusting GitHub + the repo's branch protection, not the release signing key |
540
+
541
+ **Rollback recipe (plugin path).** If an update misbehaves, pin the marketplace clone to the previous release tag and reinstall from it:
542
+
543
+ ```bash
544
+ # 1. Find the local marketplace clone
545
+ ls ~/.claude/plugins/marketplaces/ # e.g. sdsrss
546
+
547
+ # 2. Pin it to the previous good tag (tags mirror npm versions, e.g. v3.62.0)
548
+ cd ~/.claude/plugins/marketplaces/sdsrss
549
+ git fetch --tags && git checkout v3.62.0
550
+
551
+ # 3. Reinstall from the pinned clone — inside Claude Code:
552
+ # /plugin install claude-mem-lite@sdsrss
553
+ # 4. To leave the pin later: git checkout main, then the normal update flow.
554
+ ```
555
+
556
+ 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.
557
+
532
558
  ### doctor
533
559
 
534
560
  Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
package/cli/common.mjs CHANGED
@@ -2,10 +2,13 @@
2
2
  // Extracted from mem-cli.mjs (v2.41) as first step in the god-module split.
3
3
  //
4
4
  // Scope: pure utilities only. No DB, no imports from other cli/ files; only
5
- // `lib/` leaf utilities may be re-exported through here (currently:
6
- // parseIdToken). This module is the single source of truth for stdout/stderr
7
- // framing, arg parsing, ID-token parsing, and relative-time formatting —
8
- // every command imports from here so the CLI stays consistent.
5
+ // leaf utilities from `lib/` and the repo root may be pulled in (currently:
6
+ // parseIdToken, neutralizeContextDelimiters). This module is the single source
7
+ // of truth for stdout/stderr framing, arg parsing, ID-token parsing, and
8
+ // relative-time formatting — every command imports from here so the CLI stays
9
+ // consistent.
10
+
11
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
9
12
 
10
13
  // ─── Argument Parsing ────────────────────────────────────────────────────────
11
14
 
@@ -85,8 +88,41 @@ export function parseArgs(argv) {
85
88
 
86
89
  // ─── Output Helpers ──────────────────────────────────────────────────────────
87
90
 
88
- /** Write a line to stdout. */
91
+ /**
92
+ * Write a line to stdout, with structural context delimiters neutralized.
93
+ *
94
+ * CLI stdout IS model context, not just a human channel: commands/mem.md routes
95
+ * `/mem search|get|recall|timeline` to `node cli.mjs … via Bash`, and
96
+ * buildServerInstructions actively tells the agent the Bash CLI is the CHEAPER path
97
+ * than the MCP tool. The MCP read family has been defanged since v3.61 at its own
98
+ * chokepoint (server.mjs safeHandler), but the CLI twins printed stored text raw —
99
+ * so the exact indirect-prompt-injection channel the MCP defang closes stayed open on
100
+ * the surface the instructions recommend (audit 2026-08-14 A1). Observations are
101
+ * stored raw on purpose (defense lives at the injection boundary, not at save), so it
102
+ * has to happen here, at the write.
103
+ *
104
+ * `out` is the single stdout writer for every command in mem-cli.mjs and cli/*.mjs,
105
+ * so a NEW read command is covered by construction (§9 parallel-path completeness).
106
+ * Payloads that must round-trip byte-exact use `outVerbatim` instead — see below.
107
+ * The transform is idempotent (it strips brackets, it does not re-add them), so a
108
+ * path that already defanged upstream — `context` → buildSessionContextLines — is
109
+ * unaffected.
110
+ */
89
111
  export function out(text) {
112
+ // String() first: neutralizeContextDelimiters coerces nullish to '', which would turn
113
+ // a pre-existing `out(undefined)` line from "undefined" into an empty line.
114
+ outVerbatim(neutralizeContextDelimiters(String(text)));
115
+ }
116
+
117
+ /**
118
+ * Write a line to stdout with NO defang. The CLI mirror of
119
+ * `safeHandler(fn, { verbatim: true })` on the MCP side, and for the same single
120
+ * reason: `export` is the backup half of backup/restore, so neutralizing its payload
121
+ * would silently rewrite every backed-up row whose text legitimately contains these
122
+ * tags — and `restore` would write the rewritten text back. Only use this for bytes
123
+ * that must survive a round trip; anything a model reads goes through `out`.
124
+ */
125
+ export function outVerbatim(text) {
90
126
  process.stdout.write(text + '\n');
91
127
  }
92
128
 
@@ -260,6 +296,43 @@ export function fmtDateShort(iso) {
260
296
  // because the formatter lived only in mem-cli.mjs.
261
297
  export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
262
298
 
299
+ // Display labels for observation columns whose NAME misdescribes their contents.
300
+ // `files_modified` holds whatever file list the writer attached: hook-captured rows fill
301
+ // it from Edit/Write, but an explicit mem_save / `save --files` puts any associated path
302
+ // there — including a file that was only read. Rendering the raw column name told the
303
+ // reader those files were modified (audit 2026-08-14 F3). The label is `files` — the name
304
+ // of the input parameter that fills it. The COLUMN is untouched, so `--fields
305
+ // files_modified` / `fields:["files_modified"]` still select it. Shared by the CLI `get`
306
+ // and MCP `mem_get` renderers so the two cannot drift.
307
+ const OBS_FIELD_LABELS = { files_modified: 'files' };
308
+
309
+ /** Reader-facing label for an observation column (identity for everything unmapped). */
310
+ export function obsFieldLabel(field) {
311
+ return OBS_FIELD_LABELS[field] || field;
312
+ }
313
+
314
+ /**
315
+ * The `maintain scan` pending-purge line, shared by the CLI (mem-cli.mjs cmdMaintain) and
316
+ * the MCP mem_maintain handler (server.mjs) so the two cannot drift — same reason
317
+ * obsFieldLabel lives here.
318
+ *
319
+ * The count is `compressed_into = COMPRESSED_PENDING_PURGE` (lib/maintain-core.mjs:443),
320
+ * and the ONLY writers of that sentinel are the two idle/decay passes — decayAndMarkIdle
321
+ * (maintain-core.mjs:201) and runIdleCleanup (search-scoring.mjs:303). Compression writes
322
+ * COMPRESSED_AUTO (-1) or a positive parent id, and those rows are NOT counted. The CLI
323
+ * used to render this as "compressed originals awaiting cleanup", which told an operator
324
+ * about to run `maintain execute --ops purge_stale --confirm` that they were deleting
325
+ * compression leftovers when they were deleting decay-marked live originals (audit
326
+ * 2026-08-14 A4). Say what the rows are and what deletes them — and say nothing about
327
+ * compression, which is a different sentinel with a different lifecycle.
328
+ *
329
+ * @param {number} n stats.pendingPurge
330
+ * @returns {string} the full indented line, identical on both surfaces.
331
+ */
332
+ export function formatPendingPurgeLine(n) {
333
+ return ` Pending purge (idle-marked): ${n} (live originals marked idle by decay — purge_stale deletes them)`;
334
+ }
335
+
263
336
  // Pure formatter — null/undefined/non-time pass through; integer time fields
264
337
  // render as `<raw> (<relative>)` so callers get both an audit value and a
265
338
  // human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
package/format-utils.mjs CHANGED
@@ -48,15 +48,100 @@ export function truncate(str, max = 80) {
48
48
  // itself, where source/observations carry the delimiter names.
49
49
  const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
50
50
 
51
+ // Pass cap for the fixpoint loop below. 32 nested layers of a forged delimiter is far past
52
+ // anything prose produces; the cap exists only to bound the ADVERSARIAL cost (an unbounded
53
+ // fixpoint is O(depth \u00d7 length), i.e. quadratic on a crafted 200k-char payload, on the
54
+ // synchronous hook/CLI write path \u2014 this repo has shipped two ReDoS findings already).
55
+ const DEFANG_MAX_PASSES = 32;
56
+
57
+ /**
58
+ * Strip the angle brackets of every `re` match, repeatedly, until the text stops changing.
59
+ *
60
+ * A SINGLE pass is not enough, and the gap is two characters wide: in
61
+ * `<<system-reminder>>` the pattern matches the INNER pair, removing it leaves the outer
62
+ * pair wrapped around the bare tag name, and the "defanged" output is a live
63
+ * `<system-reminder>` \u2014 produced by the function whose job is to make it inert (pre-tag
64
+ * review, 2026-08-14). Widening the pattern to also eat adjacent brackets does not fix it
65
+ * either: `<system-reminder <system-reminder> x>y>` re-forms `<system-reminder x>` from
66
+ * text the widened match keeps. Only a fixpoint closes the general case.
67
+ *
68
+ * TERMINATION: every match begins with `<` and the replacement drops it, so any pass that
69
+ * changes the string removes at least one `<` \u2014 the iteration is self-bounded by the number
70
+ * of `<` in the input, and DEFANG_MAX_PASSES bounds it again by a constant.
71
+ *
72
+ * INERT AT ANY DEPTH: if the text is still changing when the cap is reached (\u226532 nested
73
+ * forged layers \u2014 not reachable by accident), every remaining angle bracket is removed.
74
+ * That is lossier than the normal path, but the return value then provably contains no tag,
75
+ * which is the property callers rely on; giving up at the cap and returning still-tagged
76
+ * text would hand the attacker exactly the bypass the loop exists to close.
77
+ *
78
+ * @param {string} s Input string (any type; coerced)
79
+ * @param {RegExp} re Global tag pattern whose matches start with `<` and end with `>`
80
+ * @returns {string} Text containing no match of `re`
81
+ */
82
+ function defangToFixpoint(s, re) {
83
+ let text = String(s ?? '');
84
+ for (let pass = 0; pass < DEFANG_MAX_PASSES; pass++) {
85
+ const next = text.replace(re, (m) => m.slice(1, -1));
86
+ if (next === text) return text; // fixpoint: nothing left to defang
87
+ text = next;
88
+ }
89
+ return text.replace(/[<>]/g, ''); // pathological nesting \u2192 fail closed
90
+ }
91
+
51
92
  /**
52
93
  * Defang the literal context-block delimiter tags in user-derived text. Strips just the
53
94
  * angle brackets, so `</claude-mem-context>` renders as `/claude-mem-context` \u2014 still
54
95
  * readable, but no longer a structural delimiter. Complements `mdCell`'s pipe-escaping.
96
+ * Iterated to a fixpoint (see defangToFixpoint): a single pass let `<<system-reminder>>`
97
+ * come back live.
55
98
  * @param {string} s Input string (any type; coerced)
56
99
  * @returns {string} Text with delimiter tags defanged
57
100
  */
58
101
  export function neutralizeContextDelimiters(s) {
59
- return String(s ?? '').replace(CONTEXT_DELIMITER_RE, (m) => m.slice(1, -1));
102
+ return defangToFixpoint(s, CONTEXT_DELIMITER_RE);
103
+ }
104
+
105
+ // <skill-loaded> is deliberately NOT in CONTEXT_DELIMITER_RE above: mem_use's legitimate
106
+ // load path has to emit a REAL one, and that result goes through the same handler-wide
107
+ // defang, which would strip it. So the tag is neutralized here instead — per call site,
108
+ // on the untrusted text only. Attribute-bearing openers and the bare closer both match,
109
+ // same "strip the brackets, keep the text" treatment as the class above.
110
+ const SKILL_BLOCK_RE = /<\/?skill-loaded(?:\s[^>]*)?>/gi;
111
+
112
+ /**
113
+ * Defang a literal `<skill-loaded>` opener/closer in text that is about to be echoed
114
+ * INSIDE a mem_use response. A caller-supplied name interpolated raw could otherwise
115
+ * forge a whole skill block (plus its execute imperative) in a message the caller
116
+ * controls end to end — audit F7, 2026-08-14. Never apply this to the real load path.
117
+ * Same fixpoint iteration as the class above, for the same reason: one pass turned the
118
+ * caller-supplied `<<skill-loaded>>` back into a live `<skill-loaded>` opener.
119
+ * @param {string} s Input string (any type; coerced)
120
+ * @returns {string} Text with skill-block delimiters defanged
121
+ */
122
+ export function neutralizeSkillDelimiters(s) {
123
+ return defangToFixpoint(s, SKILL_BLOCK_RE);
124
+ }
125
+
126
+ // <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
127
+ // skill body it injects as PreToolUse additionalContext. The body comes from a
128
+ // third-party repo (tools/adopt import) — an untrusted boundary — so a literal
129
+ // `</skill-bridge>` inside it would close the wrapper early and spill the rest of
130
+ // the payload (e.g. a forged <system-reminder>) as undelimited context (audit
131
+ // 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
132
+ // isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
133
+ // call site to the untrusted body only.
134
+ const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
135
+
136
+ /**
137
+ * Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
138
+ * to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
139
+ * classes above. Never apply to the wrapper itself.
140
+ * @param {string} s Input string (any type; coerced)
141
+ * @returns {string} Text with skill-bridge delimiters defanged
142
+ */
143
+ export function neutralizeSkillBridgeDelimiters(s) {
144
+ return defangToFixpoint(s, SKILL_BRIDGE_RE);
60
145
  }
61
146
 
62
147
  /**
package/hook-handoff.mjs CHANGED
@@ -173,11 +173,20 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
173
173
  try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
174
174
  }
175
175
 
176
- // 5. Key decisions — high importance observations (skip low-signal degraded titles)
176
+ // 5. Key decisions — high importance observations (skip low-signal degraded titles).
177
+ //
178
+ // superseded_at IS NULL, unlike `completed` and `files_modified` above: those two are
179
+ // the session's own history ("what happened here"), where an overturned decision still
180
+ // happened and erasing it would misreport the session. key_decisions is different — it
181
+ // is replayed to the NEXT session under "## Key Decisions" as standing policy, so a
182
+ // retracted decision rendered there is indistinguishable from live policy. The
183
+ // carry-forward fallback at the top of this function already filters the same column;
184
+ // this is the sibling that did not.
177
185
  const decisions = db.prepare(`
178
186
  SELECT title FROM observations
179
187
  WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
180
- AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
188
+ AND COALESCE(compressed_into, 0) = 0
189
+ AND superseded_at IS NULL ${obsWindowClause}
181
190
  ORDER BY created_at_epoch DESC LIMIT 10
182
191
  `).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
183
192
 
package/hook-llm.mjs CHANGED
@@ -156,7 +156,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
156
156
  // (no lesson, importance<2, empty facts, thin narrative) is dropped before
157
157
  // dedup/MinHash/vector work. Opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1.
158
158
  if (isNoiseObservation(obs)) {
159
- debugLog('saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
159
+ debugLog('DEBUG', 'saveObservation', `dropped noise: ${truncate(obs.title || '', 60)}`);
160
160
  return null;
161
161
  }
162
162
 
@@ -172,7 +172,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
172
172
  // pre-save is not lost on LLM success — that path clean-inserts a fresh row — but it loses
173
173
  // those three.) capNoiseImportance then caps any title-noise survivor to imp=1 as before.
174
174
  if (isLowYieldChangeObs(obs)) {
175
- debugLog('saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
175
+ debugLog('DEBUG', 'saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
176
176
  return null;
177
177
  }
178
178
 
@@ -182,7 +182,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
182
182
  // enter the 7-day accelerated auto-compress window in hook.mjs.
183
183
  const capped = capNoiseImportance(obs);
184
184
  if (capped !== (obs.importance ?? 1)) {
185
- debugLog('saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
185
+ debugLog('DEBUG', 'saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
186
186
  obs.importance = capped;
187
187
  }
188
188
 
package/hook-shared.mjs CHANGED
@@ -7,6 +7,9 @@ import { join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
9
  import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
10
+ // Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
11
+ // neither imports this file) — no cycle.
12
+ import { recordHookError } from './lib/hook-telemetry.mjs';
10
13
  import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
11
14
  // Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
12
15
  // fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
@@ -157,7 +160,17 @@ export function openDb() {
157
160
  // WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
158
161
  // silently dead (null DB) on a corrupt WAL until the next MCP server start.
159
162
  return ensureDbWithWalRecovery();
160
- } catch {
163
+ } catch (e) {
164
+ // Still null, still no throw — a hook must never crash the host session, and all
165
+ // eight call sites in hook.mjs are written to no-op on null. But "returned null"
166
+ // used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
167
+ // reported 0 and doctor printed "no recent silent hook breakage" while every
168
+ // capture path was dead (audit B1, 2026-08-14 — the same blindness that hid the
169
+ // v3.60 binding outage for four days). recordHookError is the established sink;
170
+ // scripts/pre-tool-recall.js already logs its own db-open failures this way, and
171
+ // routing through it also flags the native-binding family for the session-start
172
+ // self-heal. The recorder swallows its own errors, so this cannot throw.
173
+ recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
161
174
  return null;
162
175
  }
163
176
  }
package/hook.mjs CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
48
48
  import { scrubRecord } from './lib/scrub-record.mjs';
49
49
  import { formatHookError } from './lib/native-binding-hint.mjs';
50
+ import { recordHookError } from './lib/hook-telemetry.mjs';
50
51
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
51
52
  import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
52
53
  import { snapshotDb } from './lib/db-backup.mjs';
@@ -94,8 +95,14 @@ const event = process.argv[2];
94
95
  // the dispatch below exits everything else). EVERY spawnBackground/queue* event
95
96
  // MUST be listed here — a missing entry makes the detached worker exit(0)
96
97
  // silently, which looks identical to "worker ran and found nothing" from the
97
- // outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line).
98
- const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain', 'enrich-save']);
98
+ // outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line;
99
+ // audit F6, 2026-08-14: update-check had been dead the same way, so the 24h
100
+ // release check never ran and every SessionStart respawned a worker that
101
+ // exit(0)'d before its handler).
102
+ // `tests/audit-findings-20260814.test.mjs` scans BOTH detached spawners
103
+ // (spawnBackground here, spawn(node,[HOOK_PATH,…]) in lib/save-enrich.mjs) and
104
+ // reds when a spawned event is missing from this list.
105
+ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain', 'enrich-save', 'update-check']);
99
106
 
100
107
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
101
108
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
@@ -139,8 +146,19 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
139
146
  // sessions into one garbled row. planEpisodeFlush returns [ep] by reference when
140
147
  // there is ≤1 CC session (the common case → identical to before), else one sub
141
148
  // per session. Pure/sync → safe inside the signal handler.
142
- for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub);
143
- try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
149
+ // Same B1 gate as flushEpisode: without an openable DB every save below is a
150
+ // no-op, so deleting the buffer afterwards would destroy the very episode this
151
+ // handler exists to salvage. openDb is sync (safe here) and records its own
152
+ // failure; leaving the file untouched lets the next fire retry it.
153
+ const db = openDb();
154
+ if (db) {
155
+ try {
156
+ for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub, db);
157
+ try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
158
+ } finally {
159
+ try { db.close(); } catch { /* already gone */ }
160
+ }
161
+ }
144
162
  }
145
163
  } catch {}
146
164
  process.exit(0);
@@ -164,6 +182,25 @@ const RECEIPT_EVENTS = new Set(['PostToolUse', 'SessionStart', 'UserPromptSubmit
164
182
  function flushEpisode(episode, hookEventName = 'PostToolUse') {
165
183
  if (!episode || episode.entries.length === 0) return;
166
184
 
185
+ // Acquire the DB ONCE, up front, and bail before touching anything destructive when it
186
+ // will not open. Every persistence step below is a no-op without it (saveObservation
187
+ // returns null on a null db; the detached llm-episode worker hits the same wall), yet
188
+ // the `unlinkSync(episodeFile())` at the tail used to run regardless — so a DB that
189
+ // could not be opened deleted the session's captured work while the hook exited 0 with
190
+ // empty stdout AND empty stderr (audit B1, 2026-08-14). Returning here leaves the
191
+ // buffer on disk for the next fire to retry; openDb() has already recorded the failure
192
+ // under `hook-shared:db-open`. Reusing the handle for the immediate saves also drops
193
+ // this path from one open per sub-episode to one per flush.
194
+ const db = openDb();
195
+ if (!db) return;
196
+ try {
197
+ flushEpisodeWithDb(db, episode, hookEventName);
198
+ } finally {
199
+ try { db.close(); } catch { /* already closed / gone */ }
200
+ }
201
+ }
202
+
203
+ function flushEpisodeWithDb(db, episode, hookEventName) {
167
204
  // Collect Read file paths tracked by post-tool-use.sh
168
205
  // Use rename to atomically collect — prevents losing concurrent appends
169
206
  const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
@@ -185,7 +222,7 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
185
222
  const subs = planEpisodeFlush(episode);
186
223
  let anySignificant = false;
187
224
  for (const sub of subs) {
188
- const r = flushEpisodeGroup(sub);
225
+ const r = flushEpisodeGroup(sub, db);
189
226
  if (r === 'writefail') {
190
227
  // Single-group: preserve the original early return — buffer left un-unlinked
191
228
  // for a later retry, no receipt. Multi-group: skip only the failed group and
@@ -250,7 +287,7 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
250
287
  // 'significant' | 'insignificant' | 'writefail'. CLAUDE_MEM_SKIP_EPISODE_LLM
251
288
  // suppresses the detached enrichment spawn (test determinism; sibling of
252
289
  // CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
253
- function flushEpisodeGroup(ep) {
290
+ function flushEpisodeGroup(ep, db) {
254
291
  const isSignificant = episodeHasSignificantContent(ep);
255
292
 
256
293
  // Immediate save: rule-based observation for instant visibility; the LLM
@@ -258,7 +295,9 @@ function flushEpisodeGroup(ep) {
258
295
  if (isSignificant) {
259
296
  try {
260
297
  const obs = buildImmediateObservation(ep);
261
- const id = saveObservation(obs, ep.project, ep.sessionId);
298
+ // `db` is flushEpisode's handle — passed in so the caller owns open/close and the
299
+ // whole flush is gated on one availability check (B1).
300
+ const id = saveObservation(obs, ep.project, ep.sessionId, db);
262
301
  if (id) ep.savedId = id;
263
302
  } catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
264
303
  }
@@ -300,6 +339,23 @@ async function handlePostToolUse() {
300
339
 
301
340
  const { tool_name, tool_input, tool_response } = hookData;
302
341
  if (!tool_name) return;
342
+ // A non-string tool_name is a host-protocol violation, not a payload we can handle:
343
+ // `tool_name.startsWith(p)` two lines down threw a TypeError that the top-level catch
344
+ // absorbed, so the observation was dropped with nothing attributable behind it. Guard the
345
+ // type (parity with scripts/pre-skill-bridge.js:43) and RECORD it rather than dropping
346
+ // quietly: PostToolUse is the plugin's whole capture path, so a host field-shape change
347
+ // would kill every observation, and hook-errors/ is the only window into that — the same
348
+ // blindness that let the v3.60 binding outage run for 4 days. Volume is bounded by the
349
+ // recorder's 14-day retention and one short line per fire.
350
+ if (typeof tool_name !== 'string') {
351
+ recordHookError(
352
+ 'post-tool-use:tool_name-type',
353
+ new TypeError(`tool_name is ${Array.isArray(tool_name) ? 'array' : typeof tool_name}, expected string`),
354
+ RUNTIME_DIR,
355
+ { toolNameType: Array.isArray(tool_name) ? 'array' : typeof tool_name },
356
+ );
357
+ return;
358
+ }
303
359
 
304
360
  // Skip noise (source of truth: skip-tools.mjs)
305
361
  if (SKIP_TOOLS.has(tool_name)) return;
@@ -426,9 +482,24 @@ function triggerErrorRecall(db, toolInput, response) {
426
482
  FROM observations_fts
427
483
  JOIN observations o ON observations_fts.rowid = o.id
428
484
  WHERE observations_fts MATCH ? AND o.project = ?
485
+ -- Live-row invariant, same as every other model-facing retrieval path
486
+ -- (hook-context obsPool/fallbackObs/keyObs, hook-memory, search-engine,
487
+ -- recent/search/timeline/recall-core, pre-tool-recall, user-prompt-search).
488
+ -- This surface INLINES rows[0].lesson_learned into the model context, so an
489
+ -- unfiltered SELECT handed a retracted lesson to the agent verbatim while its
490
+ -- correction trailed as a bare pointer. compressed_into is filtered too, not
491
+ -- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
492
+ -- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
493
+ -- so that pointer would resolve to nothing.
494
+ AND COALESCE(o.compressed_into, 0) = 0
495
+ AND o.superseded_at IS NULL
429
496
  AND ${notLowSignalTitleClause('o')}
497
+ -- MAX(0, …) clamps recency age to >= 0 (parity with search-engine FULL_SCORE):
498
+ -- a far-future created_at (reachable via restore/import-jsonl, which accept
499
+ -- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
500
+ -- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
430
501
  ORDER BY ${OBS_BM25}
431
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
502
+ * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / 1209600000.0))
432
503
  LIMIT 3
433
504
  `).all(ftsQuery, project, nowR);
434
505
 
@@ -497,10 +568,17 @@ async function handleStop() {
497
568
  // Prevents data loss from concurrent PostToolUse writes between read and delete.
498
569
  const epFile = episodeFile();
499
570
  const claimFile = epFile + `.claim-${process.pid}-${Date.now()}`;
571
+ // Third instance of the B1 gate (flushEpisode and the SIGTERM salvage are the other
572
+ // two): this path already MOVED the buffer out of the way, so with no openable DB the
573
+ // `unlinkSync(claimFile)` below would destroy it just as surely — and the 1h orphan
574
+ // sweep would have eaten a restored-but-unnoticed claim file anyway. Open once, and
575
+ // put the buffer back under its real name when the save cannot happen.
576
+ let claimDb;
500
577
  try {
501
578
  renameSync(epFile, claimFile);
579
+ claimDb = openDb();
502
580
  try {
503
- const episode = JSON.parse(readFileSync(claimFile, 'utf8'));
581
+ const episode = claimDb ? JSON.parse(readFileSync(claimFile, 'utf8')) : null;
504
582
  if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
505
583
  if (!episode.sessionId) episode.sessionId = sessionId;
506
584
  if (!episode.project) episode.project = project;
@@ -519,7 +597,7 @@ async function handleStop() {
519
597
  if (!episodeHasSignificantContent(sub)) continue;
520
598
  try {
521
599
  const obs = buildImmediateObservation(sub);
522
- const id = saveObservation(obs, sub.project, sub.sessionId);
600
+ const id = saveObservation(obs, sub.project, sub.sessionId, claimDb);
523
601
  if (id) sub.savedId = id;
524
602
  } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
525
603
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
@@ -528,7 +606,15 @@ async function handleStop() {
528
606
  }
529
607
  }
530
608
  } finally {
531
- try { unlinkSync(claimFile); } catch {}
609
+ if (claimDb) {
610
+ try { unlinkSync(claimFile); } catch {}
611
+ try { claimDb.close(); } catch { /* already gone */ }
612
+ } else {
613
+ // Nothing was (or could be) persisted — restore the buffer under its real name
614
+ // so the next fire retries it. If even the rename fails, the claim file stays
615
+ // and the 1h orphan sweep collects it, which is the pre-B1 behaviour.
616
+ try { renameSync(claimFile, epFile); } catch { /* leave it for sweepOrphanEpisodeFiles */ }
617
+ }
532
618
  }
533
619
  } catch (e) { debugCatch(e, 'handleStop-fallback'); }
534
620
  }
@@ -935,6 +1021,15 @@ function runSessionStartAutoMaintain(db) {
935
1021
 
936
1022
  // Auto-dedup (exact): merge identical-title observations within 1h.
937
1023
  // Catches rapid duplicate writes (same hook firing twice, race conditions).
1024
+ // BOTH join sides must be live (audit 2026-08-14 H-1): without the
1025
+ // superseded_at filters (which the fuzzy channel below always had), a row the
1026
+ // fuzzy pass had tombstoned could come back as `a` (a.id < b.id) and tombstone
1027
+ // the LIVE keeper `b` — both copies gone from every read path. Worse, a user
1028
+ // correction saved with supersedes=[#A] (A.superseded_by = B's NUMERIC id) has
1029
+ // the same title as A, so the pair (A, B) tombstoned the correction B itself
1030
+ // and the string 'auto-dedup' write clobbered numeric supersession chains that
1031
+ // citation-tracker decay hand-off and timeline re-anchoring both follow. The
1032
+ // UPDATE repeats the guard so a concurrent writer can't re-stamp a chain.
938
1033
  const dupPairs = db.prepare(`
939
1034
  SELECT a.id as keep_id, b.id as remove_id
940
1035
  FROM observations a
@@ -943,12 +1038,14 @@ function runSessionStartAutoMaintain(db) {
943
1038
  AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
944
1039
  AND COALESCE(a.compressed_into, 0) = 0
945
1040
  AND COALESCE(b.compressed_into, 0) = 0
1041
+ AND a.superseded_at IS NULL
1042
+ AND b.superseded_at IS NULL
946
1043
  LIMIT 20
947
1044
  `).all();
948
1045
  if (dupPairs.length > 0) {
949
1046
  const removeIds = dupPairs.map(p => p.remove_id);
950
1047
  const ph = removeIds.map(() => '?').join(',');
951
- db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
1048
+ db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
952
1049
  debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
953
1050
  }
954
1051
 
@@ -1566,9 +1663,13 @@ async function handleUserPrompt() {
1566
1663
  try {
1567
1664
  const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
1568
1665
  const raw = readFileSync(injectedFile, 'utf8');
1569
- const { ids, ts } = JSON.parse(raw);
1570
- // Only use if written within last 10 seconds (same prompt cycle)
1571
- if (ts && Date.now() - ts < 10000 && Array.isArray(ids)) {
1666
+ const { ids, ts, session } = JSON.parse(raw);
1667
+ // Only use if written within last 10 seconds (same prompt cycle) AND by this
1668
+ // CC session the file is project-keyed, so a concurrent session's write
1669
+ // would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
1670
+ // Legacy payloads without `session` keep the old time-window-only behavior.
1671
+ if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
1672
+ && !(session && ccSessionId && session !== ccSessionId)) {
1572
1673
  for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
1573
1674
  }
1574
1675
  } catch { /* file may not exist — that's fine */ }
@@ -1767,9 +1868,19 @@ try {
1767
1868
  case 'auto-maintain': handleAutoMaintain(); break;
1768
1869
  case 'llm-optimize': await handleLLMOptimize(); break;
1769
1870
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1770
- // GitHub fetch + (non-plugin) install off the SessionStart critical path,
1771
- // writing update-state.json so the NEXT session's cached banner is fresh.
1772
- case 'update-check': await checkForUpdate(); break;
1871
+ // GitHub fetch off the SessionStart critical path, writing update-state.json so
1872
+ // the NEXT session's cached banner is fresh.
1873
+ //
1874
+ // F6 staging: the detached update-check worker has not run since v2.85.0
1875
+ // (missing from BG_EVENTS). Restore the check + banner first; re-enable the
1876
+ // self-replacing install in a follow-up once this path has proven itself, so a
1877
+ // failure in either half is attributable. Without the option, hook-update.mjs's
1878
+ // `allowInstall = options.allowInstall ?? !pluginMode` defaults to TRUE on a
1879
+ // direct / settings.json install, so fixing F6 would switch a ten-week-dormant
1880
+ // self-installer back on in the same release that resurrects the worker. The
1881
+ // module default and the installer's own guards are unchanged — install.mjs
1882
+ // still passes allowInstall:true for the explicit, user-invoked update.
1883
+ case 'update-check': await checkForUpdate({ allowInstall: false }); break;
1773
1884
  }
1774
1885
  } catch (err) {
1775
1886
  // Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
@@ -1779,6 +1890,11 @@ try {
1779
1890
  // lib/native-binding-hint.mjs.
1780
1891
  const line = formatHookError(err, event, { runtimeDir: RUNTIME_DIR });
1781
1892
  if (line) console.error(line);
1893
+ // stderr alone is invisible to `stats` self-observation — only the native-binding
1894
+ // family was persisted, so a non-binding fatal (schema drift, bad stdin shape)
1895
+ // could kill every dispatch-routed surface while the hook-errors log read zero
1896
+ // (audit 2026-08-14 M-5; same blindness one layer up from the v3.60 outage).
1897
+ recordHookError(`hook:${event}`, err, RUNTIME_DIR);
1782
1898
  }
1783
1899
 
1784
1900
  process.exit(0);
package/hooks/hooks.json CHANGED
@@ -72,6 +72,16 @@
72
72
  "timeout": 5
73
73
  }
74
74
  ]
75
+ },
76
+ {
77
+ "matcher": "Edit|Write|NotebookEdit",
78
+ "hooks": [
79
+ {
80
+ "type": "command",
81
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-launcher.mjs\" scripts/post-tool-recall.js",
82
+ "timeout": 3
83
+ }
84
+ ]
75
85
  }
76
86
  ],
77
87
  "Stop": [