claude-mem-lite 2.84.2 → 2.85.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,9 +10,9 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.84.2",
13
+ "version": "2.85.0",
14
14
  "source": "./",
15
- "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
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. Alternative to claude-mem with 600x lower cost."
16
16
  }
17
17
  ]
18
18
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.84.2",
4
- "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
3
+ "version": "2.85.0",
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. Alternative to claude-mem with 600x lower cost.",
5
5
  "author": {
6
6
  "name": "sdsrss"
7
7
  },
package/README.md CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  # claude-mem-lite
4
4
 
5
- Lightweight persistent memory system for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Automatically captures coding observations, decisions, and bug fixes during sessions, then provides full-text search to recall them later.
5
+ `claude-mem-lite` is a **persistent memory** (also called *long-term memory* or *cross-session context*) system for **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic's CLI coding agent. It runs as an **[MCP](https://modelcontextprotocol.io/) server** plus a set of Claude Code hooks, automatically capturing coding observations, decisions, and bug fixes during sessions, then providing hybrid full-text + semantic search to recall them later.
6
6
 
7
- Built as an [MCP server](https://modelcontextprotocol.io/) + Claude Code hooks. Zero external services, single SQLite database, minimal overhead.
7
+ Compared to general-purpose LLM memory frameworks like [`mem0`](https://github.com/mem0ai/mem0) or the MCP reference [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) server, claude-mem-lite is purpose-built for Claude Code's hook lifecycle: episode batching cuts LLM calls 7–10× vs the original [claude-mem](https://github.com/thedotmack/claude-mem) (600× lower total cost), and the hybrid FTS5 + TF-IDF retriever benchmarks at 0.88 Recall@10 / 0.96 Precision@10.
8
+
9
+ > 中文简介:claude-mem-lite 是 Claude Code 的轻量级**持久化记忆 / 长期记忆 / 跨会话上下文**插件,基于 MCP 协议 + 钩子机制,自动捕获编码会话中的决策、修复和上下文,并通过 FTS5 + TF-IDF 混合检索召回。详见 [中文 README](README.zh-CN.md)。
10
+
11
+ Zero external services. Single SQLite database. Minimal overhead.
8
12
 
9
13
  ## Why claude-mem-lite?
10
14
 
@@ -50,6 +54,22 @@ For a typical 50-tool-call session:
50
54
 
51
55
  The original sends **everything to the LLM and hopes it filters well**. claude-mem-lite **filters first with code, then sends only what matters** to a smaller model. This is not a downgrade; it's a smarter architecture that produces equivalent search quality at a fraction of the cost.
52
56
 
57
+ ### Comparison: memory systems for AI coding agents
58
+
59
+ How claude-mem-lite differs from the major neighbors in the LLM-memory space (verified May 2026):
60
+
61
+ | | **claude-mem-lite** | [`mem0`](https://github.com/mem0ai/mem0) | MCP reference [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | [claude-mem](https://github.com/thedotmack/claude-mem) (original) |
62
+ |---|---|---|---|---|
63
+ | **Target client** | Claude Code only | Any LLM app via SDK | Any MCP client | Claude Code only |
64
+ | **Capture model** | Auto via hooks | Manual `memory.add()` | Manual tool calls (`create_entities`, `add_observations`) | Auto via hooks |
65
+ | **Code-aware retrieval** | FTS5 + 100+ synonym pairs (incl. CJK↔EN) | General-purpose | Generic graph nodes | Code-aware |
66
+ | **Search** | Hybrid: FTS5 BM25 + TF-IDF cosine via RRF | Hybrid: semantic + BM25 + entity linking | Knowledge-graph traversal | FTS5 + Chroma vector |
67
+ | **Storage** | Single local SQLite | Pluggable; Qdrant or configurable vector store | Single JSONL file (knowledge graph) | SQLite + Chroma |
68
+ | **LLM dependency** | Haiku per episode (5–10 ops batched) | LLM per add/search op | None (graph CRUD only) | Sonnet per tool call |
69
+ | **Setup** | One command (`/plugin install` or `npx`) | SDK integration + vector store config | MCP install (per-client) | Bun + Python + Chroma |
70
+
71
+ **When to pick which**: pick `mem0` if you need a memory layer for a non-Claude-Code app (your own agent, multiple LLM providers). Pick the MCP reference `memory` server if you specifically want a knowledge-graph data model and don't mind invoking memory tools by hand. Pick claude-mem-lite if you want zero-touch automatic capture purpose-built for Claude Code's hook lifecycle, with code-domain retrieval and no external services.
72
+
53
73
  ## Features
54
74
 
55
75
  - **Automatic capture** -- Hooks into Claude Code lifecycle (PostToolUse, SessionStart, Stop, UserPromptSubmit) to record observations without manual effort
@@ -642,6 +662,40 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
642
662
  | `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the first-SessionStart auto-write of the invited-memory sentinel across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
643
663
  | `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. Since v2.82.1 auto-adopt fires on first SessionStart for any install path, so this hint typically surfaces only when you've explicitly opted out (`MEM_NO_AUTO_ADOPT=1` or `claude-mem-lite adopt --disable`). | _(disabled)_ |
644
664
 
665
+ ## FAQ
666
+
667
+ ### What is a memory system for Claude Code?
668
+
669
+ A memory system lets Claude Code remember context — coding decisions, bug fixes, file history — across sessions. By default Claude Code's context resets each session; claude-mem-lite persists observations to a local SQLite database and re-injects them at session start and on relevant prompts.
670
+
671
+ ### Does Claude Code have built-in long-term memory?
672
+
673
+ No. Claude Code's `CLAUDE.md` and `MEMORY.md` files act as static instruction memory, but there is no native dynamic recall of past sessions, bug fixes, or decisions. claude-mem-lite adds that layer via MCP and hooks, with no manual note-taking required.
674
+
675
+ ### How is claude-mem-lite different from mem0 or MCP's reference memory server?
676
+
677
+ `mem0` and the MCP `memory` server are general-purpose LLM memory frameworks designed for any client. claude-mem-lite is purpose-built for Claude Code's hook lifecycle: it captures *episodes* (batched tool calls), uses domain-specific synonym expansion for code terms (`K8s`, `DB`, `数据库`, ...), and surfaces past observations proactively before file edits via the `PreToolUse:Edit` hook.
678
+
679
+ ### Why "lite"? What did the original claude-mem do differently?
680
+
681
+ The original called an LLM on every tool use with raw JSON inputs. claude-mem-lite batches 5–10 operations per LLM call, uses a smaller model (Haiku), and runs a deterministic code-level filter before sending anything to the model. Net result: ~600× lower cost with equivalent search quality. See the [Architecture comparison](#architecture-comparison) above.
682
+
683
+ ### Does this work cross-project? Cross-machine?
684
+
685
+ Project-scoped by default — each project has its own memory namespace. Single-machine only (SQLite, not networked). Use `mem_export` (JSON / JSONL) to back up or migrate between machines.
686
+
687
+ ### What about privacy? Does it call external APIs?
688
+
689
+ Only the Haiku summarization step calls Anthropic's API (or the local `claude -p` CLI if no API key is set). All search, storage, and retrieval is local SQLite — no telemetry, no third-party services.
690
+
691
+ ### 中文常见问题
692
+
693
+ **Claude Code 怎么跨会话记住内容?** 默认不能。claude-mem-lite 通过 MCP 协议和钩子自动把决策、bug 修复、文件历史持久化到本地 SQLite,下次会话开始时再注入。
694
+
695
+ **和 mem0、官方 MCP memory server 有什么区别?** 那两个是通用 LLM 记忆框架;claude-mem-lite 是为 Claude Code 钩子生命周期定制的:批量 episode 处理、代码领域同义词扩展(K8s/DB/数据库等)、文件编辑前主动召回相关历史。
696
+
697
+ **支持中文吗?** 完整支持。FTS5 + 中英文同义词扩展(100+ 对,含 CJK ↔ EN 跨语言映射),中文记忆也可用英文关键词召回,反之亦然。
698
+
645
699
  ## License
646
700
 
647
701
  MIT
package/README.zh-CN.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  # claude-mem-lite
4
4
 
5
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 的轻量级持久化记忆系统。自动捕获编码过程中的观察、决策和问题修复,通过全文搜索随时回溯。
5
+ `claude-mem-lite` 是 **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**(Anthropic 官方 CLI 编程代理)的 **持久化记忆系统**(也称 **长期记忆 / 跨会话上下文 / Claude Code 记忆插件**)。它以 **[MCP](https://modelcontextprotocol.io/) 服务器** + Claude Code 钩子(hooks)的形式运行,在编码会话中自动捕获观察记录、决策、bug 修复,并通过 FTS5 全文检索 + TF-IDF 向量的混合检索召回历史上下文。
6
6
 
7
- 基于 [MCP 服务器](https://modelcontextprotocol.io/) + Claude Code 钩子构建。无需外部服务,单一 SQLite 数据库,开销极低。
7
+ [`mem0`](https://github.com/mem0ai/mem0)、MCP 官方参考实现的 [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) 服务器等通用 LLM 记忆框架相比,claude-mem-lite 专为 Claude Code 的钩子生命周期定制:episode 批处理把 LLM 调用量相比原版 [claude-mem](https://github.com/thedotmack/claude-mem) 减少 7-10 倍(综合成本下降 600 倍),FTS5 + TF-IDF 混合检索在 30 个查询的基准上达到 **Recall@10 = 0.88 / Precision@10 = 0.96**。
8
+
9
+ 无需外部服务。单一 SQLite 数据库。开销极低。
8
10
 
9
11
  ## 为什么选择 claude-mem-lite?
10
12
 
package/hook-memory.mjs CHANGED
@@ -34,14 +34,18 @@ function getCoverageThreshold() {
34
34
  }
35
35
 
36
36
  // v2.41: cross-project boost (applied to decisions/discoveries from other
37
- // projects). Default 0.7 = 30% penalty vs same-project hits tuned for multi-
38
- // project installs where transferable insights are the minority of matches.
37
+ // projects). Default 0.4 = 60% penalty vs same-project hits. Was 0.7 (30%), but
38
+ // a cross-project audit found that a 30% discount let strongly-matching but
39
+ // off-topic decisions still win injection slots in unrelated projects (e.g. an
40
+ // FTS5 SQL gotcha surfacing in a UI session). Transferable insights are the
41
+ // minority of cross-project matches, so the penalty should be steep; raise it
42
+ // back via env for installs that want more sharing.
39
43
  // Env override `MEM_CROSS_PROJECT_BOOST` ∈ [0, 1]; clamped, invalid → default.
40
44
  function getCrossProjectBoost() {
41
45
  const raw = process.env.MEM_CROSS_PROJECT_BOOST;
42
- if (raw === undefined || raw === '') return 0.7;
46
+ if (raw === undefined || raw === '') return 0.4;
43
47
  const n = parseFloat(raw);
44
- return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.7;
48
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
45
49
  }
46
50
  function extractQueryTerms(text) {
47
51
  if (!text) return [];
package/hook-update.mjs CHANGED
@@ -101,6 +101,38 @@ export async function checkForUpdate(options = {}) {
101
101
  }
102
102
  }
103
103
 
104
+ // ── Non-blocking SessionStart helpers (audit P3d) ──────────────────────────
105
+ // Previously handleSessionStart `await checkForUpdate()` inline, blocking the
106
+ // session up to ~3-6s on a GitHub fetch once per 24h. These two helpers split
107
+ // that: emit the banner from CACHED state (zero network) and let the network
108
+ // refresh run in a detached background worker, so SessionStart never blocks.
109
+
110
+ // Banner string from cached update-state (≤24h stale), or null. No network I/O.
111
+ export function getCachedUpdateBanner() {
112
+ try {
113
+ if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
114
+ const state = readState();
115
+ if (state.updateAvailable && state.latestVersion) {
116
+ // Cached "available" state only persists for deferred installs (plugin mode
117
+ // / allowInstall=false); a successful auto-install clears updateAvailable.
118
+ const hint = isPluginMode()
119
+ ? ' — plugin mode only checks for updates; reinstall/update the plugin to apply it'
120
+ : '';
121
+ return `\n📦 claude-mem-lite: v${state.latestVersion} available (current: v${state.installedVersion})${hint}\n`;
122
+ }
123
+ return null;
124
+ } catch { return null; }
125
+ }
126
+
127
+ // True when a network refresh is due (24h throttle) and updates aren't disabled.
128
+ // Caller spawns the refresh in the background so this session doesn't wait.
129
+ export function isUpdateCheckDue() {
130
+ try {
131
+ if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return false;
132
+ return shouldCheck(readState());
133
+ } catch { return false; }
134
+ }
135
+
104
136
  function isPluginMode() {
105
137
  return Boolean(process.env.CLAUDE_PLUGIN_ROOT);
106
138
  }
package/hook.mjs CHANGED
@@ -57,11 +57,11 @@ import { extractTailAssistantText, extractStructuredSummary } from './lib/summar
57
57
  import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
58
58
  import { detectMemOverride } from './lib/mem-override.mjs';
59
59
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
60
- import { checkForUpdate } from './hook-update.mjs';
60
+ import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
61
61
  import { handleLLMOptimize } from './hook-optimize.mjs';
62
62
  import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
63
63
  import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
64
- import { loadCiteBackForEpisode, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge } from './lib/cite-back-hint.mjs';
64
+ import { loadCiteBackForEpisode, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
65
65
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
66
66
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
67
67
  // crash on static import. Degrade gracefully to no-op when the module is absent.
@@ -570,8 +570,13 @@ async function handleStop() {
570
570
  // alongside cite-recall. Same scan target (transcript already in OS
571
571
  // cache); same persistence file; one extra line in buildCiteRecallNudge.
572
572
  const bugfixStats = countUnsavedBugfixShape(transcriptPath);
573
- const payload = { ...stats, ...bugfixStats, project, savedAt: Date.now() };
574
573
  const dest = join(RUNTIME_DIR, `cite-recall-${project.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64)}.json`);
574
+ // Carry the consecutive-low-cite streak forward so the SessionStart
575
+ // nag can self-silence after the project has ignored it N times.
576
+ let priorStreak = 0;
577
+ try { priorStreak = JSON.parse(readFileSync(dest, 'utf8')).lowStreak || 0; } catch {}
578
+ const lowStreak = nextCiteLowStreak(priorStreak, stats);
579
+ const payload = { ...stats, ...bugfixStats, lowStreak, project, savedAt: Date.now() };
575
580
  writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
576
581
  } catch (e) { debugCatch(e, 'handleStop-cite-recall-persist'); }
577
582
  }
@@ -1181,18 +1186,14 @@ async function handleSessionStart() {
1181
1186
  // Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
1182
1187
  try { getVocabulary(db); } catch (e) { debugCatch(e, 'session-start-vocab'); }
1183
1188
 
1184
- // Auto-update check (24h throttle, 3s timeout, silent on failure)
1185
- // Awaited so process.exit(0) doesn't kill the promise before notification
1189
+ // Auto-update check (audit P3d): NON-BLOCKING. Emit the banner from cached
1190
+ // state (zero network) and, if the 24h check is due, refresh in a detached
1191
+ // background worker so SessionStart never blocks on a GitHub fetch (was an
1192
+ // inline `await checkForUpdate()` that could stall the session 3-6s).
1186
1193
  try {
1187
- const updateResult = await checkForUpdate();
1188
- if (updateResult?.updated) {
1189
- process.stdout.write(`\n🔄 claude-mem-lite: v${updateResult.from} → v${updateResult.to} updated\n`);
1190
- } else if (updateResult?.updateAvailable) {
1191
- const hint = updateResult.installDeferred
1192
- ? ' — plugin mode only checks for updates; reinstall/update the plugin to apply it'
1193
- : '';
1194
- process.stdout.write(`\n📦 claude-mem-lite: v${updateResult.to} available (current: v${updateResult.from})${hint}\n`);
1195
- }
1194
+ const banner = getCachedUpdateBanner();
1195
+ if (banner) process.stdout.write(banner);
1196
+ if (isUpdateCheckDue()) spawnBackground('update-check');
1196
1197
  } catch (e) { debugCatch(e, 'session-start-update'); }
1197
1198
 
1198
1199
  } finally {
@@ -1496,6 +1497,10 @@ try {
1496
1497
  case 'llm-summary': await handleLLMSummary(); break;
1497
1498
  case 'auto-compress': handleAutoCompress(); break;
1498
1499
  case 'llm-optimize': await handleLLMOptimize(); break;
1500
+ // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1501
+ // GitHub fetch + (non-plugin) install off the SessionStart critical path,
1502
+ // writing update-state.json so the NEXT session's cached banner is fresh.
1503
+ case 'update-check': await checkForUpdate(); break;
1499
1504
  }
1500
1505
  } catch (err) {
1501
1506
  // Always log fatal errors (ungated) with structured format
package/install.mjs CHANGED
@@ -30,6 +30,7 @@ import { RESOURCE_METADATA } from './install-metadata.mjs';
30
30
  import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
31
31
  import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
32
32
  import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
33
+ import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
33
34
 
34
35
  // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
35
36
  // and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
@@ -1753,6 +1754,16 @@ function cleanup() {
1753
1754
  }
1754
1755
  }
1755
1756
 
1757
+ // Reap leaked test-fixture sandboxes from temp (mem-e2e-* / mem-audit-* / cite-*
1758
+ // etc.) left by interrupted vitest runs — the §8.V4 disposal gap the audit found
1759
+ // (~795MB). 24h age here (vs 1h in the test reaper) is conservative for a manual
1760
+ // cleanup. Scans os.tmpdir() and the Claude Code temp root, depth-1, mem-prefixes
1761
+ // only — never touches other tools' temp dirs.
1762
+ const fixtureRoots = [tmpdir(), join(homedir(), '.claude', 'tmp')];
1763
+ const swept = sweepStaleTestFixtures({ dirs: fixtureRoots, ageMs: 24 * 60 * 60 * 1000, dryRun });
1764
+ for (const p of swept.names) ok(`${dryRun ? 'Would remove' : 'Removed'}: ${p}`);
1765
+ removed += swept.removed;
1766
+
1756
1767
  const verb = dryRun ? 'would be removed' : 'removed';
1757
1768
  console.log(`\n ${removed === 0 ? 'No stale files found.' : `${removed} stale file(s) ${verb}.`}\n`);
1758
1769
  }
@@ -151,26 +151,42 @@ export function bumpCitationAccess(db, ids, project) {
151
151
  return n;
152
152
  }
153
153
 
154
- // Matches a pre-tool-recall lesson line: ` #NN [type] body...`. Bounded type
155
- // list mirrors observations.type CHECK + the events table's allowed event_type
156
- // values pre-tool-recall.js can surface.
154
+ // Matches a pre-tool-recall / error-recall lesson line: ` #NN [type] body...`.
155
+ // Bounded type list mirrors observations.type CHECK + the events table's allowed
156
+ // event_type values these surfaces can emit.
157
157
  const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
158
158
 
159
+ // Add a numeric obs id to `set` if it parses to a sane in-range positive int.
160
+ function addObsId(set, raw) {
161
+ const id = Number(raw);
162
+ if (Number.isInteger(id) && id > 0 && id < 1e7) set.add(id);
163
+ }
164
+
165
+ // Claude Code records a registered hook command (e.g. `node "${CLAUDE_PLUGIN_ROOT}/hook.mjs" user-prompt`)
166
+ // VERBATIM with the path quote-wrapped: `node "/abs/hook.mjs" user-prompt`. A
167
+ // naive `.includes('hook.mjs user-prompt')` then fails because the `"` sits
168
+ // between the path and the subcommand — this was the bug that made the entire
169
+ // UserPromptSubmit injection surface invisible to citation-decay in every real
170
+ // install (tests only ever used unquoted commands, so it was never caught).
171
+ // Strip shell quotes before substring-matching so command detection is robust to
172
+ // plugin-cache vs symlinked-install AND quoted vs unquoted path forms.
173
+ function normalizeHookCommand(command) {
174
+ return (command || '').replace(/["']/g, '');
175
+ }
176
+
159
177
  /**
160
- * Extract observation IDs injected by pre-tool-recall hook in this transcript.
161
- *
162
- * Tighter than `computeCiteRecall`'s over-inclusive "any #NN in non-assistant
163
- * text" only counts IDs the agent actually saw from us, not user-pasted
164
- * references or unrelated #NN tokens in tool output.
178
+ * Walk every `hook_success` attachment in a transcript, invoking `fn` with the
179
+ * quote-normalized command and the injected text (JSON additionalContext
180
+ * unwrapped when present, else raw stdout). Shared by all injection extractors
181
+ * so command-matching + JSON-unwrap logic lives in exactly one place.
165
182
  *
166
183
  * @param {string|null|undefined} transcriptPath
167
- * @returns {Set<number>} unique injected IDs (empty set on missing path/file)
184
+ * @param {(ctx: {command: string, text: string}) => void} fn
168
185
  */
169
- export function extractInjectedFromPreToolUse(transcriptPath) {
170
- const ids = new Set();
171
- if (!transcriptPath || !existsSync(transcriptPath)) return ids;
186
+ function eachHookAttachment(transcriptPath, fn) {
187
+ if (!transcriptPath || !existsSync(transcriptPath)) return;
172
188
  let raw;
173
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
189
+ try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return; }
174
190
  for (const line of raw.split('\n')) {
175
191
  if (!line.trim()) continue;
176
192
  let entry;
@@ -178,83 +194,140 @@ export function extractInjectedFromPreToolUse(transcriptPath) {
178
194
  if (entry.type !== 'attachment') continue;
179
195
  const att = entry.attachment;
180
196
  if (!att || att.type !== 'hook_success') continue;
181
- if (!(att.command || '').includes('pre-tool-recall')) continue;
182
197
  const stdout = att.stdout || '';
183
198
  if (!stdout) continue;
184
- // stdout is JSON wrapping additionalContext OR raw text (legacy);
185
- // try JSON first and fall back to raw.
199
+ // stdout is JSON wrapping additionalContext OR raw text (triggerErrorRecall
200
+ // and the <memory-context> block write raw). Try JSON first, fall back to raw.
186
201
  let text = stdout;
187
202
  try {
188
203
  const parsed = JSON.parse(stdout);
189
204
  text = parsed?.hookSpecificOutput?.additionalContext || stdout;
190
205
  } catch {}
206
+ fn({ command: normalizeHookCommand(att.command), text });
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Extract observation IDs injected by pre-tool-recall hook in this transcript.
212
+ *
213
+ * Tighter than `computeCiteRecall`'s over-inclusive "any #NN in non-assistant
214
+ * text" — only counts IDs the agent actually saw from us, not user-pasted
215
+ * references or unrelated #NN tokens in tool output.
216
+ *
217
+ * @param {string|null|undefined} transcriptPath
218
+ * @returns {Set<number>} unique injected IDs (empty set on missing path/file)
219
+ */
220
+ export function extractInjectedFromPreToolUse(transcriptPath) {
221
+ const ids = new Set();
222
+ eachHookAttachment(transcriptPath, ({ command, text }) => {
223
+ if (!command.includes('pre-tool-recall')) return;
191
224
  INJECTED_RE.lastIndex = 0;
192
225
  let m;
193
- while ((m = INJECTED_RE.exec(text))) {
194
- const id = Number(m[1]);
195
- if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
196
- }
197
- }
226
+ while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
227
+ });
198
228
  return ids;
199
229
  }
200
230
 
201
231
  // v34.x: UserPromptSubmit injection extractor. hook.mjs handleUserPrompt emits
202
232
  // formatMemoryLine `- [type] title | Lesson: X (#NN)[ [verify-before-use]]`,
203
- // which INJECTED_RE (anchored on `#NN [type]`) never matched — leaving the
204
- // highest-volume injection surface invisible to applyCitationDecay. The two
205
- // extractors are disjoint by design: PTR has `[type]` AFTER `#NN`, UPS has
206
- // `(#NN)` at end-of-line.
233
+ // which INJECTED_RE (anchored on `#NN [type]`) never matched — leaving this
234
+ // injection surface invisible to applyCitationDecay. The extractors are disjoint
235
+ // by design: PTR has `[type]` AFTER `#NN`, UPS has `(#NN)` at end-of-line.
207
236
  //
208
237
  // Line-scan with `- [` prefix gate so a lesson body containing a back-reference
209
238
  // like "see (#999)" doesn't pollute the injected set (would streak-uncite an
210
239
  // obs we never actually displayed as a top-level entry).
211
240
  const UPS_LINE_PREFIX = '- [';
212
241
  const UPS_ID_RE = /\(#(\d{1,7})\)/g;
242
+ // Quote-normalized (see normalizeHookCommand): real recorded command is
243
+ // `node "/abs/hook.mjs" user-prompt` → normalized to `node /abs/hook.mjs user-prompt`.
213
244
  const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
214
245
 
215
246
  /**
216
247
  * Extract observation IDs injected by the UserPromptSubmit `<memory-context>`
217
248
  * block (hook.mjs handleUserPrompt). Disjoint from pre-tool-recall extraction —
218
- * the Stop handler unions both via extractAllInjected.
249
+ * the Stop handler unions all surfaces via extractAllInjected.
219
250
  *
220
251
  * @param {string|null|undefined} transcriptPath
221
252
  * @returns {Set<number>}
222
253
  */
223
254
  export function extractInjectedFromUserPromptSubmit(transcriptPath) {
224
255
  const ids = new Set();
225
- if (!transcriptPath || !existsSync(transcriptPath)) return ids;
226
- let raw;
227
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
228
- for (const line of raw.split('\n')) {
229
- if (!line.trim()) continue;
230
- let entry;
231
- try { entry = JSON.parse(line); } catch { continue; }
232
- if (entry.type !== 'attachment') continue;
233
- const att = entry.attachment;
234
- if (!att || att.type !== 'hook_success') continue;
235
- // Suffix match — survives plugin-cache vs symlinked-install path differences.
236
- if (!(att.command || '').includes(UPS_COMMAND_SUFFIX)) continue;
237
- const stdout = att.stdout || '';
238
- if (!stdout.includes('<memory-context')) continue;
239
- for (const memLine of stdout.split('\n')) {
256
+ eachHookAttachment(transcriptPath, ({ command, text }) => {
257
+ if (!command.includes(UPS_COMMAND_SUFFIX)) return;
258
+ if (!text.includes('<memory-context')) return;
259
+ for (const memLine of text.split('\n')) {
240
260
  if (!memLine.startsWith(UPS_LINE_PREFIX)) continue;
241
261
  // Take the LAST (#NN) on the line — formatMemoryLine puts the obs id
242
262
  // in trailing parens, possibly followed by ` [verify-before-use]`. Any
243
- // earlier (#NN) refs are inside title/lesson text (per the test that
244
- // pins "see (#999)" → NOT extracted).
263
+ // earlier (#NN) refs are inside title/lesson text.
245
264
  const matches = [...memLine.matchAll(UPS_ID_RE)];
246
265
  if (matches.length === 0) continue;
247
- const id = Number(matches[matches.length - 1][1]);
248
- if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
266
+ addObsId(ids, matches[matches.length - 1][1]);
249
267
  }
250
- }
268
+ });
269
+ return ids;
270
+ }
271
+
272
+ /**
273
+ * Extract observation IDs injected by the PostToolUse error-recall hint
274
+ * (hook.mjs triggerErrorRecall → `[claude-mem-lite] Related memories found for
275
+ * this error:` followed by ` #NN [type] title` lines, delivered via
276
+ * post-tool-use.sh). This is a high-volume surface that NO extractor matched
277
+ * before — error-recall'd obs accrued injection_count but never reached
278
+ * applyCitationDecay, so they could neither promote nor demote.
279
+ *
280
+ * @param {string|null|undefined} transcriptPath
281
+ * @returns {Set<number>}
282
+ */
283
+ export function extractInjectedFromErrorRecall(transcriptPath) {
284
+ const ids = new Set();
285
+ eachHookAttachment(transcriptPath, ({ command, text }) => {
286
+ if (!command.includes('post-tool-use')) return;
287
+ if (!text.includes('Related memories found for this error')) return;
288
+ // INJECTED_RE requires `#NN [type]`, so the trailing
289
+ // `→ Use mem_get(ids=[7933,8455])` line (bare numbers) is not matched.
290
+ INJECTED_RE.lastIndex = 0;
291
+ let m;
292
+ while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
293
+ });
294
+ return ids;
295
+ }
296
+
297
+ // user-prompt-search.js formatResults emits `[mem] FYI — Related memories ...`
298
+ // then one `#NN <icon> title` row per obs (raw stdout, line-leading id). Distinct
299
+ // from the `<memory-context>` block (hook.mjs) — the two UPS injectors dedup obs
300
+ // by id at inject time, so they carry DISJOINT obs sets; both must be extracted
301
+ // or the FYI-carried (highest-importance keyContext) obs never reach decay.
302
+ const FYI_HEADER = '[mem] FYI — Related memories';
303
+ // Anchored at line start so `P#NN` past-question rows (user_prompts, different id
304
+ // space) and any `#NN` inside lesson text are NOT matched.
305
+ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
306
+
307
+ /**
308
+ * Extract observation IDs injected by the user-prompt-search.js `[mem] FYI —
309
+ * Related memories` block.
310
+ *
311
+ * @param {string|null|undefined} transcriptPath
312
+ * @returns {Set<number>}
313
+ */
314
+ export function extractInjectedFromFyi(transcriptPath) {
315
+ const ids = new Set();
316
+ eachHookAttachment(transcriptPath, ({ command, text }) => {
317
+ if (!command.includes('user-prompt-search')) return;
318
+ if (!text.includes(FYI_HEADER)) return;
319
+ for (const fyiLine of text.split('\n')) {
320
+ const m = FYI_LINE_ID_RE.exec(fyiLine);
321
+ if (m) addObsId(ids, m[1]);
322
+ }
323
+ });
251
324
  return ids;
252
325
  }
253
326
 
254
327
  /**
255
- * Union of pre-tool-recall + UserPromptSubmit injection IDs for a transcript.
256
- * Single integration point the Stop handler calls — keeps hook.mjs's wiring
257
- * a one-liner and gives the contract test something to assert against.
328
+ * Union of every injection surface's IDs for a transcript: pre-tool-recall +
329
+ * UserPromptSubmit `<memory-context>` + PostToolUse error-recall + the
330
+ * user-prompt-search FYI block. Single integration point the Stop handler calls.
258
331
  *
259
332
  * @param {string|null|undefined} transcriptPath
260
333
  * @returns {Set<number>}
@@ -263,6 +336,8 @@ export function extractAllInjected(transcriptPath) {
263
336
  return new Set([
264
337
  ...extractInjectedFromPreToolUse(transcriptPath),
265
338
  ...extractInjectedFromUserPromptSubmit(transcriptPath),
339
+ ...extractInjectedFromErrorRecall(transcriptPath),
340
+ ...extractInjectedFromFyi(transcriptPath),
266
341
  ]);
267
342
  }
268
343
 
@@ -172,10 +172,40 @@ export function countUnsavedBugfixShape(transcriptPath) {
172
172
  // the bugfix-shape heuristic already requires ≥3 entries)
173
173
  // Either gate can fire independently. Both off → empty string (no surface).
174
174
  //
175
+ // Self-silence: after this many consecutive qualifying sessions where the
176
+ // project's cite-recall stayed below threshold, stop emitting the ratio nag — a
177
+ // project that has ignored the cite-#NN ask N times running is not going to
178
+ // start because we asked again; further nags are pure context noise (the audit's
179
+ // "nag-at-0%-compliance" anti-pattern). The streak resets the moment cite-recall
180
+ // recovers, so the nudge re-engages if behavior changes. Env override:
181
+ // CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER (0 = never silence).
182
+ export const CITE_NUDGE_SILENCE_AFTER = 3;
183
+
184
+ // True iff this session's stats satisfy the ratio-nag gate (low cite-recall with
185
+ // enough injection volume to judge). Shared by buildCiteRecallNudge (decide to
186
+ // nag) and nextCiteLowStreak (decide to keep silencing).
187
+ function ratioGateFires(data, env) {
188
+ const threshold = Number(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD) || 0.6;
189
+ const minInjected = Number(env.CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED) || 5;
190
+ return typeof data?.injected === 'number'
191
+ && typeof data?.ratio === 'number'
192
+ && data.injected >= minInjected
193
+ && data.ratio < threshold;
194
+ }
195
+
196
+ // Next consecutive-low-cite streak: increment when the ratio gate fires this
197
+ // session, reset to 0 otherwise (recovery, or too few injections to judge).
198
+ export function nextCiteLowStreak(priorStreak, stats, env = process.env) {
199
+ const prior = Number.isFinite(priorStreak) ? priorStreak : 0;
200
+ return ratioGateFires(stats, env) ? prior + 1 : 0;
201
+ }
202
+
175
203
  // Env opt-outs:
176
204
  // • CLAUDE_MEM_NO_CITE_NUDGE=1 — disables BOTH gates (full silence)
177
205
  // • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.6)
178
206
  // • CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED — ratio gate min-volume (default 5)
207
+ // • CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER — consecutive-low streak before the
208
+ // ratio nag self-silences (default 3; 0 = never silence)
179
209
  export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
180
210
  if (env.CLAUDE_MEM_NO_CITE_NUDGE === '1') return '';
181
211
  try {
@@ -183,13 +213,15 @@ export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
183
213
  const path = join(runtimeDir, `cite-recall-${safe}.json`);
184
214
  const raw = readFileSync(path, 'utf8');
185
215
  const data = JSON.parse(raw);
186
- const threshold = Number(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD) || 0.6;
187
- const minInjected = Number(env.CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED) || 5;
216
+ const silenceAfter = env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER !== undefined
217
+ ? Number(env.CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER)
218
+ : CITE_NUDGE_SILENCE_AFTER;
219
+ // silenceAfter > 0 AND the project has ignored the nag that many times running.
220
+ const silenced = silenceAfter > 0
221
+ && typeof data.lowStreak === 'number'
222
+ && data.lowStreak >= silenceAfter;
188
223
  const lines = [];
189
- if (typeof data.injected === 'number'
190
- && typeof data.ratio === 'number'
191
- && data.injected >= minInjected
192
- && data.ratio < threshold) {
224
+ if (!silenced && ratioGateFires(data, env)) {
193
225
  const pct = Math.round(data.ratio * 100);
194
226
  lines.push(`[mem] Last session cite-recall ${pct}% (${data.recalled}/${data.injected}) — when injected lessons (#NN lines) inform your action, cite #NN explicitly so the contract loop stays observable.`);
195
227
  }
@@ -0,0 +1,69 @@
1
+ // Sweep stale claude-mem-lite test-fixture directories from temp dirs.
2
+ //
3
+ // Tests create sandboxes via mkdtempSync(join(tmpdir(), '<prefix>')) and clean
4
+ // them in afterEach — but an interrupted or SIGKILL'd vitest run never reaches
5
+ // afterEach, leaking the dir (and its DBs) forever. The cross-project audit
6
+ // found ~795MB of such residue (mem-e2e-* / mem-audit-* dominating). Per-test
7
+ // cleanup cannot survive SIGKILL, so we ALSO reap at the next run's start
8
+ // (globalSetup) and via `node install.mjs cleanup`.
9
+ //
10
+ // Safety: depth-1 only (no recursion — §8 forbids deep traversal of ~/.claude),
11
+ // age-gated so a concurrently-running suite isn't disturbed, and restricted to a
12
+ // conservative allowlist of clearly mem-namespaced prefixes so we never delete
13
+ // another tool's temp dirs (e.g. code-graph-mcp's `.tmp*`/`index.db`).
14
+
15
+ import { readdirSync, statSync, rmSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { tmpdir } from 'os';
18
+
19
+ // Prefixes used by THIS repo's mkdtempSync fixtures. Deliberately only the
20
+ // clearly mem-namespaced ones — generic prefixes some tests use (plans-/tasks-/
21
+ // metrics-/drift-/projects-/git-fixture-) are EXCLUDED to avoid collateral
22
+ // deletion of unrelated /tmp dirs.
23
+ export const TEST_FIXTURE_PREFIXES = [
24
+ 'mem-', 'cite-', 'memdir-', 'adopt-', 'citation-test-',
25
+ 'text-floor-', 'unsaved-bugfix-', 'hook-telemetry-', 'hook-latency-',
26
+ 'quiet-hooks-', 'silent-adopt-', 'sweep-orphan-', 'pre-recall-sandbox-',
27
+ 'err-sampler-', 'cml-preflight-', 'cli-audit-',
28
+ ];
29
+
30
+ export const DEFAULT_FIXTURE_AGE_MS = 60 * 60 * 1000; // 1h — wide margin over the longest test
31
+
32
+ function isFixtureName(name) {
33
+ return TEST_FIXTURE_PREFIXES.some(p => name.startsWith(p));
34
+ }
35
+
36
+ /**
37
+ * Remove (or, with dryRun, list) stale test-fixture directories.
38
+ *
39
+ * @param {object} [opts]
40
+ * @param {string[]} [opts.dirs] temp roots to scan, depth-1 (default [os.tmpdir()])
41
+ * @param {number} [opts.ageMs] only act on entries older than this (default 1h)
42
+ * @param {boolean} [opts.dryRun] when true, list but do not delete
43
+ * @param {number} [opts.now] injectable clock for tests
44
+ * @returns {{removed: number, names: string[]}} absolute paths removed (or that would be)
45
+ */
46
+ export function sweepStaleTestFixtures({ dirs, ageMs = DEFAULT_FIXTURE_AGE_MS, dryRun = false, now = Date.now() } = {}) {
47
+ const roots = (dirs && dirs.length) ? dirs : [tmpdir()];
48
+ const cutoff = now - ageMs;
49
+ const names = [];
50
+ const seen = new Set();
51
+ for (const root of roots) {
52
+ if (!root || seen.has(root)) continue;
53
+ seen.add(root);
54
+ let entries;
55
+ try { entries = readdirSync(root); } catch { continue; }
56
+ for (const name of entries) {
57
+ if (!isFixtureName(name)) continue;
58
+ const full = join(root, name);
59
+ try {
60
+ const st = statSync(full);
61
+ if (!st.isDirectory()) continue;
62
+ if (st.mtimeMs >= cutoff) continue; // too fresh — may be an in-flight run
63
+ if (!dryRun) rmSync(full, { recursive: true, force: true });
64
+ names.push(full);
65
+ } catch { /* concurrent unlink / permission — ignore */ }
66
+ }
67
+ }
68
+ return { removed: names.length, names };
69
+ }
package/mem-cli.mjs CHANGED
@@ -1869,7 +1869,7 @@ function cmdMaintain(db, args) {
1869
1869
  const { positional, flags } = parseArgs(args);
1870
1870
  const action = positional[0];
1871
1871
  if (!action || !['scan', 'execute'].includes(action)) {
1872
- fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,dedup,purge_stale,rebuild_vectors] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
1872
+ fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
1873
1873
  return;
1874
1874
  }
1875
1875
 
@@ -1879,6 +1879,10 @@ function cmdMaintain(db, args) {
1879
1879
  const STALE_AGE_MS = 30 * 86400000;
1880
1880
  const SCAN_LIMIT = 500;
1881
1881
  const SIMILARITY_THRESHOLD = 0.7;
1882
+ // demote_pinned threshold: a memory injected this many times with zero
1883
+ // citations is "pinned noise" the regular `decay` op can't touch (decay
1884
+ // protects injection_count > 0). 8 aligns with the noise-penalty tier-2 cut.
1885
+ const PINNED_INJ_THRESHOLD = 8;
1882
1886
 
1883
1887
  if (action === 'scan') {
1884
1888
  const staleAge = Date.now() - STALE_AGE_MS;
@@ -1915,7 +1919,10 @@ function cmdMaintain(db, args) {
1915
1919
  COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
1916
1920
  THEN 1 ELSE 0 END), 0) as broken,
1917
1921
  COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
1918
- THEN 1 ELSE 0 END), 0) as boostable
1922
+ THEN 1 ELSE 0 END), 0) as boostable,
1923
+ COALESCE(SUM(CASE WHEN COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
1924
+ AND COALESCE(cited_count, 0) = 0 AND COALESCE(importance, 1) > 1
1925
+ THEN 1 ELSE 0 END), 0) as pinned
1919
1926
  FROM observations
1920
1927
  WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
1921
1928
  `).get(staleAge, ...baseParams);
@@ -1930,6 +1937,7 @@ function cmdMaintain(db, args) {
1930
1937
  out(` Stale (>30d, imp=1, no access): ${stats.stale}`);
1931
1938
  out(` Broken (no title/narrative): ${stats.broken}`);
1932
1939
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1940
+ out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
1933
1941
  out(` Pending purge: ${pendingPurge.count} (compressed originals awaiting cleanup)`);
1934
1942
  if (duplicates.length > 0) {
1935
1943
  const AUTO_MERGE_THRESHOLD = 0.85;
@@ -1962,7 +1970,7 @@ function cmdMaintain(db, args) {
1962
1970
  }
1963
1971
 
1964
1972
  // Execute
1965
- const VALID_OPS = ['cleanup', 'decay', 'boost', 'dedup', 'purge_stale', 'rebuild_vectors'];
1973
+ const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
1966
1974
  const opsStr = flags.ops || 'cleanup,decay,boost';
1967
1975
  const ops = opsStr.split(',').map(s => s.trim());
1968
1976
  const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
@@ -2024,6 +2032,31 @@ function cmdMaintain(db, args) {
2024
2032
  results.push(`Decayed ${decayed.changes} stale observations, marked ${idleMarked.changes} idle as pending-purge${decayCap}`);
2025
2033
  }
2026
2034
 
2035
+ if (ops.includes('demote_pinned')) {
2036
+ // Repair the citation-decay blind spot: the `decay` op above PROTECTS
2037
+ // injection_count > 0 rows, so a memory injected many times but never
2038
+ // cited stays pinned at max importance and keeps dominating injection
2039
+ // forever (the entrenched-noise pool the extractor bug let accumulate).
2040
+ // Target the inverse signal — heavy injection, zero citations — and drop
2041
+ // importance to 1 in a SINGLE pass. Injection priority is binary
2042
+ // (importance >= 2 → full weight; hook-memory.mjs), so a gentle 3→2 step
2043
+ // would leave the obs dominating injection just the same; only reaching 1
2044
+ // actually de-ranks it. Floors at 1 (not 0/purge) so a later boost (access)
2045
+ // or a genuine cite can still rescue a useful entry.
2046
+ const demoted = db.prepare(`
2047
+ UPDATE observations SET importance = 1
2048
+ WHERE id IN (
2049
+ SELECT id FROM observations
2050
+ WHERE COALESCE(compressed_into, 0) = 0
2051
+ AND COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
2052
+ AND COALESCE(cited_count, 0) = 0
2053
+ AND COALESCE(importance, 1) > 1
2054
+ ${projectFilter} LIMIT ${OP_CAP}
2055
+ )
2056
+ `).run(...baseParams);
2057
+ results.push(`Demoted ${demoted.changes} pinned-but-uncited observations to importance 1 (inj>=${PINNED_INJ_THRESHOLD}, cited=0)${capHint(demoted.changes)}`);
2058
+ }
2059
+
2027
2060
  if (ops.includes('boost')) {
2028
2061
  const boosted = db.prepare(`
2029
2062
  UPDATE observations SET importance = MIN(3, COALESCE(importance, 1) + 1)
@@ -2137,6 +2170,24 @@ function cmdMaintain(db, args) {
2137
2170
  }
2138
2171
  }
2139
2172
 
2173
+ // vacuum: reclaim freelist pages left behind by DELETEs (purge_stale / cleanup
2174
+ // / dedup). DELETE only grows the freelist; the file never shrinks without
2175
+ // VACUUM, which is absent everywhere else (auto_vacuum=0). Must run OUTSIDE any
2176
+ // transaction. Whole-DB regardless of --project. Reports freelist before/after
2177
+ // as the §7 reclaim metric.
2178
+ if (ops.includes('vacuum')) {
2179
+ try {
2180
+ const pageSize = db.pragma('page_size', { simple: true });
2181
+ const freeBefore = db.pragma('freelist_count', { simple: true });
2182
+ db.exec('VACUUM');
2183
+ const freeAfter = db.pragma('freelist_count', { simple: true });
2184
+ const reclaimedMB = ((Math.max(0, freeBefore - freeAfter) * pageSize) / 1048576).toFixed(1);
2185
+ results.push(`VACUUM: reclaimed ~${reclaimedMB}MB (freelist ${freeBefore} → ${freeAfter} pages)`);
2186
+ } catch (e) {
2187
+ results.push(`VACUUM failed — ${e.message}`);
2188
+ }
2189
+ }
2190
+
2140
2191
  out(`[mem] ${results.join('\n[mem] ')}`);
2141
2192
  }
2142
2193
 
@@ -2575,10 +2626,12 @@ Commands:
2575
2626
  --project P Filter by project
2576
2627
 
2577
2628
  maintain <scan|execute> Memory maintenance
2578
- --ops O Comma-separated: cleanup,decay,boost,dedup,purge_stale,rebuild_vectors
2629
+ --ops O Comma-separated: cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
2579
2630
  --merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
2580
2631
  --project P Filter by project
2581
2632
  --retain-days N For purge_stale: keep last N days (default 30)
2633
+ demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise)
2634
+ vacuum: reclaim freelist dead space (whole-DB, ignores --project)
2582
2635
 
2583
2636
  optimize LLM-powered memory optimization (preview by default)
2584
2637
  --run Execute (default: preview gates)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.84.2",
4
- "description": "Lightweight persistent memory system for Claude Code",
3
+ "version": "2.85.0",
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. Alternative to claude-mem with 600x lower cost.",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
7
7
  "engines": {
@@ -58,6 +58,7 @@
58
58
  "lib/private-strip.mjs",
59
59
  "lib/citation-tracker.mjs",
60
60
  "lib/cite-back-hint.mjs",
61
+ "lib/tmp-fixture-sweep.mjs",
61
62
  "lib/summary-extractor.mjs",
62
63
  "lib/id-routing.mjs",
63
64
  "lib/err-sampler.mjs",
@@ -329,14 +329,18 @@ try {
329
329
  lines.push(` #${r.id} [${r.type}] ${title}`);
330
330
  }
331
331
  }
332
- } else if (!isRead) {
333
- // R-4: Edit/Write empty → short backfill reminder. Two goals: (1) Claude
334
- // sees that the system actually ran, (2) Claude is nudged to save a lesson
335
- // after a non-obvious bug. Reminder is one line to keep per-Edit cost low.
332
+ } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
333
+ // R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
334
+ // of the cross-project audit: this "no prior lessons, remember to /lesson"
335
+ // reminder fired on ~70% of Edit/Write recalls and drove zero observed
336
+ // /lesson calls — pure context noise, mostly on brand-new files that by
337
+ // definition can't have a lesson. Save-nudging now lives at Stop time
338
+ // (buildCiteRecallNudge's unsaved-bugfix line + the cite-back hint), which
339
+ // has the full episode to judge whether a real fix happened. Set
340
+ // CLAUDE_MEM_PRETOOL_NUDGE=1 to restore the per-Edit reminder.
336
341
  //
337
- // v2.34.6: Read does NOT emit this nudge. Read is passive the agent
338
- // isn't necessarily about to solve anything, so /lesson prompts are noise.
339
- // Empty Reads exit silently, saving ~60 tokens × (every empty-file Read).
342
+ // Read never emitted this (passive). The cooldown write below still runs on
343
+ // every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
340
344
  lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
341
345
  lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
342
346
  }
package/server.mjs CHANGED
@@ -1484,6 +1484,29 @@ server.registerTool(
1484
1484
  results.push(`Boosted ${boosted.changes} frequently-accessed observations` + (boosted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
1485
1485
  }
1486
1486
 
1487
+ if (ops.includes('demote_pinned')) {
1488
+ // CLI-parity (cmdMaintain): repair the citation-decay blind spot. The
1489
+ // `decay` op protects injection_count > 0, so a memory injected many
1490
+ // times but never cited stays pinned at max importance and keeps
1491
+ // dominating injection. Target heavy-injection + zero-citation and
1492
+ // drop importance to 1 in one pass — injection priority is binary
1493
+ // (importance>=2), so a 3→2 step would not de-rank it. Floor 1 (not
1494
+ // purge). PINNED_INJ_THRESHOLD=8.
1495
+ const demoted = db.prepare(`
1496
+ UPDATE observations SET importance = 1
1497
+ WHERE id IN (
1498
+ SELECT id FROM observations
1499
+ WHERE COALESCE(compressed_into, 0) = 0
1500
+ AND COALESCE(injection_count, 0) >= 8
1501
+ AND COALESCE(cited_count, 0) = 0
1502
+ AND COALESCE(importance, 1) > 1
1503
+ ${projectFilter}
1504
+ LIMIT ${OP_ROW_CAP}
1505
+ )
1506
+ `).run(...baseParams);
1507
+ results.push(`Demoted ${demoted.changes} pinned-but-uncited observations to importance 1 (inj>=8, cited=0)` + (demoted.changes >= OP_ROW_CAP ? ' (cap reached, re-run for more)' : ''));
1508
+ }
1509
+
1487
1510
  if (ops.includes('dedup') && args.merge_ids) {
1488
1511
  let totalMerged = 0;
1489
1512
  const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
@@ -1557,6 +1580,22 @@ server.registerTool(
1557
1580
  }
1558
1581
  }
1559
1582
 
1583
+ // vacuum: reclaim freelist dead space left by DELETEs. CLI-parity
1584
+ // (cmdMaintain). Must run OUTSIDE any transaction; whole-DB.
1585
+ if (ops.includes('vacuum')) {
1586
+ try {
1587
+ const pageSize = db.pragma('page_size', { simple: true });
1588
+ const freeBefore = db.pragma('freelist_count', { simple: true });
1589
+ db.exec('VACUUM');
1590
+ const freeAfter = db.pragma('freelist_count', { simple: true });
1591
+ const reclaimedMB = ((Math.max(0, freeBefore - freeAfter) * pageSize) / 1048576).toFixed(1);
1592
+ results.push(`VACUUM: reclaimed ~${reclaimedMB}MB (freelist ${freeBefore} → ${freeAfter} pages)`);
1593
+ } catch (e) {
1594
+ debugCatch(e, 'vacuum');
1595
+ results.push(`VACUUM failed — ${e.message}`);
1596
+ }
1597
+ }
1598
+
1560
1599
  return { content: [{ type: 'text', text: results.join('\n') }] };
1561
1600
  }
1562
1601
 
package/source-files.mjs CHANGED
@@ -41,6 +41,9 @@ export const SOURCE_FILES = [
41
41
  'lib/private-strip.mjs',
42
42
  'lib/citation-tracker.mjs',
43
43
  'lib/cite-back-hint.mjs',
44
+ // v2.85: stale test-fixture sweeper. Imported by install.mjs (cleanup) + cli.mjs.
45
+ // Missing from manifest → tarball ships install.mjs that ERR_MODULE_NOT_FOUND on cleanup.
46
+ 'lib/tmp-fixture-sweep.mjs',
44
47
  'lib/summary-extractor.mjs',
45
48
  'lib/id-routing.mjs',
46
49
  'lib/err-sampler.mjs',
package/tool-schemas.mjs CHANGED
@@ -203,8 +203,8 @@ export const memOptimizeSchema = {
203
203
 
204
204
  export const memMaintainSchema = {
205
205
  action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
206
- operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'purge_stale', 'rebuild_vectors'])).optional()
207
- .describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors'),
206
+ operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
207
+ .describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
208
208
  merge_ids: z.preprocess(
209
209
  (v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
210
210
  z.array(z.array(z.number().int()).min(2))