claude-mem-lite 3.61.0 → 3.62.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.0",
13
+ "version": "3.62.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.0",
3
+ "version": "3.62.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/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
 
@@ -176,6 +212,11 @@ export const KNOWN_CLI_FLAGS = new Set([
176
212
  // stay silent about (`adopt --disable/--enable`, `activity --min-importance`,
177
213
  // `save --supersedes`). Verified by running each command and checking for a warning.
178
214
  'disable', 'enable', 'min-importance', 'supersedes',
215
+ // `doctor --benchmark --prompts-limit N` — read off raw argv in cli/doctor.mjs, so
216
+ // it never appeared in a `flags.x` grep. Caught by independent review after the
217
+ // warn-on-every-unknown-flag flip turned the omission into a false warning on a
218
+ // documented, working command.
219
+ 'prompts-limit',
179
220
  ]);
180
221
 
181
222
  /** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
@@ -255,6 +296,43 @@ export function fmtDateShort(iso) {
255
296
  // because the formatter lived only in mem-cli.mjs.
256
297
  export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
257
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
+
258
336
  // Pure formatter — null/undefined/non-time pass through; integer time fields
259
337
  // render as `<raw> (<relative>)` so callers get both an audit value and a
260
338
  // human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
package/format-utils.mjs CHANGED
@@ -48,15 +48,79 @@ 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);
60
124
  }
61
125
 
62
126
  /**
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,6 +482,17 @@ 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')}
430
497
  ORDER BY ${OBS_BM25}
431
498
  * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
@@ -497,10 +564,17 @@ async function handleStop() {
497
564
  // Prevents data loss from concurrent PostToolUse writes between read and delete.
498
565
  const epFile = episodeFile();
499
566
  const claimFile = epFile + `.claim-${process.pid}-${Date.now()}`;
567
+ // Third instance of the B1 gate (flushEpisode and the SIGTERM salvage are the other
568
+ // two): this path already MOVED the buffer out of the way, so with no openable DB the
569
+ // `unlinkSync(claimFile)` below would destroy it just as surely — and the 1h orphan
570
+ // sweep would have eaten a restored-but-unnoticed claim file anyway. Open once, and
571
+ // put the buffer back under its real name when the save cannot happen.
572
+ let claimDb;
500
573
  try {
501
574
  renameSync(epFile, claimFile);
575
+ claimDb = openDb();
502
576
  try {
503
- const episode = JSON.parse(readFileSync(claimFile, 'utf8'));
577
+ const episode = claimDb ? JSON.parse(readFileSync(claimFile, 'utf8')) : null;
504
578
  if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
505
579
  if (!episode.sessionId) episode.sessionId = sessionId;
506
580
  if (!episode.project) episode.project = project;
@@ -519,7 +593,7 @@ async function handleStop() {
519
593
  if (!episodeHasSignificantContent(sub)) continue;
520
594
  try {
521
595
  const obs = buildImmediateObservation(sub);
522
- const id = saveObservation(obs, sub.project, sub.sessionId);
596
+ const id = saveObservation(obs, sub.project, sub.sessionId, claimDb);
523
597
  if (id) sub.savedId = id;
524
598
  } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
525
599
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
@@ -528,7 +602,15 @@ async function handleStop() {
528
602
  }
529
603
  }
530
604
  } finally {
531
- try { unlinkSync(claimFile); } catch {}
605
+ if (claimDb) {
606
+ try { unlinkSync(claimFile); } catch {}
607
+ try { claimDb.close(); } catch { /* already gone */ }
608
+ } else {
609
+ // Nothing was (or could be) persisted — restore the buffer under its real name
610
+ // so the next fire retries it. If even the rename fails, the claim file stays
611
+ // and the 1h orphan sweep collects it, which is the pre-B1 behaviour.
612
+ try { renameSync(claimFile, epFile); } catch { /* leave it for sweepOrphanEpisodeFiles */ }
613
+ }
532
614
  }
533
615
  } catch (e) { debugCatch(e, 'handleStop-fallback'); }
534
616
  }
@@ -1767,9 +1849,19 @@ try {
1767
1849
  case 'auto-maintain': handleAutoMaintain(); break;
1768
1850
  case 'llm-optimize': await handleLLMOptimize(); break;
1769
1851
  // 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;
1852
+ // GitHub fetch off the SessionStart critical path, writing update-state.json so
1853
+ // the NEXT session's cached banner is fresh.
1854
+ //
1855
+ // F6 staging: the detached update-check worker has not run since v2.85.0
1856
+ // (missing from BG_EVENTS). Restore the check + banner first; re-enable the
1857
+ // self-replacing install in a follow-up once this path has proven itself, so a
1858
+ // failure in either half is attributable. Without the option, hook-update.mjs's
1859
+ // `allowInstall = options.allowInstall ?? !pluginMode` defaults to TRUE on a
1860
+ // direct / settings.json install, so fixing F6 would switch a ten-week-dormant
1861
+ // self-installer back on in the same release that resurrects the worker. The
1862
+ // module default and the installer's own guards are unchanged — install.mjs
1863
+ // still passes allowInstall:true for the explicit, user-invoked update.
1864
+ case 'update-check': await checkForUpdate({ allowInstall: false }); break;
1773
1865
  }
1774
1866
  } catch (err) {
1775
1867
  // Log fatal errors (ungated) with structured format. ERR_DLOPEN_FAILED (an
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": [
package/install.mjs CHANGED
@@ -679,6 +679,24 @@ const memPostToolUse = {
679
679
  }]
680
680
  };
681
681
 
682
+ // Component 2 of the bind-salience forcing function: after an Edit/Write, flag an
683
+ // identifier the file's own lesson named that the edit just removed (component 1 is the
684
+ // pre-edit directive from scripts/pre-tool-recall.js, which also records the identifiers
685
+ // this one checks). Shipped, signed and tested since it was written, but registered in
686
+ // NEITHER registry — so `CLAUDE_MEM_SALIENCE=bind` delivered half the mechanism and
687
+ // nothing said so (audit B6, 2026-08-14). Matched on the edit tools only, NOT Read: there
688
+ // is no post-edit state to compare after a read. Inert (returns before touching stdin)
689
+ // unless CLAUDE_MEM_SALIENCE=bind, so the default chain pays one short-circuit spawn per
690
+ // edit and emits nothing.
691
+ const memPostToolRecall = {
692
+ matcher: 'Edit|Write|NotebookEdit',
693
+ hooks: [{
694
+ type: 'command',
695
+ command: nodeHook('scripts/post-tool-recall.js'),
696
+ timeout: 3
697
+ }]
698
+ };
699
+
682
700
  const memSessionStart = {
683
701
  matcher: 'startup|clear|compact',
684
702
  hooks: [{
@@ -697,6 +715,21 @@ const memStop = {
697
715
  }]
698
716
  };
699
717
 
718
+ // Fires immediately BEFORE auto-compaction, re-emitting <claude-mem-context> so the
719
+ // summarizer that rewrites the transcript still has memory in scope (SessionStart's
720
+ // compact matcher fires AFTER, when the context is already gone). Parity with
721
+ // hooks/hooks.json: omitting it here made every settings.json install lose exactly the
722
+ // block that exists to survive compaction, invisibly — doctor only ever asked "are ANY
723
+ // mem hooks present", never "which events" (audit B3, 2026-08-14).
724
+ const memPreCompact = {
725
+ matcher: '*',
726
+ hooks: [{
727
+ type: 'command',
728
+ command: nodeHook('hook.mjs', 'pre-compact'),
729
+ timeout: 5
730
+ }]
731
+ };
732
+
700
733
  const memUserPrompt = {
701
734
  matcher: '*',
702
735
  hooks: [
@@ -754,9 +787,14 @@ const memPreAgentInject = {
754
787
 
755
788
  // Filter out existing mem hooks, then append fresh ones
756
789
  // PreToolUse has three separate matchers, so we register all three
790
+ // Event set MUST stay equal to hooks/hooks.json's (minus scripts/setup.sh, which
791
+ // bootstraps the plugin cache and has no settings.json counterpart) —
792
+ // tests/audit-silent-20260814.test.mjs diffs a real `install --dev` run's
793
+ // settings.json against the shipped manifest and reds on any new divergence.
757
794
  const hookConfigs = {
758
795
  PreToolUse: [memPreToolRecall, memPreSkillBridge, memPreAgentInject],
759
- PostToolUse: [memPostToolUse],
796
+ PostToolUse: [memPostToolUse, memPostToolRecall],
797
+ PreCompact: [memPreCompact],
760
798
  SessionStart: [memSessionStart],
761
799
  Stop: [memStop],
762
800
  UserPromptSubmit: [memUserPrompt],
@@ -768,7 +806,10 @@ for (const [event, configs] of Object.entries(hookConfigs)) {
768
806
  }
769
807
 
770
808
  writeSettings(settings);
771
- ok('Hooks configured (PreToolUse, PostToolUse, SessionStart, Stop, UserPromptSubmit)');
809
+ // Derived from the map, not a parallel literal: the pre-B3 line said five events and
810
+ // kept saying five after the map changed, which is how a missing registration reads as
811
+ // a successful one.
812
+ ok(`Hooks configured (${Object.keys(hookConfigs).join(', ')})`);
772
813
  }
773
814
 
774
815
  function backupLegacyClaudeMemData() {
@@ -13,6 +13,13 @@ import { notLowSignalTitleClause } from '../utils.mjs';
13
13
  * { filename, rows } where rows carry the column superset both surfaces render.
14
14
  * Side effect: bumps access_count / last_accessed_at on every returned row —
15
15
  * recall IS engagement, and the tier/decay system feeds on these counters.
16
+ *
17
+ * `superseded_at IS NULL` is load-bearing twice over (audit B2, 2026-08-14): recall was
18
+ * the ONE retrieval path missing it, so a lesson a later save explicitly retracted
19
+ * (`--supersedes N`) was still served to an agent about to edit that very file — and the
20
+ * access-count bump below runs over exactly these rows, so the tombstone was ALSO pushed
21
+ * back up the decay/tier system on every read. `includeNoise` is about LOW_SIGNAL titles
22
+ * and must not reach this clause: nobody asks for retracted content.
16
23
  */
17
24
  export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
18
25
  const filename = basename(file);
@@ -25,6 +32,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
25
32
  FROM observations o
26
33
  JOIN observation_files of2 ON of2.obs_id = o.id
27
34
  WHERE COALESCE(o.compressed_into, 0) = 0
35
+ AND o.superseded_at IS NULL
28
36
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
29
37
  ${noiseClause}
30
38
  ORDER BY o.created_at_epoch DESC
package/mem-cli.mjs CHANGED
@@ -43,7 +43,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
43
43
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
44
44
  import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
45
45
  import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
46
- import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
46
+ import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
47
47
  import { saveObservation } from './lib/save-observation.mjs';
48
48
  import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
49
49
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
@@ -533,7 +533,7 @@ function renderObsRows(db, ids, requestedFields) {
533
533
  const formatted = formatObsFieldValue(f, val);
534
534
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
535
535
  const display = typeof formatted === 'string' && formatted.length > maxLen ? formatted.slice(0, maxLen) + '…' : formatted;
536
- lines.push(`${f}: ${display}`);
536
+ lines.push(`${obsFieldLabel(f)}: ${display}`);
537
537
  }
538
538
  parts.push(lines.join('\n'));
539
539
  }
@@ -1327,7 +1327,13 @@ function cmdContext(db, args) {
1327
1327
  }
1328
1328
  out(JSON.stringify(result, null, 2));
1329
1329
  } else {
1330
- out(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
1330
+ // outVerbatim: `context` is the one CLI command that must EMIT a real
1331
+ // <claude-mem-context> wrapper — it prints the same block the SessionStart hook
1332
+ // injects, so `out`'s defang would strip the delimiters this command exists to
1333
+ // produce (the CLI twin of why <skill-loaded> is excluded from CONTEXT_DELIMITER_RE).
1334
+ // The untrusted half is already neutralized one layer up: buildSessionContextLines
1335
+ // defangs every row it renders, so only the trusted wrapper is written raw here.
1336
+ outVerbatim(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
1331
1337
  }
1332
1338
  }
1333
1339
 
@@ -1714,15 +1720,20 @@ function cmdExport(db, args) {
1714
1720
  // jsonl → 0 lines (valid empty file)
1715
1721
  // The friendly note goes to stderr so it doesn't poison stdout for callers
1716
1722
  // piping to a parser.
1717
- if (format === 'json') out('[]');
1723
+ if (format === 'json') outVerbatim('[]');
1718
1724
  process.stderr.write('[mem] No observations found matching criteria\n');
1719
1725
  return;
1720
1726
  }
1721
1727
 
1728
+ // outVerbatim, NOT out: `out` neutralizes structural context delimiters (cli/common.mjs)
1729
+ // because CLI stdout is model context — but this stream is a BACKUP that `restore` reads
1730
+ // back, so defanging it would silently rewrite any row whose text legitimately contains
1731
+ // `<system-reminder>`/`</claude-mem-context>` and persist the rewrite on restore. Mirrors
1732
+ // safeHandler(mem_export, { verbatim: true }) on the MCP side (audit 2026-08-14 A1).
1722
1733
  if (format === 'jsonl') {
1723
- for (const r of rows) out(JSON.stringify(r));
1734
+ for (const r of rows) outVerbatim(JSON.stringify(r));
1724
1735
  } else {
1725
- out(JSON.stringify(rows, null, 2));
1736
+ outVerbatim(JSON.stringify(rows, null, 2));
1726
1737
  }
1727
1738
 
1728
1739
  if (limitGiven && rows.length >= limit) {
@@ -1971,7 +1982,7 @@ function cmdMaintain(db, args) {
1971
1982
  out(` Broken (no title/narrative): ${stats.broken}`);
1972
1983
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1973
1984
  out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
1974
- out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
1985
+ out(formatPendingPurgeLine(stats.pendingPurge));
1975
1986
  if (duplicates.length > 0) {
1976
1987
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
1977
1988
  const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
@@ -3119,8 +3130,12 @@ async function cmdOptimize(db, args) {
3119
3130
  if (project) out(` Project filter: ${project}`);
3120
3131
  out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}`);
3121
3132
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
3122
- out(` Cluster-merge: ${preview.clusterMerge} clusters`);
3123
- out(` Smart-compress: ${preview.smartCompress} clusters`);
3133
+ // "candidates" matches the MCP wording (server.mjs mem_optimize preview) AND the
3134
+ // Re-enrich line just above, which already read that way on both surfaces. The two
3135
+ // surfaces render one optimizePreview() result — tests/audit-findings-20260814.test.mjs
3136
+ // drives both and compares the label lists, so the drift cannot reopen.
3137
+ out(` Cluster-merge candidates: ${preview.clusterMerge} clusters`);
3138
+ out(` Smart-compress candidates: ${preview.smartCompress} clusters`);
3124
3139
  out(` Total: ${preview.total} items`);
3125
3140
  if (verbose) {
3126
3141
  out('');
@@ -3194,7 +3209,7 @@ export async function run(argv) {
3194
3209
  for (const { flag, suggestion } of suggestUnknownFlags(parseArgs(cmdArgs).flags)) {
3195
3210
  process.stderr.write(suggestion
3196
3211
  ? `[mem] Unknown flag --${flag}; did you mean --${suggestion}?\n`
3197
- : `[mem] Unknown flag --${flag} — ignored (it filtered nothing). Run "claude-mem-lite help" for this command's flags.\n`);
3212
+ : `[mem] Unknown flag --${flag} — ignored, it had no effect. Run "claude-mem-lite help" for this command's flags.\n`);
3198
3213
  }
3199
3214
 
3200
3215
  // adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.61.0",
3
+ "version": "3.62.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.61.0",
9
+ "version": "3.62.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.61.0",
3
+ "version": "3.62.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",
@@ -114,7 +114,7 @@ const OR_TOP_BM25_FLOOR = TOP_REL_FLOOR === 0
114
114
  ? 0
115
115
  : Number(process.env.CLAUDE_MEM_UPS_OR_BM25_MIN || 30);
116
116
 
117
- // ─── Corpus-size normalization of the absolute floors (v3.60.2) ─────────────
117
+ // ─── Corpus-size normalization of the absolute floors (v3.61.0) ─────────────
118
118
  //
119
119
  // Both floors above are ABSOLUTE magnitudes, but the quantity they gate is not
120
120
  // scale-free: FTS5 bm25 carries an IDF term ≈ ln(N/df), so the SAME hit scores
package/secret-scrub.mjs CHANGED
@@ -30,16 +30,45 @@ export const SECRET_PATTERNS = [
30
30
  // value is covered (the hex-only assignment pattern below misses non-hex values).
31
31
  // 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
32
32
  [/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
33
- // 1b. `:` separator, PASSWORD nouns always scrub. The prose lookbehind below
34
- // was originally applied to the whole noun class, which meant any credential
35
- // noun preceded by an English word escaped — so a session narrative like
36
- // "deployed to staging, the db password: hunter2correct" persisted the
37
- // password in plaintext and re-injected it into every later context block
38
- // (R5 dogfood, 2026-08-13). Unlike `token`/`bearer`/`secret`, the pinned
39
- // prose set (#8283) contains no `password|passwd|passphrase` case: writing
40
- // "<word> password: <6+ chars>" names a credential, it is not conversational
41
- // usage. Letter-glued non-keywords (`mypassword:`) still miss via `(?:\b|_)`.
42
- [/((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
33
+ // 1b. `:` separator, PASSWORD nouns. Position decides how permissive the value
34
+ // class may be, because the two positions have opposite error costs.
35
+ //
36
+ // CONFIG position (start of line, or not preceded by an English word +
37
+ // space) is unambiguous assignment syntax scrub any value, exactly as
38
+ // before. Pinned by the ` password: hunter2` indent cases.
39
+ //
40
+ // PROSE position ("<word> password: ") is where v3.61.0 first removed the
41
+ // lookbehind outright, to stop "deployed to staging, the db password:
42
+ // hunter2correct" from persisting a credential. That closed a leak by
43
+ // trading it for something worse: scrubbing runs on the WRITE path, and the
44
+ // value class matches ordinary English, so "Reset the password: instructions
45
+ // are in the onboarding doc" was stored irreversibly as "password: *** are
46
+ // in the onboarding doc" (caught by independent pre-tag review). The claim
47
+ // that "<word> password: <6+ chars>" always names a credential was simply
48
+ // false. So in prose position the VALUE must look like a credential: not a
49
+ // run of lowercase letters. A digit, any uppercase, or a symbol qualifies —
50
+ // `hunter2correct`, `S3cretValue`, `correct-horse-battery-staple` all scrub,
51
+ // while `instructions` / `rotation` / `yesterday` are left alone.
52
+ //
53
+ // "Credential-shaped" is spelled as: NOT a single run of ≤15 letters. The
54
+ // patterns carry `i`, so the letter class is case-insensitive by
55
+ // construction — deliberately, because prose capitalizes ("Reset the
56
+ // password: Instructions are in the doc" must survive, and a
57
+ // lowercase-only test would corrupt it). The length bound is what still
58
+ // catches a letters-only secret: English words in prose run short, secrets
59
+ // do not, so `aVeryLongOpaqueSecretToken` (26) scrubs while `instructions`
60
+ // (12) does not.
61
+ //
62
+ // Two known, accepted gaps: a short letters-only password in prose position
63
+ // ("the password: hunter") survives, and an English word longer than 15
64
+ // letters is over-scrubbed. Config position still catches the former; the
65
+ // latter is rare in prose and errs toward protecting a secret. A value
66
+ // indistinguishable from an English word cannot be told from one without
67
+ // corrupting prose — which is exactly the error this arm exists to undo.
68
+ // Both arms emit `***` (3 chars, under the {6,} floor), so they cannot
69
+ // double-apply.
70
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
71
+ [/((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)(?![A-Za-z]{1,15}(?=[\s,;'"}\]]|$))[^\s,;'"}\]]{6,}/gi, '$1***'],
43
72
  // 1c. `:` separator, prose-ambiguous nouns → keep the lookbehind ("the token: alice"):
44
73
  [/((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
45
74
  // access_token / refresh_token are the canonical OAuth2 field names — they were
package/server.mjs CHANGED
@@ -27,8 +27,11 @@ import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
27
27
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
28
  import { computeStatsFeed } from './lib/stats-core.mjs';
29
29
  import { buildLessonNudge } from './lib/save-nudge.mjs';
30
- import { formatObsFieldValue } from './cli/common.mjs';
31
- import { neutralizeContextDelimiters } from './format-utils.mjs';
30
+ import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
31
+ // The partial-export warning points the caller at the CLI twin, which exports the complete
32
+ // set by default — the invocation has to be the one that actually works on this install.
33
+ import { CLI_INVOKE } from './cli-path.mjs';
34
+ import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from './format-utils.mjs';
32
35
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
33
36
 
34
37
  // Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
@@ -638,7 +641,7 @@ server.registerTool(
638
641
  // gets a scannable hint instead of a bare millisecond integer.
639
642
  const display = formatObsFieldValue(f, val);
640
643
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
641
- lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
644
+ lines.push(`${obsFieldLabel(f)}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
642
645
  }
643
646
  sections.push(lines.join('\n'));
644
647
  }
@@ -1113,7 +1116,7 @@ server.registerTool(
1113
1116
  ` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
1114
1117
  ` Broken (no title/narrative): ${stats.broken}`,
1115
1118
  ` Boostable (accessed>3, imp<3): ${stats.boostable}`,
1116
- ` Pending purge (idle-marked): ${stats.pendingPurge}`,
1119
+ formatPendingPurgeLine(stats.pendingPurge),
1117
1120
  ];
1118
1121
  if (duplicates.length > 0) {
1119
1122
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
@@ -1567,6 +1570,11 @@ server.registerTool(
1567
1570
 
1568
1571
  // ─── Tool: mem_use ──────────────────────────────────────────────────────────
1569
1572
 
1573
+ // Cap on the caller-supplied name echoed back in a miss message. Well past any real
1574
+ // skill/agent name (the longest registered one here is 23 chars), short enough that an
1575
+ // unbounded argument cannot pad the response — the echo appears twice.
1576
+ const ECHO_NAME_MAX = 80;
1577
+
1570
1578
  server.registerTool(
1571
1579
  'mem_use',
1572
1580
  {
@@ -1582,8 +1590,8 @@ server.registerTool(
1582
1590
  const name = args.name.trim();
1583
1591
  const type = args.type || 'skill';
1584
1592
 
1585
- // 1. Exact match by name or invocation_name
1586
- let row = rdb.prepare(`
1593
+ // 1. Exact match by name or invocation_name — the ONLY path that loads content.
1594
+ const row = rdb.prepare(`
1587
1595
  SELECT id, name, type, local_path, invocation_name, capability_summary
1588
1596
  FROM resources
1589
1597
  WHERE status = 'active' AND type = ?
@@ -1591,16 +1599,36 @@ server.registerTool(
1591
1599
  LIMIT 1
1592
1600
  `).get(type, name, name);
1593
1601
 
1594
- // 2. Fuzzy fallback: FTS5 search, take top result
1602
+ // 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
1603
+ // produces the candidate list), but its result is only ever rendered as names: loading
1604
+ // the top hit under the caller's requested name shipped a different skill's body inside
1605
+ // <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
1606
+ // nothing marking the swap, so an agent that asked for A executed B (audit F1,
1607
+ // 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
1608
+ // `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
1609
+ // exact-name decision the caller makes.
1595
1610
  if (!row) {
1596
- const results = searchResources(rdb, name, { type, limit: 1 });
1597
- if (results.length > 0) {
1598
- row = rdb.prepare(`SELECT id, name, type, local_path, invocation_name, capability_summary FROM resources WHERE name = ? AND type = ? AND status = 'active'`).get(results[0].name, results[0].type);
1611
+ let candidates = [];
1612
+ try { candidates = searchResources(rdb, name, { type, limit: 5 }).map((r) => r.name).filter(Boolean); }
1613
+ catch { /* a suggestion is best-effort; the miss message below still stands */ }
1614
+ // Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
1615
+ // raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
1616
+ // imperative inside this message, and the handler-wide defangResult cannot catch it —
1617
+ // <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
1618
+ // it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
1619
+ // Registered names are defanged too (a crafted one can be imported), but NOT truncated:
1620
+ // the suggestion tells the caller to load one by its exact name, so it must stay exact.
1621
+ const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
1622
+ const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
1623
+ const head = `No ${type} found for "${echoed}".`;
1624
+ const browse = `mem_registry(action="search", query="${echoed}")`;
1625
+ if (candidates.length === 0) {
1626
+ return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
1599
1627
  }
1600
- }
1601
-
1602
- if (!row) {
1603
- return { content: [{ type: 'text', text: `No ${type} found for "${name}". Try mem_registry(action="search", query="${name}") to browse.` }] };
1628
+ const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
1629
+ return { content: [{ type: 'text', text:
1630
+ `${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
1631
+ `Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.` }] };
1604
1632
  }
1605
1633
 
1606
1634
  // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
@@ -1717,7 +1745,13 @@ async function runExport(db, args) {
1717
1745
  }
1718
1746
 
1719
1747
  const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
1720
- const exportLimit = Math.min(args.limit ?? 200, 1000);
1748
+ // No clamp (audit 2026-08-14 A2): `Math.min(args.limit ?? 200, 1000)` made an MCP-driven
1749
+ // backup of a >1000-row store impossible, on the tool whose own description says "USE
1750
+ // when: Backing up memory before a migration or reinstall" — while the CLI twin exported
1751
+ // the complete matching set (mem-cli.mjs cmdExport, fixed there for the same reason). The
1752
+ // DEFAULT stays 200: an MCP result is model context, so a bare exploratory call must not
1753
+ // dump a whole store into the transcript. An explicit limit is now honoured at any size.
1754
+ const exportLimit = args.limit ?? 200;
1721
1755
  // T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
1722
1756
  // "user got exactly what existed". Trim to exportLimit before rendering.
1723
1757
  // EXPORT_COLUMNS_SQL: shared with CLI cmdExport — the full round-trippable set restore
@@ -1733,7 +1767,27 @@ async function runExport(db, args) {
1733
1767
  ? rows.map(r => JSON.stringify(r)).join('\n')
1734
1768
  : JSON.stringify(rows, null, 2);
1735
1769
 
1736
- const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
1770
+ // A truncated backup is the failure mode this tool must never produce quietly: the old
1771
+ // note ("Results capped at N … increase limit (max 1000)") never said how much was
1772
+ // missing, and its advice was a dead end on a store past the ceiling. Name the real
1773
+ // total, the number of rows left out, and a remedy that actually returns all of them.
1774
+ //
1775
+ // The remedy has to lead with a FILE REDIRECT (pre-tag review, 2026-08-14). Removing the
1776
+ // 1000-row ceiling took away the only bound on an MCP export's size, and the 200 default
1777
+ // is kept precisely because an MCP result IS model context — so "re-run with
1778
+ // limit: <total>", the previous first suggestion, told the caller to put the entire store
1779
+ // into one tool result (thin fixture rows measure ~612 bytes each, so a few-thousand-row
1780
+ // store is megabytes in a single message), and a bare `cli.mjs export` had the same
1781
+ // property unredirected. The limit re-run stays mentioned but demoted, with its cost
1782
+ // stated: it is occasionally what the caller actually wants, and silently dropping it
1783
+ // would send them back to guessing.
1784
+ let cap = '';
1785
+ if (moreAvailable) {
1786
+ const total = db.prepare(`SELECT COUNT(*) AS c FROM observations ${where}`).get(...params).c;
1787
+ cap = `\nWARNING — PARTIAL EXPORT, NOT A COMPLETE BACKUP: capped at ${exportLimit} of ${total} matching observations; ${total - exportLimit} rows are missing from this payload and restoring it would lose them.` +
1788
+ `\nFor a complete backup, write it to a FILE instead of pulling it through this conversation: \`${CLI_INVOKE} export --format jsonl > backup.jsonl\` (the CLI exports the complete set by default). Narrowing with date_from/date_to also works.` +
1789
+ `\nRaising \`limit\` here is the last resort, not the first: this result is model context, so all ${total} rows would be loaded into the transcript.`;
1790
+ }
1737
1791
  return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
1738
1792
  }
1739
1793
 
@@ -1745,7 +1799,10 @@ server.registerTool(
1745
1799
  },
1746
1800
  // verbatim: the export payload feeds `restore` — defanging it would silently
1747
1801
  // rewrite backed-up rows whose text legitimately contains these tags.
1748
- safeHandler(async (args) => runExport(db, args), { verbatim: true })
1802
+ safeHandler(
1803
+ async (args) => runExport(db, applyArgAliases(args, { from: 'date_from', to: 'date_to' })),
1804
+ { verbatim: true },
1805
+ )
1749
1806
  );
1750
1807
 
1751
1808
  // ─── Tool: mem_recall ────────────────────────────────────────────────────────
package/tool-schemas.mjs CHANGED
@@ -100,7 +100,7 @@ export const memSearchSchema = {
100
100
  or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
101
101
  deep: coerceBool.optional().describe('Tri-state LLM multi-query/HyDE deep search (observations-only). true=force; false=never; omit=AUTO (default ON for mem_search): a normal search that returns weak/few results auto-escalates with ONE Haiku call (query rewritten to keyword/concept/HyDE variants, RRF-fused). Set CLAUDE_MEM_AUTO_DEEP=0 to disable AUTO. Passive recall stays single-query.'),
102
102
  rerank: coerceBool.optional().describe('Opt-in: LLM-rerank the deep-search candidates for ranking precision (one extra Haiku call, ~1.4s). Requires deep=true (no effect on AUTO/normal). Reserve for hard, ranking-sensitive queries where the right memory is likely retrieved but mis-ranked — skip for routine search. Default off.'),
103
- // ── CLI-flag aliases (v3.60.2) ──────────────────────────────────────────────
103
+ // ── CLI-flag aliases (v3.61.0) ──────────────────────────────────────────────
104
104
  // A property the schema doesn't declare is STRIPPED by the validator, so a caller
105
105
  // using the CLI vocabulary (`--source` / `--from` / `--to` / `--since`) previously
106
106
  // got the UNFILTERED answer with nothing marking the filter as dropped — a wider
@@ -206,7 +206,7 @@ export const memSaveSchema = {
206
206
  type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
207
207
  project: z.string().optional().describe('Project name (default: inferred from CWD)'),
208
208
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
209
- files: coerceStringArray.optional().describe('File paths associated with this observation'),
209
+ files: coerceStringArray.optional().describe('File paths associated with this observation. Stored in the `files_modified` column and rendered as `files` — passing a path here does not assert the file was edited; a file you only read belongs here too'),
210
210
  lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway, ≤500 chars (for bugfix: root cause & fix; for decision: rationale)'),
211
211
  closes_deferred: coerceDeferredTokens.optional().describe('Close one or more deferred_work items in the same project. Mixed array: bare integer = ordinal-within-project, "D#<n>" string = raw id. Transactional with the obs insert — a single invalid id rolls back the whole save.'),
212
212
  supersedes: coerceSupersedes.optional().describe('Observation ids (same project) that this save overturns. They are marked superseded — dropped from live search — and linked to the new row (superseded_by). Use ONLY when this genuinely replaces a prior conclusion; do NOT use for merely-related or updated-but-still-valid memories.'),
@@ -274,8 +274,19 @@ export const memExportSchema = {
274
274
  format: z.enum(['json', 'jsonl']).optional().describe('Output format (default: json)'),
275
275
  date_from: z.string().optional().describe('Start date (ISO 8601 or YYYY-MM-DD)'),
276
276
  date_to: z.string().optional().describe('End date (ISO 8601 or YYYY-MM-DD)'),
277
+ // CLI-flag aliases — `export` reads flags.from / flags.to (mem-cli.mjs). Same
278
+ // silent-drop defect the search/recent aliases fix, and the widest blast radius of
279
+ // the three: an ignored bound exports the whole DB instead of a date slice.
280
+ from: z.string().optional().describe('Alias for `date_from` (CLI `export --from`)'),
281
+ to: z.string().optional().describe('Alias for `date_to` (CLI `export --to`)'),
277
282
  include_compressed: coerceBool.optional().describe('Include compressed observations (default: false)'),
278
- limit: coerceInt.pipe(z.number().int().min(1).max(1000)).optional().describe('Max observations to export (default: 200, max: 1000)'),
283
+ // No upper bound: this is the BACKUP tool ("USE when: Backing up memory before a
284
+ // migration or reinstall"), and a 1000-row ceiling made a complete backup of a bigger
285
+ // store impossible over MCP while the CLI twin exported everything (audit 2026-08-14 A2).
286
+ // The default stays 200 because an MCP result is model context — a bare exploratory call
287
+ // must not dump a whole store into the transcript — but a capped result now announces
288
+ // itself as PARTIAL and names the limit that would return all of it.
289
+ limit: coerceInt.pipe(z.number().int().min(1)).optional().describe('Max observations to export (default: 200 — a capped result is flagged PARTIAL and names the total; pass that total for a complete backup, no upper bound)'),
279
290
  };
280
291
 
281
292
  export const memRecallSchema = {
@@ -584,7 +595,8 @@ export const tools = [
584
595
  {
585
596
  name: 'mem_use',
586
597
  description:
587
- 'Load and activate a skill or agent from the registry by name (exact or fuzzy).\n' +
598
+ 'Load and activate a skill or agent from the registry by EXACT name or invocation_name.\n' +
599
+ 'A name that matches nothing returns closest-match names to pick from — never another resource\'s content.\n' +
588
600
  '\n' +
589
601
  'DO NOT use when:\n' +
590
602
  ' - You have not confirmed the skill exists (run mem_registry action="list" first)\n' +