claude-mem-lite 2.31.1 → 2.32.1

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": "2.31.1",
13
+ "version": "2.32.1",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.31.1",
3
+ "version": "2.32.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -92,6 +92,7 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
92
92
  - **Prompt-time memory injection** -- UserPromptSubmit hook automatically searches and injects relevant past observations with recency and importance weighting
93
93
  - **Smart skill invocation** -- Auto-loaded and searched managed skills/agents include portable `~` paths with `Read()` guidance; native plugin skills recommend `Skill("full:name")`; prevents `Skill()` misuse for managed resources that aren't registered with Claude Code's native handler
94
94
  - **Dual injection dedup** -- `user-prompt-search.js` and `handleUserPrompt` coordinate via temp file to prevent duplicate memory injection
95
+ - **Plugin cache hook self-heal** -- Claude Code runtime reads plugin hooks from `~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json`, not from the marketplace source. When `install.mjs`-managed `settings.json` hooks coexist with a stale cache `hooks.json` (e.g. from a previous marketplace install or a plugin auto-update), the runtime registers hooks twice → every session start / user prompt fires twice. `install.mjs` and `hook-update.mjs` now clear cache `hooks.json` in every version dir, and `hook.mjs session-start` self-heals on every session (gated by `hasInstallManagedHooks` so plugin-only users are not affected). `install.mjs status` reports cache pollution state (since v2.31.1/2.31.2).
95
96
  - **Result-dedup cooldown** -- User-prompt memory injection uses result-overlap detection (>80% ID overlap → skip) instead of time-based cooldown, allowing topic switches within seconds while preventing redundant injections
96
97
  - **OR query fallback** -- When AND-joined FTS5 queries return zero results, automatically relaxes to OR-joined queries for broader recall (applied in both user-prompt-search and hook-memory paths)
97
98
  - **Configurable LLM model** -- Switch between Haiku (fast/cheap) and Sonnet (deeper analysis) via `CLAUDE_MEM_MODEL` env var
@@ -101,6 +102,9 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
101
102
  - **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
102
103
  - **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
103
104
  - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/clear` or `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap
105
+ - **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
106
+ - **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
107
+ - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. Slash commands: `/lesson`, `/bug`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic
104
108
  - **In-place observation updates** -- `mem_update` tool modifies existing observations atomically (field update + FTS text rebuild + vector re-computation in one transaction), preserving original IDs and references
105
109
  - **Bulk export** -- `mem_export` tool exports observations as JSON or JSONL, with project/type/date filtering and 1000-row pagination cap with batch guidance
106
110
  - **FTS integrity management** -- `mem_fts_check` tool verifies FTS5 index health or rebuilds indexes on demand, useful after database recovery or when search results seem wrong
@@ -235,6 +239,8 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
235
239
  /mem timeline <query> # Browse timeline around a match
236
240
  /mem browse # Tier-grouped memory dashboard
237
241
  /mem <query> # Shorthand for search
242
+ /lesson <text> # Save a non-obvious lesson to the events table (v2.31.0)
243
+ /bug <text> # Log a known bug + repro steps to the events table (v2.31.0)
238
244
  ```
239
245
 
240
246
  ### Efficient Search Workflow
@@ -245,6 +251,48 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
245
251
  3. mem_get(ids=[12345, 12346]) -> full details
246
252
  ```
247
253
 
254
+ ### Invited Memory (v2.32+)
255
+
256
+ Opt-in mechanism that installs a single sentinel-wrapped line into the project's
257
+ memdir (`~/.claude/projects/<encoded>/memory/MEMORY.md`) so Claude Code loads
258
+ the plugin's MCP-tool triggers as **user-memory** — a higher instruction-following
259
+ authority than MCP server instructions (which are framed as tool metadata).
260
+
261
+ ```bash
262
+ claude-mem-lite adopt # install for current project
263
+ claude-mem-lite adopt --all # install for every project under ~/.claude/projects/
264
+ claude-mem-lite adopt --status # list adopted projects + sentinel versions
265
+ claude-mem-lite adopt --dry-run # preview without writing
266
+ claude-mem-lite unadopt # remove cleanly
267
+ ```
268
+
269
+ Slash commands `/adopt` and `/unadopt` wrap the same CLI.
270
+
271
+ **What adoption changes:**
272
+ - A `<!-- claude-mem-lite:begin v1 -->…<!-- claude-mem-lite:end -->` block is
273
+ added to `MEMORY.md` under a `## 插件契约` header, containing one ≤150-char
274
+ line pointing at `mem_recall` / `mem_save` with their key arguments.
275
+ - A `plugin_claude_mem_lite.md` detail file is written (not auto-loaded; read
276
+ on demand when the MEMORY.md pointer surfaces in context).
277
+ - Post-adopt the conservative hook layer auto-trims: MCP server instructions
278
+ drop the `WHEN TO USE` section, SessionStart injection drops the `File Lessons`
279
+ / `Key Context` sections. `#ID` references and the `Recent` table still fire
280
+ so `mem_get` remains reachable.
281
+
282
+ **Safety:**
283
+ - Hash-guarded: editing the sentinel body yourself blocks automatic rewrites
284
+ unless you pass `--force`.
285
+ - Budget-gated: refuses to insert when MEMORY.md is already >180 lines, so
286
+ Claude Code's 200-line MEMORY.md cap won't truncate the block.
287
+ - `install` only auto-adopts when run from the claude-mem-lite source repo
288
+ itself (detected via git remote match); other users must opt in explicitly.
289
+ - The fallback hook layer is never removed from source — conditional trim is
290
+ runtime-gated on sentinel presence, so projects without adoption get the
291
+ full verbose output.
292
+
293
+ See `docs/plans/2026-04-16-invited-memory-pattern.md` for the full design
294
+ (including the reusable template other plugins can follow).
295
+
248
296
  ## Database Schema
249
297
 
250
298
  Five core tables with FTS5 virtual tables for search:
@@ -523,6 +571,8 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
523
571
  | `CLAUDE_MEM_DIR` | Custom data directory. All databases, runtime files, and managed resources are stored here. | `~/.claude-mem-lite/` |
524
572
  | `CLAUDE_MEM_MODEL` | LLM model for background calls (episode extraction, session summaries). Accepts `haiku` or `sonnet`. | `haiku` |
525
573
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
574
+ | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. | _(disabled)_ |
575
+ | `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. The hint self-clears once `adopt` runs; set this env to suppress it without adopting. | _(disabled)_ |
526
576
 
527
577
  ## License
528
578
 
package/README.zh-CN.md CHANGED
@@ -94,6 +94,10 @@
94
94
  - **LLM 并发控制** -- 基于文件的信号量将后台 worker 限制为 2 个并发 LLM 调用,防止资源争用
95
95
  - **stdin 溢出保护** -- Hook 输入在 256KB 处截断,对超大工具输出使用正则挽救关键信息
96
96
  - **跨会话交接** -- 在 `/clear` 或 `/exit` 时捕获会话状态(请求、已完成工作、后续步骤、关键文件),下次会话检测到继续意图时自动注入上下文(支持显式关键词和 FTS5 术语重叠匹配)
97
+ - **插件缓存 hook 自愈** -- Claude Code runtime 从 `~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json` 读取插件 hook,而非 marketplace 源。当 `install.mjs` 写入 `settings.json` 的 hooks 与残留 cache `hooks.json` 同时存在(例如曾装过 marketplace 版本,或插件被 Claude Code 自动升级重建 cache),runtime 会注册两套 hook → 每次 SessionStart / UserPromptSubmit 都触发两份。`install.mjs` 和 `hook-update.mjs` 现在会清理每个 cache 版本目录下的 `hooks.json`;`hook.mjs session-start` 每次启动自愈(通过 `hasInstallManagedHooks` 门控,不影响纯插件模式用户);`install.mjs status` 会报告 cache 污染状况(自 v2.31.1 / v2.31.2 起)。
98
+ - **Git-SHA 延续锚点**(v2.31.0)-- handoff 记录包含 `git_sha_at_handoff` 字段,任何匹配当前 `HEAD` 的 handoff 都视为延续会话,不受 TTL 限制。代码状态比时钟时间更能反映上下文延续。
99
+ - **启动面板**(v2.31.0)-- SessionStart hook 将 `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + 最近 /exit 交接 + 最近事件数聚合为一个结构化块,通过 `hookSpecificOutput.additionalContext` 注入。
100
+ - **活动命名空间**(v2.31.0)-- 为非 memdir 类型(`bugfix` / `lesson` / `bug` / `discovery` / `refactor` / `feature` / `observation` / `decision`)启用独立的 `events` 表 + FTS5,与 observations 表的 `WHAT_NOT_TO_SAVE` 语义解耦。CLI:`claude-mem-lite activity save|search|recent|show`;斜杠命令:`/lesson`、`/bug`。`hook-llm` 通过 `persistHaikuSummary` 路由非 memdir 摘要,observations → events 升级路径是事务原子的。
97
101
 
98
102
  ## 平台支持
99
103
 
@@ -222,6 +226,8 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
222
226
  /mem timeline <query> # 围绕匹配结果浏览时间线
223
227
  /mem browse # 分层记忆仪表盘
224
228
  /mem <query> # search 的简写
229
+ /lesson <text> # 保存非显而易见的经验到 events 表(v2.31.0)
230
+ /bug <text> # 记录已知 bug + 复现步骤到 events 表(v2.31.0)
225
231
  ```
226
232
 
227
233
  ### 高效搜索工作流
@@ -232,6 +238,44 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
232
238
  3. mem_get(ids=[12345, 12346]) -> 完整详情
233
239
  ```
234
240
 
241
+ ### Invited Memory(邀请式记忆,v2.32+)
242
+
243
+ Opt-in 机制——向项目 memdir(`~/.claude/projects/<encoded>/memory/MEMORY.md`)
244
+ 注入单行 sentinel 包围的插件契约,Claude Code 会把它作为 **user-memory** 加载
245
+ 到系统提示——比 MCP server instructions(被框定为 tool metadata)具有更高的
246
+ instruction-following 权威。
247
+
248
+ ```bash
249
+ claude-mem-lite adopt # 当前项目注入
250
+ claude-mem-lite adopt --all # 扫描 ~/.claude/projects/* 全部注入
251
+ claude-mem-lite adopt --status # 列出所有已 adopt 的项目 + 版本号
252
+ claude-mem-lite adopt --dry-run # 只打印不写入
253
+ claude-mem-lite unadopt # 精确移除
254
+ ```
255
+
256
+ Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
257
+
258
+ **Adopt 后会发生什么:**
259
+ - `MEMORY.md` 新增一段 `<!-- claude-mem-lite:begin v1 -->…<!-- claude-mem-lite:end -->`
260
+ 包裹的 `## 插件契约`,含一行 ≤150 字符、指向 `mem_recall` / `mem_save` 关键参数
261
+ 的动作锚条目。
262
+ - 生成 `plugin_claude_mem_lite.md` 详情文件(按需读取,不自动加载)。
263
+ - 保守 hook 层自动瘦身:MCP server instructions 去掉 `WHEN TO USE` 段,
264
+ SessionStart 注入去掉 `File Lessons` / `Key Context`。`#ID` 引用与 `Recent`
265
+ 表保留,`mem_get` 仍可随时展开。
266
+
267
+ **安全性:**
268
+ - Hash 守护:你手动改了 sentinel 段 → 下一次 adopt 报 `UserEditedError`,
269
+ 除非显式 `--force`。
270
+ - 预算门:MEMORY.md 已 >180 行时拒绝新增(避开 Claude Code 200 行截断)。
271
+ - `install` 只在从 claude-mem-lite 源码仓库运行时 auto-adopt(靠 git remote 判别);
272
+ 其它用户需显式调用。
273
+ - 保守 hook 层源码永不删——条件瘦身仅基于 sentinel 存在性做 runtime 判断,
274
+ 未 adopt 的项目仍看完整 verbose 输出。
275
+
276
+ 完整设计见 `docs/plans/2026-04-16-invited-memory-pattern.md`(含其它插件
277
+ 可复用的模板)。
278
+
235
279
  ## 数据库结构
236
280
 
237
281
  五张核心表 + FTS5 虚拟表用于搜索:
@@ -500,6 +544,8 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
500
544
  | `CLAUDE_MEM_DIR` | 自定义数据目录。所有数据库、运行时文件和托管资源均存储在此。 | `~/.claude-mem-lite/` |
501
545
  | `CLAUDE_MEM_MODEL` | 后台 LLM 调用模型(Episode 提取、会话总结、调度)。可选 `haiku` 或 `sonnet`。 | `haiku` |
502
546
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
547
+ | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。 | _(禁用)_ |
548
+ | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。一旦运行 `adopt` 该提示自动消失;此 env 让偏好保守层的用户在不 adopt 的情况下也能静音。 | _(禁用)_ |
503
549
 
504
550
  ## 许可证
505
551
 
package/adopt-cli.mjs ADDED
@@ -0,0 +1,166 @@
1
+ // Phase C (Invited-Memory plan, T11): CLI handlers for
2
+ // claude-mem-lite adopt [--all] [--force] [--dry-run] [--status]
3
+ // claude-mem-lite unadopt [--all]
4
+ //
5
+ // adopt = write sentinel section into MEMORY.md + drop plugin_claude_mem_lite.md
6
+ // unadopt = precise sentinel removal + doc cleanup
7
+ // --all = scan every project under ~/.claude/projects/*/memory/
8
+ // --force = override UserEditedError
9
+ // --dry-run = print intent without writing
10
+ // --status = list all adopted projects + versions
11
+
12
+ import { existsSync, readdirSync, statSync } from 'fs';
13
+ import { homedir } from 'os';
14
+ import { join } from 'path';
15
+ import {
16
+ memdirPath, writePluginSection, removePluginSection,
17
+ writePluginDoc, removePluginDoc,
18
+ isAdopted, readMemoryIndex,
19
+ UserEditedError, BudgetExceededError,
20
+ } from './memdir.mjs';
21
+ import {
22
+ PLUGIN_SLUG, CURRENT_SENTINEL_VERSION, getIndexLine, getDetailDoc,
23
+ } from './adopt-content.mjs';
24
+
25
+ function log(msg) { console.log(msg); }
26
+
27
+ function detectCwd() {
28
+ return process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
29
+ }
30
+
31
+ function projectsRoot() {
32
+ return join(homedir(), '.claude', 'projects');
33
+ }
34
+
35
+ function listAllMemdirs() {
36
+ const base = projectsRoot();
37
+ if (!existsSync(base)) return [];
38
+ const out = [];
39
+ for (const name of readdirSync(base)) {
40
+ const memdir = join(base, name, 'memory');
41
+ try {
42
+ if (existsSync(memdir) && statSync(memdir).isDirectory()) {
43
+ out.push({ projectSlug: name, memdir });
44
+ }
45
+ } catch { /* ignore entries we can't stat */ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function hasFlag(args, flag) { return Array.isArray(args) && args.includes(flag); }
51
+
52
+ /**
53
+ * cmdAdopt — write sentinel section + plugin doc to memdir.
54
+ * Exit code 1 on any hard failure; skipped (--all + UserEditedError) doesn't
55
+ * fail the batch.
56
+ */
57
+ export function cmdAdopt(args = []) {
58
+ if (hasFlag(args, '--status')) return statusAll();
59
+
60
+ const all = hasFlag(args, '--all');
61
+ const force = hasFlag(args, '--force');
62
+ const dryRun = hasFlag(args, '--dry-run');
63
+
64
+ const targets = all
65
+ ? listAllMemdirs().map((m) => m.memdir)
66
+ : [memdirPath(detectCwd())];
67
+
68
+ if (targets.length === 0) {
69
+ log('[adopt] no memdirs to adopt (use without --all for current project)');
70
+ return;
71
+ }
72
+
73
+ let created = 0, updated = 0, unchanged = 0, skipped = 0, failed = 0;
74
+ for (const memdir of targets) {
75
+ const r = adoptOne(memdir, { force, dryRun, all });
76
+ if (r.action === 'created') created++;
77
+ else if (r.action === 'updated') updated++;
78
+ else if (r.action === 'unchanged') unchanged++;
79
+ else if (r.action === 'skipped') skipped++;
80
+ else if (r.action === 'dry-run') unchanged++;
81
+ else failed++;
82
+ }
83
+
84
+ log('');
85
+ log(`[adopt] ${targets.length} target(s): ${created} created, ${updated} updated, ${unchanged} unchanged, ${skipped} skipped, ${failed} failed`);
86
+ if (failed > 0) process.exitCode = 1;
87
+ }
88
+
89
+ function adoptOne(memdir, { force, dryRun, all }) {
90
+ const contentLine = getIndexLine();
91
+ const version = CURRENT_SENTINEL_VERSION;
92
+
93
+ if (dryRun) {
94
+ log(`[adopt --dry-run] ${memdir}`);
95
+ log(` MEMORY.md line: ${contentLine}`);
96
+ log(` detail file: plugin_claude_mem_lite.md (${getDetailDoc().length} chars)`);
97
+ return { action: 'dry-run' };
98
+ }
99
+
100
+ try {
101
+ const r = writePluginSection(memdir, { slug: PLUGIN_SLUG, version, contentLine, force });
102
+ writePluginDoc(memdir, PLUGIN_SLUG, getDetailDoc());
103
+ log(`[adopt] ${memdir} → ${r.action}`);
104
+ return r;
105
+ } catch (e) {
106
+ if (e instanceof UserEditedError && all) {
107
+ log(`[adopt] ${memdir} → skipped (user-edited; pass --force to override)`);
108
+ return { action: 'skipped' };
109
+ }
110
+ if (e instanceof UserEditedError) {
111
+ log(`[adopt] ${memdir} → refused: ${e.message}`);
112
+ log('[adopt] pass --force to overwrite, or edit/uninstall manually.');
113
+ return { action: 'failed' };
114
+ }
115
+ if (e instanceof BudgetExceededError) {
116
+ log(`[adopt] ${memdir} → failed: ${e.message}`);
117
+ return { action: 'failed' };
118
+ }
119
+ log(`[adopt] ${memdir} → error: ${e.message}`);
120
+ return { action: 'failed' };
121
+ }
122
+ }
123
+
124
+ function statusAll() {
125
+ const dirs = listAllMemdirs();
126
+ log('[adopt --status] scanning ~/.claude/projects/*/memory/');
127
+ if (dirs.length === 0) { log(' (no memdirs found)'); return; }
128
+ let adopted = 0;
129
+ for (const { projectSlug, memdir } of dirs) {
130
+ if (isAdopted(memdir, PLUGIN_SLUG)) {
131
+ const idx = readMemoryIndex(memdir, PLUGIN_SLUG);
132
+ log(` ✓ ${projectSlug} (${idx.version})`);
133
+ adopted++;
134
+ }
135
+ }
136
+ log('');
137
+ log(`[adopt --status] ${adopted}/${dirs.length} adopted`);
138
+ }
139
+
140
+ /**
141
+ * cmdUnadopt — precise removal of sentinel section + plugin doc.
142
+ * Exit code stays 0: unadopt is idempotent; "absent" isn't an error.
143
+ */
144
+ export function cmdUnadopt(args = []) {
145
+ const all = hasFlag(args, '--all');
146
+ const targets = all
147
+ ? listAllMemdirs().map((m) => m.memdir)
148
+ : [memdirPath(detectCwd())];
149
+
150
+ if (targets.length === 0) {
151
+ log('[unadopt] no memdirs found');
152
+ return;
153
+ }
154
+
155
+ let removed = 0, absent = 0;
156
+ for (const memdir of targets) {
157
+ const r = removePluginSection(memdir, PLUGIN_SLUG);
158
+ removePluginDoc(memdir, PLUGIN_SLUG);
159
+ if (r.action === 'removed') removed++;
160
+ else absent++;
161
+ log(`[unadopt] ${memdir} → ${r.action}`);
162
+ }
163
+
164
+ log('');
165
+ log(`[unadopt] ${targets.length} target(s): ${removed} removed, ${absent} absent`);
166
+ }
@@ -0,0 +1,75 @@
1
+ // Phase C (Invited-Memory plan, T10): Content generators for the
2
+ // claude-mem-lite sentinel line and its companion plugin_claude_mem_lite.md
3
+ // detail doc. Kept separate so the strings are testable without side effects
4
+ // and can evolve without touching memdir.mjs primitives.
5
+ //
6
+ // Bumping CURRENT_SENTINEL_VERSION: pick the next vN and update
7
+ // docs/plans/2026-04-16-invited-memory-pattern.md so installs do a version-
8
+ // bump replace instead of treating the old content as a user edit.
9
+
10
+ export const PLUGIN_SLUG = 'claude-mem-lite';
11
+ export const CURRENT_SENTINEL_VERSION = 'v1';
12
+
13
+ /**
14
+ * One-line entry injected into MEMORY.md's sentinel section. Must stay ≤150
15
+ * chars so it clears Claude Code's 200-line MEMORY.md cap with headroom.
16
+ */
17
+ export function getIndexLine() {
18
+ return '- [claude-mem-lite](plugin_claude_mem_lite.md) — Edit 前 `mem_recall(file=…)`;bugfix 后 `mem_save(type="bugfix", lesson_learned, importance=2)`';
19
+ }
20
+
21
+ /**
22
+ * Full detail doc rendered into `memdir/plugin_claude_mem_lite.md`. Not
23
+ * auto-loaded by Claude Code — the MEMORY.md index line points to it and
24
+ * Claude reads it on demand when the injected hint suggests relevance.
25
+ */
26
+ export function getDetailDoc() {
27
+ return `# claude-mem-lite 插件契约
28
+
29
+ > 由 \`claude-mem-lite adopt\` 生成;卸载用 \`claude-mem-lite unadopt\`。
30
+ > 设计背景见 docs/plans/2026-04-16-invited-memory-pattern.md。
31
+
32
+ ## 何时调用 MCP 工具
33
+
34
+ | 时机 | 工具 | 关键参数 |
35
+ |------|------|----------|
36
+ | Edit / Write 前 | \`mem_recall\` | \`file="<路径>"\` —— 过往 bugfix 与教训 |
37
+ | Test failure / error | \`mem_search\` | \`query="<错误关键词>", obs_type="bugfix"\` |
38
+ | Refactor 前 | \`mem_search\` | \`query="<模块>", obs_type="refactor"\` |
39
+ | 新功能起手 | \`mem_search\` | \`query="<功能区域>"\` —— 找 prior art |
40
+ | Bugfix 解决后 | \`mem_save\` | \`type="bugfix", lesson_learned="<根因+修法>", importance=2\` |
41
+ | 架构决策后 | \`mem_save\` | \`type="decision", lesson_learned="<取舍理由>", importance=2\` |
42
+ | 上下文提到 #NN | \`mem_get\` | \`ids=[NN]\` |
43
+
44
+ ## Decision rules(替代多步 search)
45
+
46
+ - "最近做了啥" → \`mem_recent\`
47
+ - "<文件> 有哪些记忆" → \`mem_recall\`
48
+ - "#NN 前后发生了啥" → \`mem_timeline\`
49
+ - "清理过期记忆" → \`mem_maintain\`
50
+ - "FTS 索引健康吗" → \`mem_fts_check\`
51
+ - "按 tier 浏览" → \`mem_browse\`
52
+ - "备份导出" → \`mem_export\`
53
+
54
+ ## CLI 速查
55
+
56
+ | 命令 | 用途 |
57
+ |------|------|
58
+ | \`claude-mem-lite search "query"\` | FTS5 全文搜索 |
59
+ | \`claude-mem-lite search "err" --type bugfix\` | 按类型过滤 |
60
+ | \`claude-mem-lite recall "file.mjs"\` | 文件相关记忆 |
61
+ | \`claude-mem-lite recent 5\` | 最近 5 条 |
62
+ | \`claude-mem-lite get 42,43\` | 按 ID 展开 |
63
+ | \`claude-mem-lite timeline --anchor 42\` | 时间线上下文 |
64
+
65
+ ## 质量门槛
66
+
67
+ - \`mem_save\` 的 \`lesson_learned\` 不要写 \`none\`——写不出教训就保持 NULL
68
+ - \`decision\` 命中率 72.7% vs \`change\` 16.5%——一条好 decision ≈ 20 条 change
69
+ - 一般搜索跳过 \`obs_type\` 让系统自动路由;特定意图再过滤
70
+
71
+ ## 卸载
72
+
73
+ \`claude-mem-lite unadopt\` 精确移除 sentinel 段 + 本文件;其它 MEMORY.md 内容不动。
74
+ `;
75
+ }
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: adopt
3
+ description: "Use when: user asks to increase claude-mem-lite's tool-invocation rate in the current project, or wants to install the invited-memory sentinel so Claude Code auto-loads the contract as user-memory. Writes a single sentinel-wrapped line to ~/.claude/projects/<encoded>/memory/MEMORY.md plus a plugin_claude_mem_lite.md detail file. Run /unadopt to remove."
4
+ ---
5
+
6
+ # /adopt
7
+
8
+ Install the **invited-memory** sentinel into the current project's Claude Code
9
+ memdir (`~/.claude/projects/<encoded>/memory/`). The sentinel line carries the
10
+ MCP-tool triggers (`mem_recall` / `mem_save`) at system-prompt authority
11
+ (framed as "user's auto-memory") so Claude Code is more likely to call them
12
+ proactively than when those same triggers live in MCP server instructions.
13
+
14
+ ## What it writes
15
+
16
+ 1. **`MEMORY.md`** — ONE sentinel-wrapped line under a `## 插件契约` header.
17
+ Idempotent: re-running replaces the block only if the version bumped.
18
+ 2. **`plugin_claude_mem_lite.md`** — Detailed contract (CLI cheatsheet +
19
+ MCP decision rules). Not auto-loaded; Claude reads it on demand when the
20
+ MEMORY.md pointer surfaces in context.
21
+ 3. **`.plugin_claude_mem_lite_state.json`** — Sidecar with sentinel body hash
22
+ for user-edit detection.
23
+
24
+ ## Flags
25
+
26
+ - `--force` — overwrite a sentinel block that was manually edited
27
+ - `--dry-run` — print intended writes without touching disk
28
+ - `--all` — adopt every memdir under `~/.claude/projects/*/memory/`
29
+ - `--status` — list all adopted projects with their sentinel versions
30
+
31
+ ## Removal
32
+
33
+ `/unadopt` precisely removes the sentinel block + plugin doc + state file.
34
+ User content outside the sentinel is preserved.
35
+
36
+ ## Conservative layer still active
37
+
38
+ Adoption does NOT remove the hook-based injection (SessionStart context,
39
+ UserPromptSubmit related-memory). Those remain as fallback for older Claude
40
+ Code versions. Post-adopt the MCP `WHEN TO USE` section + the `File Lessons` /
41
+ `Key Context` sections auto-trim since the sentinel line already carries the
42
+ triggers at higher authority.
43
+
44
+ !claude-mem-lite adopt $ARGUMENTS
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: unadopt
3
+ description: "Use when: user wants to remove the invited-memory sentinel from the current project (or all projects with --all). Cleans MEMORY.md sentinel block + plugin_claude_mem_lite.md + state sidecar. User content outside the sentinel is preserved. Benign no-op on never-adopted memdirs."
4
+ ---
5
+
6
+ # /unadopt
7
+
8
+ Remove the claude-mem-lite invited-memory sentinel from the current project's
9
+ Claude Code memdir. Opposite of `/adopt`.
10
+
11
+ ## What it removes
12
+
13
+ 1. The `<!-- claude-mem-lite:begin vN --> ... <!-- claude-mem-lite:end -->`
14
+ block from `MEMORY.md` (plus the preceding `## 插件契约` header line it owns).
15
+ 2. `plugin_claude_mem_lite.md` detail file.
16
+ 3. `.plugin_claude_mem_lite_state.json` sidecar.
17
+
18
+ Everything else in `MEMORY.md` is preserved byte-for-byte.
19
+
20
+ ## Flags
21
+
22
+ - `--all` — unadopt every memdir under `~/.claude/projects/*/memory/`
23
+
24
+ ## Aftermath
25
+
26
+ Once unadopted, the conservative hook layer (SessionStart `File Lessons` /
27
+ `Key Context`, MCP instructions `WHEN TO USE`) goes back to verbose mode —
28
+ Claude Code will see the full injection again on the next session start.
29
+
30
+ !claude-mem-lite unadopt $ARGUMENTS
package/hook-context.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  debugLog, debugCatch,
9
9
  DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
10
10
  } from './utils.mjs';
11
- import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS } from './hook-shared.mjs';
11
+ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, effectiveQuiet } from './hook-shared.mjs';
12
12
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
13
13
 
14
14
  /**
@@ -315,18 +315,25 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
315
315
  keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
316
316
  }
317
317
 
318
- if (fileLessons.length > 0) {
318
+ // Phase A (QUIET_HOOKS) + Phase D (adopted sentinel): drop descriptive
319
+ // File Lessons / Key Context sections when the user has opted into low-noise
320
+ // hooks OR adopted invited-memory (MEMORY.md sentinel carries the triggers
321
+ // at higher system-prompt authority). The Recent table still fires so #IDs
322
+ // remain reachable via mem_get.
323
+ const quiet = effectiveQuiet();
324
+ if (fileLessons.length > 0 && !quiet) {
319
325
  summaryLines.push('### File Lessons');
320
326
  summaryLines.push(...fileLessons.slice(0, 5));
321
327
  summaryLines.push('');
322
328
  }
323
- if (keyContext.length > 0) {
329
+ if (keyContext.length > 0 && !quiet) {
324
330
  summaryLines.push('### Key Context');
325
331
  summaryLines.push(...keyContext.slice(0, 5));
326
332
  summaryLines.push('');
327
333
  }
328
- } else if (!latestSummary) {
329
- // Fallback: no summary AND no key observations — show recent activity
334
+ } else if (!latestSummary && !effectiveQuiet()) {
335
+ // Fallback: no summary AND no key observations — show recent activity.
336
+ // Skipped under QUIET_HOOKS since the Recent table already carries titles.
330
337
  const recentObs = (observations.length >= 3 ? observations : fallbackObs).slice(0, 3);
331
338
  if (recentObs.length > 0) {
332
339
  summaryLines.push('### Recent Activity');
package/hook-shared.mjs CHANGED
@@ -8,6 +8,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from '
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
9
  import { ensureDb, DB_DIR } from './schema.mjs';
10
10
  import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared } from './haiku-client.mjs';
11
+ // Phase D: invited-memory sentinel detection. memdir.mjs only pulls in fs/path/os/crypto;
12
+ // adopt-content.mjs is pure strings. No circular deps — memdir doesn't import hook-shared.
13
+ import { memdirPath as _memdirPath, isAdopted as _isAdopted } from './memdir.mjs';
14
+ import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
11
15
 
12
16
  // ─── Constants ────────────────────────────────────────────────────────────────
13
17
 
@@ -24,6 +28,33 @@ export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
24
28
  export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
25
29
  export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
26
30
 
31
+ // Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
32
+ // bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
33
+ // related-memory lesson suffix). Intended for users who adopted invited-memory
34
+ // (MEMORY.md sentinel) or who otherwise want minimal hook noise. Function form
35
+ // (not const) so modules importing at load time still respect later env sets
36
+ // in-process, and tests can toggle per-call. See docs/plans/2026-04-16-invited-memory-pattern.md.
37
+ export function isQuietHooks() {
38
+ return process.env.MEM_QUIET_HOOKS === '1';
39
+ }
40
+
41
+ // Phase D (v2.32.1+): if the current project has adopted our invited-memory
42
+ // sentinel, MEMORY.md already carries the triggers at system-prompt authority
43
+ // — so hook + MCP-instruction output can also go quiet. isQuietHooks (env)
44
+ // remains an independent, stronger override.
45
+ export function isAdoptedHere(cwd) {
46
+ try {
47
+ const resolved = cwd || process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
48
+ return _isAdopted(_memdirPath(resolved), _PLUGIN_SLUG);
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function effectiveQuiet(cwd) {
55
+ return isQuietHooks() || isAdoptedHere(cwd);
56
+ }
57
+
27
58
  // Handoff system constants
28
59
  export const HANDOFF_EXPIRY_CLEAR = 6 * 3600000; // 6 hours (covers lunch/meeting breaks)
29
60
  export const HANDOFF_EXPIRY_EXIT = 7 * 24 * 60 * 60 * 1000; // 7 days
package/hook-update.mjs CHANGED
@@ -196,6 +196,7 @@ const SOURCE_FILES = [
196
196
  'cli.mjs', 'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
197
197
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs', 'skip-tools.mjs',
198
198
  'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs',
199
+ 'hook-optimize.mjs', 'plugin-cache-guard.mjs',
199
200
  'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'package-lock.json', 'skill.md',
200
201
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
201
202
  'registry-retriever.mjs', 'resource-discovery.mjs',
@@ -289,6 +290,13 @@ export function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR) {
289
290
  // Post-update: prune old plugin cache versions (keep latest 3)
290
291
  try { prunePluginCache(); } catch (e) { debugCatch(e, 'prunePluginCache'); }
291
292
 
293
+ // Post-update: clear cache hooks.json in every remaining version. Claude Code
294
+ // runtime reads plugin hooks from cache, not marketplace source — leaving populated
295
+ // cache hooks.json alongside install.mjs-written settings.json causes double firing.
296
+ // Inline impl (no import of plugin-cache-guard.mjs — this module must run even when
297
+ // the guard module is absent on disk, e.g. auto-upgrading from pre-2.31.2).
298
+ try { clearCacheHookResidue(); } catch (e) { debugCatch(e, 'clearCacheHookResidue'); }
299
+
292
300
  debugLog('DEBUG', 'hook-update', `Auto-update: switched ${installed.length} paths`);
293
301
  return true;
294
302
  } catch (err) {
@@ -348,6 +356,33 @@ function copyReleaseIntoStaging(sourceDir, stagingDir) {
348
356
  debugLog('DEBUG', 'hook-update', `Auto-update staged ${copied} source files`);
349
357
  }
350
358
 
359
+ // ── Cache hook residue clearing ────────────────────────────
360
+ // Inline (does not import plugin-cache-guard.mjs) so hook-update.mjs keeps working
361
+ // even if plugin-cache-guard.mjs is missing on disk in degraded installs.
362
+ export function clearCacheHookResidue() {
363
+ const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
364
+ if (!existsSync(cacheBase)) return 0;
365
+ let cleared = 0;
366
+ for (const ver of readdirSync(cacheBase)) {
367
+ const p = join(cacheBase, ver, 'hooks', 'hooks.json');
368
+ if (!existsSync(p)) continue;
369
+ try {
370
+ const h = JSON.parse(readFileSync(p, 'utf8'));
371
+ if (!h.hooks || Object.keys(h.hooks).length === 0) continue;
372
+ writeFileSync(p, JSON.stringify({
373
+ description: h.description || 'claude-mem-lite hooks',
374
+ _note: `Auto-cleared by hook-update.mjs post-install — prevents double hook registration (cache ver: ${ver})`,
375
+ hooks: {},
376
+ }, null, 2) + '\n');
377
+ cleared++;
378
+ } catch { /* ignore single bad entry */ }
379
+ }
380
+ if (cleared > 0) {
381
+ debugLog('DEBUG', 'hook-update', `Cache hooks residue cleared in ${cleared} version(s)`);
382
+ }
383
+ return cleared;
384
+ }
385
+
351
386
  // ── Plugin Cache Pruning ──────────────────────────────────
352
387
  const PLUGIN_CACHE_KEEP = 3;
353
388
 
package/hook.mjs CHANGED
@@ -32,7 +32,16 @@ import { searchRelevantMemories } from './hook-memory.mjs';
32
32
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
33
33
  import { checkForUpdate } from './hook-update.mjs';
34
34
  import { handleLLMOptimize } from './hook-optimize.mjs';
35
- import { clearPluginCacheHooks, hasInstallManagedHooks } from './plugin-cache-guard.mjs';
35
+ // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
36
+ // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
37
+ // crash on static import. Degrade gracefully to no-op when the module is absent.
38
+ let _cacheGuardCache = null;
39
+ async function loadCacheGuard() {
40
+ if (_cacheGuardCache !== null) return _cacheGuardCache;
41
+ try { _cacheGuardCache = await import('./plugin-cache-guard.mjs'); }
42
+ catch { _cacheGuardCache = {}; }
43
+ return _cacheGuardCache;
44
+ }
36
45
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
37
46
  import { getVocabulary } from './tfidf.mjs';
38
47
 
@@ -402,9 +411,12 @@ async function handleSessionStart() {
402
411
  // re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
403
412
  // registration alongside install.mjs-managed settings.json entries. Silently
404
413
  // clear — gated by hasInstallManagedHooks to avoid breaking plugin-only users.
414
+ // Dynamic-import fallback: if plugin-cache-guard.mjs is missing (pre-2.31.2
415
+ // auto-upgrade install), skip self-heal instead of crashing the entire hook.
405
416
  try {
406
- if (hasInstallManagedHooks()) {
407
- const cleared = clearPluginCacheHooks({
417
+ const guard = await loadCacheGuard();
418
+ if (guard.hasInstallManagedHooks && guard.hasInstallManagedHooks()) {
419
+ const cleared = guard.clearPluginCacheHooks({
408
420
  reason: 'Auto-healed by hook.mjs session-start — install.mjs-managed hooks active in settings.json',
409
421
  });
410
422
  if (cleared.length > 0) {
package/install.mjs CHANGED
@@ -224,6 +224,10 @@ async function install() {
224
224
  'lib/plan-reader.mjs',
225
225
  'lib/git-state.mjs',
226
226
  'lib/startup-dashboard.mjs',
227
+ // v2.32 (invited-memory): memdir primitives + adopt/unadopt CLI.
228
+ 'memdir.mjs',
229
+ 'adopt-content.mjs',
230
+ 'adopt-cli.mjs',
227
231
  ];
228
232
 
229
233
  if (IS_DEV) {
@@ -807,6 +811,25 @@ async function install() {
807
811
  log('No existing database — will be created on first use');
808
812
  }
809
813
 
814
+ // 7b. Dogfood auto-adopt (invited-memory, Phase C T13).
815
+ // Only fires when install.mjs is running from the claude-mem-lite source repo
816
+ // itself (detected via git remote match). In npm/npx flows PROJECT_DIR is a
817
+ // cache dir with no git metadata, so this is a no-op for end users.
818
+ // --no-adopt override respected.
819
+ if (!flags.has('--no-adopt')) {
820
+ try {
821
+ const remote = execFileSync('git', ['-C', PROJECT_DIR, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: 'pipe' }).trim();
822
+ const isDogfood = /github\.com[:/]sdsrss\/claude-mem-lite(\.git)?$/i.test(remote);
823
+ if (isDogfood) {
824
+ const { cmdAdopt } = await import('./adopt-cli.mjs');
825
+ cmdAdopt([]);
826
+ ok('Invited-memory: auto-adopt for claude-mem-lite dogfood repo');
827
+ }
828
+ } catch {
829
+ // Not a git repo, or git missing — silent skip (this is the normal npm path).
830
+ }
831
+ }
832
+
810
833
  // 8. Disable old claude-mem plugin
811
834
  if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
812
835
  settings.enabledPlugins['claude-mem@thedotmack'] = false;
@@ -851,6 +874,10 @@ async function uninstall() {
851
874
  const settings = readSettings();
852
875
  cleanupMemHooksFromSettings(settings);
853
876
 
877
+ // 2b. Uninstall does NOT auto-unadopt — an adopted project may be in active use
878
+ // by the user in other Claude Code sessions. Tell them the command instead.
879
+ log('Invited-memory: adopt state preserved. Run `claude-mem-lite unadopt --all` to remove sentinel sections.');
880
+
854
881
  // 3. Clean plugin registry entries conservatively (avoid deleting other plugins
855
882
  // from the same marketplace publisher)
856
883
  const pluginsDir = join(homedir(), '.claude', 'plugins');
package/mem-cli.mjs CHANGED
@@ -14,6 +14,7 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
14
14
  import { searchResources } from './registry-retriever.mjs';
15
15
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
16
16
  import { buildSessionContextLines } from './hook-context.mjs';
17
+ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
17
18
  import { basename } from 'path';
18
19
  import { readFileSync } from 'fs';
19
20
 
@@ -2058,6 +2059,17 @@ Commands:
2058
2059
  --files (plural, comma-split) preferred; --file (singular) kept for back-compat.
2059
2060
  Use /lesson or /bug slash commands for faster capture (T8).
2060
2061
 
2062
+ adopt Inject claude-mem-lite sentinel line into this project's
2063
+ ~/.claude/projects/<encoded>/memory/MEMORY.md so Claude Code
2064
+ auto-loads it as user-memory (higher instruction authority).
2065
+ --all Adopt every project under ~/.claude/projects/*/memory/
2066
+ --force Overwrite a sentinel block that was manually edited
2067
+ --dry-run Print intended writes without touching disk
2068
+ --status List adopted projects + version
2069
+
2070
+ unadopt Precise removal of the sentinel block + plugin_claude_mem_lite.md.
2071
+ --all Unadopt every project
2072
+
2061
2073
  DB: ${DB_PATH}`);
2062
2074
  }
2063
2075
 
@@ -2336,6 +2348,11 @@ export async function run(argv) {
2336
2348
  return;
2337
2349
  }
2338
2350
 
2351
+ // adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
2352
+ // no DB needed. Route them before ensureDb() so an unbootable DB doesn't block.
2353
+ if (cmd === 'adopt') { cmdAdopt(cmdArgs); return; }
2354
+ if (cmd === 'unadopt') { cmdUnadopt(cmdArgs); return; }
2355
+
2339
2356
  let db;
2340
2357
  try {
2341
2358
  db = ensureDb();
package/memdir.mjs ADDED
@@ -0,0 +1,252 @@
1
+ // Phase B (Invited-Memory plan): memdir.mjs — primitives for the per-project
2
+ // Claude Code memdir at ~/.claude/projects/<encoded>/memory/.
3
+ //
4
+ // The public API lets a plugin install a single sentinel-wrapped line into
5
+ // MEMORY.md (auto-loaded by Claude Code into the system prompt) plus an
6
+ // on-demand `plugin_<slug>.md` detail file. Writes are idempotent, hash-guarded
7
+ // against manual user edits, and capped by a 180-line budget so the injection
8
+ // block never gets truncated by Claude Code's 200-line MEMORY.md cap.
9
+ //
10
+ // See docs/plans/2026-04-16-invited-memory-pattern.md for rationale.
11
+
12
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, mkdirSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { homedir } from 'os';
15
+ import { createHash } from 'crypto';
16
+
17
+ const MEMORY_LINE_BUDGET = 180;
18
+ const SECTION_HEADER = '## 插件契约';
19
+
20
+ export class UserEditedError extends Error {
21
+ constructor(message) { super(message); this.name = 'UserEditedError'; }
22
+ }
23
+
24
+ export class BudgetExceededError extends Error {
25
+ constructor(message) { super(message); this.name = 'BudgetExceededError'; }
26
+ }
27
+
28
+ // ─── Path helpers ────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Mirror Claude Code's project-path encoding: every non-alphanumeric char
32
+ * is replaced with "-". Lossy by design — the encoded path is a directory
33
+ * slug, not a reversible encoding. Ground truth fixture:
34
+ * /mnt/data_ssd/dev/projects/mem → -mnt-data-ssd-dev-projects-mem
35
+ * Memory ref: #7687 ("Claude Code mangles EVERY non-alphanumeric char").
36
+ */
37
+ export function encodeProjectPath(absPath) {
38
+ return String(absPath).replace(/[^a-zA-Z0-9]/g, '-');
39
+ }
40
+
41
+ /**
42
+ * Absolute path to the project's memdir. Caller runs mkdir-p as needed.
43
+ */
44
+ export function memdirPath(projectCwd) {
45
+ return join(homedir(), '.claude', 'projects', encodeProjectPath(projectCwd), 'memory');
46
+ }
47
+
48
+ function memoryFile(memdir) { return join(memdir, 'MEMORY.md'); }
49
+
50
+ function slugSnake(slug) {
51
+ return String(slug).replace(/[^a-zA-Z0-9]/g, '_');
52
+ }
53
+
54
+ function docFile(memdir, slug) {
55
+ return join(memdir, `plugin_${slugSnake(slug)}.md`);
56
+ }
57
+
58
+ function stateFile(memdir, slug) {
59
+ return join(memdir, `.plugin_${slugSnake(slug)}_state.json`);
60
+ }
61
+
62
+ // ─── Sentinel rendering & parsing ────────────────────────────────────────────
63
+
64
+ function sentinelRegex(slug) {
65
+ // Escape regex metacharacters in the slug. In practice plugin slugs are
66
+ // [a-z0-9-], but guard against '.', '+' etc. from arbitrary other plugins.
67
+ const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ return new RegExp(
69
+ `<!-- ${esc}:begin (v\\d+) -->\\s*\\n([\\s\\S]*?)<!-- ${esc}:end -->`,
70
+ );
71
+ }
72
+
73
+ function renderSentinel(slug, version, contentLine) {
74
+ return [
75
+ `<!-- ${slug}:begin ${version} -->`,
76
+ SECTION_HEADER,
77
+ contentLine,
78
+ `<!-- ${slug}:end -->`,
79
+ ].join('\n');
80
+ }
81
+
82
+ function canonicalBody(contentLine) {
83
+ // Must match what sentinelRegex captures in match[2]: everything between
84
+ // "begin X -->\n" and "<!-- X:end -->". That's SECTION_HEADER + '\n' +
85
+ // contentLine + '\n'.
86
+ return `${SECTION_HEADER}\n${contentLine}\n`;
87
+ }
88
+
89
+ function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
90
+
91
+ function atomicWrite(path, content) {
92
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
93
+ writeFileSync(tmp, content);
94
+ renameSync(tmp, path);
95
+ }
96
+
97
+ function readState(memdir, slug) {
98
+ const p = stateFile(memdir, slug);
99
+ if (!existsSync(p)) return null;
100
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
101
+ }
102
+
103
+ function writeState(memdir, slug, state) {
104
+ atomicWrite(stateFile(memdir, slug), JSON.stringify(state, null, 2) + '\n');
105
+ }
106
+
107
+ function clearState(memdir, slug) {
108
+ const p = stateFile(memdir, slug);
109
+ if (existsSync(p)) try { unlinkSync(p); } catch { /* best-effort */ }
110
+ }
111
+
112
+ // ─── Public API ──────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Parse MEMORY.md for the plugin's sentinel block.
116
+ * @returns {{ exists: boolean, raw: string, lineCount: number,
117
+ * section: string|null, body: string|null, version: string|null }}
118
+ */
119
+ export function readMemoryIndex(memdir, slug) {
120
+ const path = memoryFile(memdir);
121
+ if (!existsSync(path)) {
122
+ return { exists: false, raw: '', lineCount: 0, section: null, body: null, version: null };
123
+ }
124
+ const raw = readFileSync(path, 'utf8');
125
+ const m = raw.match(sentinelRegex(slug));
126
+ const lineCount = raw.length === 0 ? 0 : raw.split('\n').length;
127
+ if (!m) return { exists: true, raw, lineCount, section: null, body: null, version: null };
128
+ return { exists: true, raw, lineCount, section: m[0], body: m[2], version: m[1] };
129
+ }
130
+
131
+ /**
132
+ * Insert-or-update the plugin's sentinel block in MEMORY.md, writing a
133
+ * state sidecar alongside it.
134
+ *
135
+ * Idempotent: rewriting identical inputs is a no-op (returns action='unchanged').
136
+ *
137
+ * @param {string} memdir Absolute path to the memory directory.
138
+ * @param {object} opts
139
+ * @param {string} opts.slug Plugin slug (e.g. 'claude-mem-lite').
140
+ * @param {string} opts.version Contract version, e.g. 'v1'.
141
+ * @param {string} opts.contentLine Single-line index entry (≤150 chars).
142
+ * @param {boolean} [opts.force=false] Override UserEditedError.
143
+ * @returns {{action: 'created'|'updated'|'unchanged'}}
144
+ *
145
+ * @throws {BudgetExceededError} when MEMORY.md has > 180 lines AND we'd be
146
+ * adding a new section (updates to an existing sentinel are always allowed).
147
+ * @throws {UserEditedError} when the sentinel exists but its body hash doesn't
148
+ * match the last hash we wrote (detected via the state sidecar). Also thrown
149
+ * when a sentinel exists without any state file — treated as foreign content.
150
+ */
151
+ export function writePluginSection(memdir, { slug, version, contentLine, force = false }) {
152
+ if (!existsSync(memdir)) mkdirSync(memdir, { recursive: true });
153
+ const path = memoryFile(memdir);
154
+ const raw = existsSync(path) ? readFileSync(path, 'utf8') : '';
155
+ const match = raw.match(sentinelRegex(slug));
156
+
157
+ const freshSection = renderSentinel(slug, version, contentLine);
158
+ const freshHash = sha256(canonicalBody(contentLine));
159
+
160
+ if (!match) {
161
+ // Insert: enforce the 180-line budget so we never get truncated at 200.
162
+ const existingLines = raw.length === 0 ? 0 : raw.split('\n').length;
163
+ if (existingLines > MEMORY_LINE_BUDGET) {
164
+ throw new BudgetExceededError(
165
+ `MEMORY.md has ${existingLines} lines (> ${MEMORY_LINE_BUDGET}); refuse to add new sentinel section for ${slug}.`,
166
+ );
167
+ }
168
+ // Assemble with one blank line before our section for readability.
169
+ let next;
170
+ if (raw.length === 0) {
171
+ next = freshSection + '\n';
172
+ } else if (raw.endsWith('\n\n')) {
173
+ next = raw + freshSection + '\n';
174
+ } else if (raw.endsWith('\n')) {
175
+ next = raw + '\n' + freshSection + '\n';
176
+ } else {
177
+ next = raw + '\n\n' + freshSection + '\n';
178
+ }
179
+ atomicWrite(path, next);
180
+ writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
181
+ return { action: 'created' };
182
+ }
183
+
184
+ // Update path: hash-guard against user edits via state sidecar.
185
+ if (!force) {
186
+ const state = readState(memdir, slug);
187
+ if (!state) {
188
+ throw new UserEditedError(
189
+ `${slug} sentinel found without plugin state file — treating as foreign content. Pass force=true to re-adopt.`,
190
+ );
191
+ }
192
+ const currentHash = sha256(match[2]);
193
+ if (state.bodyHash !== currentHash) {
194
+ throw new UserEditedError(
195
+ `${slug} sentinel body was modified since last write (hash mismatch). Pass force=true to overwrite.`,
196
+ );
197
+ }
198
+ }
199
+
200
+ const next = raw.replace(match[0], freshSection);
201
+ const changed = next !== raw;
202
+ if (changed) atomicWrite(path, next);
203
+ writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
204
+ return { action: changed ? 'updated' : 'unchanged' };
205
+ }
206
+
207
+ /**
208
+ * Remove the plugin's sentinel block plus its state sidecar. External content
209
+ * in MEMORY.md is preserved.
210
+ * @returns {{action: 'removed'|'absent'}}
211
+ */
212
+ export function removePluginSection(memdir, slug) {
213
+ clearState(memdir, slug);
214
+ const path = memoryFile(memdir);
215
+ if (!existsSync(path)) return { action: 'absent' };
216
+ const raw = readFileSync(path, 'utf8');
217
+ const match = raw.match(sentinelRegex(slug));
218
+ if (!match) return { action: 'absent' };
219
+
220
+ // Delete the match plus a trailing newline + a preceding blank line so we
221
+ // don't leave a stranded paragraph gap.
222
+ let start = match.index;
223
+ let end = match.index + match[0].length;
224
+ if (raw[end] === '\n') end++;
225
+ if (start > 0 && raw.slice(0, start).endsWith('\n\n')) start--;
226
+ const next = raw.slice(0, start) + raw.slice(end);
227
+ atomicWrite(path, next);
228
+ return { action: 'removed' };
229
+ }
230
+
231
+ /**
232
+ * Whether this memdir has our sentinel. Body edits don't demote the adoption —
233
+ * users who hand-tweak the contract line still count as adopted.
234
+ */
235
+ export function isAdopted(memdir, slug) {
236
+ if (!existsSync(memdir)) return false;
237
+ const { section } = readMemoryIndex(memdir, slug);
238
+ return section !== null;
239
+ }
240
+
241
+ // ─── Plugin detail doc IO ────────────────────────────────────────────────────
242
+
243
+ export function writePluginDoc(memdir, slug, markdown) {
244
+ if (!existsSync(memdir)) mkdirSync(memdir, { recursive: true });
245
+ atomicWrite(docFile(memdir, slug), markdown);
246
+ }
247
+
248
+ export function removePluginDoc(memdir, slug) {
249
+ const path = docFile(memdir, slug);
250
+ if (!existsSync(path)) return;
251
+ try { unlinkSync(path); } catch { /* best-effort */ }
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.31.1",
3
+ "version": "2.32.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,6 +35,9 @@
35
35
  "hook-context.mjs",
36
36
  "hook-handoff.mjs",
37
37
  "hook-update.mjs",
38
+ "memdir.mjs",
39
+ "adopt-content.mjs",
40
+ "adopt-cli.mjs",
38
41
  "haiku-client.mjs",
39
42
  "registry.mjs",
40
43
  "registry-retriever.mjs",
@@ -66,6 +69,8 @@
66
69
  "commands/memory.md",
67
70
  "commands/update.md",
68
71
  "commands/tools.md",
72
+ "commands/adopt.md",
73
+ "commands/unadopt.md",
69
74
  "hooks/hooks.json",
70
75
  "scripts/launch.mjs",
71
76
  "scripts/setup.sh",
@@ -85,6 +90,9 @@
85
90
  "better-sqlite3": "^12.6.2",
86
91
  "zod": "^4.3.6"
87
92
  },
93
+ "overrides": {
94
+ "hono": ">=4.12.14"
95
+ },
88
96
  "devDependencies": {
89
97
  "@eslint/js": "^10.0.1",
90
98
  "@vitest/coverage-v8": "^4.0.18",
@@ -167,6 +167,10 @@ function readStdin() {
167
167
 
168
168
  // ─── Format Output ──────────────────────────────────────────────────────────
169
169
 
170
+ // Phase A (v2.31.3+): drop lesson suffix when MEM_QUIET_HOOKS=1; users on invited-memory
171
+ // path can mem_get the ID for full detail.
172
+ const QUIET_HOOKS = process.env.MEM_QUIET_HOOKS === '1';
173
+
170
174
  function formatResults(rows) {
171
175
  if (!rows || rows.length === 0) return null;
172
176
 
@@ -174,7 +178,7 @@ function formatResults(rows) {
174
178
  for (const r of rows) {
175
179
  const icon = typeIcon(r.type);
176
180
  const title = truncate(r.title || '', 70);
177
- const lesson = r.lesson_learned ? ` — ${truncate(r.lesson_learned, 50)}` : '';
181
+ const lesson = !QUIET_HOOKS && r.lesson_learned ? ` — ${truncate(r.lesson_learned, 50)}` : '';
178
182
  lines.push(`#${r.id} ${icon} ${title}${lesson}`);
179
183
  }
180
184
  return lines.join('\n');
@@ -5,6 +5,54 @@ import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from
5
5
  import { BASE_STOP_WORDS } from './stop-words.mjs';
6
6
  import { porterStem } from './tfidf.mjs';
7
7
 
8
+ // ─── MCP Server Instructions Builder ───────────────────────────────────────
9
+ // Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
10
+ // Decision-rules sections; keeps the irreducible CLI/MCP tool list. Intended
11
+ // for users who adopted invited-memory (MEMORY.md sentinel carries the same
12
+ // triggers at higher authority). Default false preserves v2.31.2 behavior.
13
+
14
+ const INSTRUCTIONS_BASE = [
15
+ 'Long-term memory across sessions. Hooks auto-inject context; CLI preferred for explicit queries.',
16
+ '',
17
+ 'CLI (via Bash):',
18
+ ' claude-mem-lite search "query" — FTS5 full-text search',
19
+ ' claude-mem-lite search "err" --type bugfix — filter by type',
20
+ ' claude-mem-lite recall "file.mjs" — file-related memories',
21
+ ' claude-mem-lite recent 5 — latest observations',
22
+ ' claude-mem-lite get 42,43 — full details by ID',
23
+ ' claude-mem-lite timeline --anchor 42 — chronological context',
24
+ '',
25
+ 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline for programmatic access.',
26
+ 'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
27
+ 'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
28
+ ];
29
+
30
+ const INSTRUCTIONS_VERBOSE = [
31
+ '',
32
+ 'WHEN TO USE (proactive triggers during coding):',
33
+ ' • About to Edit/Write a file → mem_recall(file="path") FIRST — past bugfixes & lessons',
34
+ ' • Test failure or error → mem_search(query="error keywords", obs_type="bugfix")',
35
+ ' • Before refactoring → mem_search(query="module-name", obs_type="refactor") for past decisions',
36
+ ' • Starting new feature → mem_search(query="feature area") for prior art & patterns',
37
+ ' • After fixing a tricky bug → mem_save(type="bugfix", lesson_learned="root cause & fix")',
38
+ ' • After architecture decision → mem_save(type="decision", lesson_learned="rationale")',
39
+ ' • Hook-injected context mentions #ID → mem_get(ids=[ID]) for full details',
40
+ '',
41
+ 'Decision rules (use INSTEAD OF multi-step search):',
42
+ ' • "what happened recently?" → mem_recent (NOT search with empty query)',
43
+ ' • "what do we know about file.mjs?" → mem_recall (NOT grep + manual search)',
44
+ ' • "show me around observation #42" → mem_timeline (NOT mem_get + manual navigation)',
45
+ ' • "clean up old/duplicate memories" → mem_maintain (NOT manual mem_delete loop)',
46
+ ' • "is the search index healthy?" → mem_fts_check (NOT manual COUNT queries)',
47
+ ' • "overview of memory tiers" → mem_browse (NOT mem_search + manual grouping)',
48
+ ' • "export for backup" → mem_export (NOT manual SELECT queries)',
49
+ ];
50
+
51
+ export function buildServerInstructions(quiet = false) {
52
+ if (quiet) return INSTRUCTIONS_BASE.join('\n');
53
+ return [...INSTRUCTIONS_BASE, ...INSTRUCTIONS_VERBOSE].join('\n');
54
+ }
55
+
8
56
  // ─── Search Re-ranking Helpers ────────────────────────────────────────────
9
57
 
10
58
  /**
package/server.mjs CHANGED
@@ -8,7 +8,8 @@ import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryT
8
8
  import { extractCjkLikePatterns } from './nlp.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
11
- import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup } from './server-internals.mjs';
11
+ import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
12
+ import { effectiveQuiet } from './hook-shared.mjs';
12
13
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
13
14
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
14
15
 
@@ -105,41 +106,7 @@ const RECENCY_HALF_LIFE_MS = DEFAULT_DECAY_HALF_LIFE_MS;
105
106
 
106
107
  const server = new McpServer(
107
108
  { name: 'claude-mem-lite', version: PKG_VERSION },
108
- {
109
- instructions: [
110
- 'Long-term memory across sessions. Hooks auto-inject context; CLI preferred for explicit queries.',
111
- '',
112
- 'CLI (via Bash):',
113
- ' claude-mem-lite search "query" — FTS5 full-text search',
114
- ' claude-mem-lite search "err" --type bugfix — filter by type',
115
- ' claude-mem-lite recall "file.mjs" — file-related memories',
116
- ' claude-mem-lite recent 5 — latest observations',
117
- ' claude-mem-lite get 42,43 — full details by ID',
118
- ' claude-mem-lite timeline --anchor 42 — chronological context',
119
- '',
120
- 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline for programmatic access.',
121
- 'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
122
- 'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
123
- '',
124
- 'WHEN TO USE (proactive triggers during coding):',
125
- ' • About to Edit/Write a file → mem_recall(file="path") FIRST — past bugfixes & lessons',
126
- ' • Test failure or error → mem_search(query="error keywords", obs_type="bugfix")',
127
- ' • Before refactoring → mem_search(query="module-name", obs_type="refactor") for past decisions',
128
- ' • Starting new feature → mem_search(query="feature area") for prior art & patterns',
129
- ' • After fixing a tricky bug → mem_save(type="bugfix", lesson_learned="root cause & fix")',
130
- ' • After architecture decision → mem_save(type="decision", lesson_learned="rationale")',
131
- ' • Hook-injected context mentions #ID → mem_get(ids=[ID]) for full details',
132
- '',
133
- 'Decision rules (use INSTEAD OF multi-step search):',
134
- ' • "what happened recently?" → mem_recent (NOT search with empty query)',
135
- ' • "what do we know about file.mjs?" → mem_recall (NOT grep + manual search)',
136
- ' • "show me around observation #42" → mem_timeline (NOT mem_get + manual navigation)',
137
- ' • "clean up old/duplicate memories" → mem_maintain (NOT manual mem_delete loop)',
138
- ' • "is the search index healthy?" → mem_fts_check (NOT manual COUNT queries)',
139
- ' • "overview of memory tiers" → mem_browse (NOT mem_search + manual grouping)',
140
- ' • "export for backup" → mem_export (NOT manual SELECT queries)',
141
- ].join('\n'),
142
- },
109
+ { instructions: buildServerInstructions(effectiveQuiet()) },
143
110
  );
144
111
 
145
112
  // Track MCP request activity for idle-time cleanup (see idle timer below)