claude-mem-lite 2.82.1 → 2.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.82.1",
13
+ "version": "2.83.0",
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.82.1",
3
+ "version": "2.83.0",
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
@@ -138,7 +138,7 @@ The original sends **everything to the LLM and hopes it filters well**. claude-m
138
138
 
139
139
  Plugin mode manages its own hooks/runtime. On session start it only **checks and reports** new claude-mem-lite versions; it does **not** self-overwrite plugin files in place. Update plugin-mode installs through Claude's plugin workflow.
140
140
 
141
- > **First session per project: run `/adopt` once.** Plugin install gives you the MCP server + hooks + slash commands, but the **invited-memory sentinel** (a system-authority pointer that boosts Claude's proactive use of `mem_recall` / `mem_save`) is opt-in and project-scoped. Without it the hooks still record observations and inject context, but Claude is far less likely to call the MCP tools on its own. One-time per project: `/adopt`. Remove with `/unadopt`.
141
+ > **Auto-adopt fires on the first SessionStart per project (v2.82.1+).** The plugin automatically writes the **invited-memory sentinel** (a system-authority pointer that boosts Claude's proactive use of `mem_recall` / `mem_save`) into the project's memdir **no manual `/adopt` needed**, regardless of install path (npm, npx, `/plugin`, manual). Per-project opt-out: `claude-mem-lite adopt --disable` (`--enable` to re-arm). Global opt-out: `export MEM_NO_AUTO_ADOPT=1`. Manual `/adopt` remains available for re-applying after edits and for the `--all` batch path.
142
142
 
143
143
  ### Method 2: npx (one-liner)
144
144
 
@@ -281,9 +281,11 @@ authority than MCP server instructions (which are framed as tool metadata).
281
281
  ```bash
282
282
  claude-mem-lite adopt # install for current project
283
283
  claude-mem-lite adopt --all # install for every project under ~/.claude/projects/
284
- claude-mem-lite adopt --status # list adopted projects + sentinel versions
284
+ claude-mem-lite adopt --status # list adopted/disabled projects + current gating snapshot
285
285
  claude-mem-lite adopt --dry-run # preview without writing
286
- claude-mem-lite unadopt # remove cleanly
286
+ claude-mem-lite adopt --disable # opt out of auto-adopt for current project (writes .mem-no-auto-adopt sentinel)
287
+ claude-mem-lite adopt --enable # re-arm auto-adopt for current project (deletes the sentinel)
288
+ claude-mem-lite unadopt # remove sentinel + doc (runtime marker stays to honor the explicit removal)
287
289
  ```
288
290
 
289
291
  Slash commands `/adopt` and `/unadopt` wrap the same CLI.
@@ -313,8 +315,12 @@ Slash commands `/adopt` and `/unadopt` wrap the same CLI.
313
315
  unless you pass `--force`.
314
316
  - Budget-gated: refuses to insert when MEMORY.md is already >180 lines, so
315
317
  Claude Code's 200-line MEMORY.md cap won't truncate the block.
316
- - `install` only auto-adopts when run from the claude-mem-lite source repo
317
- itself (detected via git remote match); other users must opt in explicitly.
318
+ - **Auto-adopt fires on the first SessionStart per project for any install
319
+ path (v2.82.1+).** Per-project opt-out: `claude-mem-lite adopt --disable`
320
+ (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker
321
+ deletion / plugin reinstalls). Global opt-out: `MEM_NO_AUTO_ADOPT=1`.
322
+ Pre-v2.82.1 the `CLAUDE_PLUGIN_ROOT` gate left auto-adopt unreachable for
323
+ every `install.mjs`-written hook (the common path) — see CHANGELOG v2.82.1.
318
324
  - The fallback hook layer is never removed from source — conditional trim is
319
325
  runtime-gated on sentinel presence, so projects without adoption get the
320
326
  full verbose output.
@@ -614,8 +620,9 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
614
620
  | `CLAUDE_MEM_DIR` | Custom data directory. All databases, runtime files, and managed resources are stored here. | `~/.claude-mem-lite/` |
615
621
  | `CLAUDE_MEM_MODEL` | LLM model for background calls (episode extraction, session summaries). Accepts `haiku` or `sonnet`. | `haiku` |
616
622
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
617
- | `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)_ |
618
- | `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)_ |
623
+ | `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. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
624
+ | `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)_ |
625
+ | `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)_ |
619
626
 
620
627
  ## License
621
628
 
package/README.zh-CN.md CHANGED
@@ -148,7 +148,7 @@ node install.mjs install
148
148
  1. **安装依赖** -- `npm install --omit=dev`(编译原生 `better-sqlite3`)
149
149
  2. **注册 MCP 服务器** -- `mem-lite` 服务器,包含 20 个工具(9 个核心通过 `tools/list` 暴露 + 11 个隐藏但可调;完整表见 Usage 段)。v2.78 前服务器名为通用的 `mem`,现已改名为 `mem-lite` 避免与用户其它 `.mcp.json` 冲突;工具名(`mem_search`/`mem_recall` 等)保持不变。
150
150
 
151
- > **每个项目第一次使用,跑一次 `/adopt`。** Plugin 安装给你 MCP server + hooks + slash commands,但**邀请式 memory 哨兵**(一条提升 Claude 主动调用 `mem_recall` / `mem_save` 的 system-authority 指针)是按项目 opt-in 的。不跑 `/adopt` hooks 仍记录观察、注入上下文,但 Claude 不太会主动调 MCP 工具。一次性、按项目:`/adopt`;撤销 `/unadopt`。
151
+ > **首次 SessionStart 自动 adopt(v2.82.1+)。** 插件会自动向该项目 memdir 写入**邀请式 memory 哨兵**(一条提升 Claude 主动调用 `mem_recall` / `mem_save` 的 system-authority 指针),**任何安装路径都生效**(npm、npx、`/plugin`、手动),**无需再手动跑 `/adopt`**。项目级关闭:`claude-mem-lite adopt --disable`(重新启用用 `--enable`)。全局关闭:`export MEM_NO_AUTO_ADOPT=1`。手动 `/adopt` 仍保留用于编辑后重写或 `--all` 批量场景。
152
152
  3. **配置钩子** -- `PostToolUse`、`PreToolUse`、`SessionStart`、`Stop`、`UserPromptSubmit` 生命周期钩子
153
153
  4. **创建数据目录** -- `~/.claude-mem-lite/`(隐藏目录),存放数据库、运行时和托管资源文件
154
154
  5. **自动迁移** -- 自动检测 `~/.claude-mem/`(原版 claude-mem)或 `~/claude-mem-lite/`(v0.5 前的非隐藏目录),将数据库和运行时文件迁移到 `~/.claude-mem-lite/`,原目录保持不变
@@ -263,9 +263,11 @@ instruction-following 权威。
263
263
  ```bash
264
264
  claude-mem-lite adopt # 当前项目注入
265
265
  claude-mem-lite adopt --all # 扫描 ~/.claude/projects/* 全部注入
266
- claude-mem-lite adopt --status # 列出所有已 adopt 的项目 + 版本号
266
+ claude-mem-lite adopt --status # 列出已 adopt / 已禁用项目 + 当前 gate 快照
267
267
  claude-mem-lite adopt --dry-run # 只打印不写入
268
- claude-mem-lite unadopt # 精确移除
268
+ claude-mem-lite adopt --disable # 当前项目关闭 auto-adopt(写 .mem-no-auto-adopt 哨兵)
269
+ claude-mem-lite adopt --enable # 当前项目重新启用 auto-adopt(删哨兵)
270
+ claude-mem-lite unadopt # 精确移除 sentinel + 详情文档(runtime marker 保留以尊重显式撤销)
269
271
  ```
270
272
 
271
273
  Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
@@ -291,8 +293,11 @@ Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
291
293
  - Hash 守护:你手动改了 sentinel 段 → 下一次 adopt 报 `UserEditedError`,
292
294
  除非显式 `--force`。
293
295
  - 预算门:MEMORY.md 已 >180 行时拒绝新增(避开 Claude Code 200 行截断)。
294
- - `install` 只在从 claude-mem-lite 源码仓库运行时 auto-adopt(靠 git remote 判别);
295
- 其它用户需显式调用。
296
+ - **任何安装路径首次 SessionStart 自动 adopt(v2.82.1+)。** 项目级关闭:
297
+ `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,
298
+ 存活于 marker 删除 / 插件重装)。全局关闭:`export MEM_NO_AUTO_ADOPT=1`。
299
+ v2.82.1 前因 `CLAUDE_PLUGIN_ROOT` gate 与 `install.mjs` 写出的 hook 命令
300
+ 不匹配,auto-adopt 实质 5 周零触发——见 CHANGELOG v2.82.1。
296
301
  - 保守 hook 层源码永不删——条件瘦身仅基于 sentinel 存在性做 runtime 判断,
297
302
  未 adopt 的项目仍看完整 verbose 输出。
298
303
 
@@ -575,8 +580,9 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
575
580
  | `CLAUDE_MEM_DIR` | 自定义数据目录。所有数据库、运行时文件和托管资源均存储在此。 | `~/.claude-mem-lite/` |
576
581
  | `CLAUDE_MEM_MODEL` | 后台 LLM 调用模型(Episode 提取、会话总结、调度)。可选 `haiku` 或 `sonnet`。 | `haiku` |
577
582
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
578
- | `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 流程或偏好最小化自动注入的用户。 | _(禁用)_ |
579
- | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。一旦运行 `adopt` 该提示自动消失;此 env 让偏好保守层的用户在不 adopt 的情况下也能静音。 | _(禁用)_ |
583
+ | `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 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
584
+ | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止首次 SessionStart 在**所有**项目自动写入邀请式 memory 哨兵。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
585
+ | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径首次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
580
586
 
581
587
  ## 许可证
582
588
 
package/hook-memory.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
4
  import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
5
+ import { citeFactorJs } from './scoring-sql.mjs';
5
6
  import { recordMetric } from './lib/metrics.mjs';
6
7
  import { DB_DIR } from './schema.mjs';
7
8
 
@@ -163,6 +164,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
163
164
  const selectStmt = db.prepare(`
164
165
  SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
165
166
  o.created_at_epoch, o.files_modified,
167
+ o.cited_count, o.uncited_streak,
166
168
  ${OBS_BM25} as relevance,
167
169
  ${noisePenaltyClause('o')} as noise_penalty
168
170
  FROM observations_fts
@@ -200,6 +202,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
200
202
  try {
201
203
  const crossStmt = db.prepare(`
202
204
  SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
205
+ o.cited_count, o.uncited_streak,
203
206
  ${OBS_BM25} as relevance,
204
207
  ${noisePenaltyClause('o')} as noise_penalty
205
208
  FROM observations_fts
@@ -241,6 +244,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
241
244
  const crossProjectPenalty = r.project === project ? 1.0 : crossPenalty;
242
245
  const orFallbackPenalty = r._or ? 0.4 : 1.0;
243
246
  const noisePenalty = typeof r.noise_penalty === 'number' ? r.noise_penalty : 1.0;
247
+ const citeFactor = citeFactorJs(r);
244
248
  return {
245
249
  ...r,
246
250
  score: Math.abs(r.relevance)
@@ -249,7 +253,8 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
249
253
  * (r.importance >= 2 ? 1.0 : 0.6)
250
254
  * crossProjectPenalty
251
255
  * orFallbackPenalty
252
- * noisePenalty,
256
+ * noisePenalty
257
+ * citeFactor,
253
258
  };
254
259
  })
255
260
  .sort((a, b) => b.score - a.score);
package/hook.mjs CHANGED
@@ -61,7 +61,7 @@ import { checkForUpdate } 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 } from './lib/cite-back-hint.mjs';
64
+ import { loadCiteBackForEpisode, buildUnsavedBugfixHint } 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.
@@ -181,8 +181,6 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
181
181
  if (RECEIPT_EVENTS.has(hookEventName)) {
182
182
  try {
183
183
  const entries = episode.entries || [];
184
- const hasError = entries.some(e => e.isError);
185
- const hasEdit = entries.some(e => EDIT_TOOLS.has(e.tool));
186
184
  const toolCounts = {};
187
185
  for (const e of entries) toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
188
186
  const toolSummary = Object.entries(toolCounts)
@@ -191,16 +189,14 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
191
189
  .map(([t, n]) => `${t}×${n}`)
192
190
  .join(', ');
193
191
  const lines = [`[mem] episode flushed: ${entries.length} entries (${toolSummary})`];
194
- if (hasError && hasEdit && entries.length >= 3) {
195
- const editFiles = entries.filter(e => EDIT_TOOLS.has(e.tool)).flatMap(e => e.files || []);
196
- const uniqueFiles = [...new Set(editFiles)].slice(0, 3);
197
- const filesHint = uniqueFiles.length > 0 ? ` (${uniqueFiles.join(', ')})` : '';
198
- lines.push(`[mem] 💡 error→fix pattern${filesHint} — consider: mem_save(type="bugfix", lesson_learned="<root cause + fix>")`);
199
- }
192
+ // v2.83: error→fix nudge lifted to lib/cite-back-hint.mjs::buildUnsavedBugfixHint
193
+ // so the wording (count + "Save now" verb) stays in sync with cite-back.
194
+ const bugfixHint = buildUnsavedBugfixHint(episode);
195
+ if (bugfixHint) lines.push(bugfixHint);
200
196
  // v2.81: cite-back hint — fires when this episode edits a file that
201
197
  // PreToolUse:Read/Edit nudged earlier in the same session. Precision
202
198
  // signal (we know the file was warned about); orthogonal to the
203
- // error→fix pattern above and may co-fire.
199
+ // bugfix-shape nudge above and may co-fire.
204
200
  const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
205
201
  if (citeBack) lines.push(citeBack);
206
202
  process.stdout.write(JSON.stringify({
@@ -41,15 +41,60 @@ export function buildCiteBackHint(episode, cooldown) {
41
41
 
42
42
  if (matches.length === 0) return null;
43
43
 
44
- const lines = ['[mem] 💡 Cite-back: you edited file(s) that received PreToolUse lessons this session.'];
44
+ // B1 (v2.83): leader line carries explicit counts (file count + total lesson
45
+ // count) and a directive verb. Pre-v2.83 wording "if you fixed it" was
46
+ // routinely treated as advisory and ignored — cite-recall data showed the
47
+ // hint firing without follow-up `/lesson` calls. §10 Specificity binds:
48
+ // numeric framing is measurably harder to dismiss than a hedged hint.
49
+ const totalLessons = matches.reduce((sum, m) => sum + m.ids.length, 0);
50
+ const lines = [
51
+ `[mem] ⚠ Cite-back: edited ${matches.length} file(s) with ${totalLessons} prior lesson(s) this session. Save now if any was the root cause:`,
52
+ ];
45
53
  for (const m of matches) {
46
54
  const fname = basename(m.file);
47
55
  const idList = m.ids.map(id => `#${id}`).join(', ');
48
- lines.push(` • ${fname} ← ${idList} — if you fixed it: /lesson --file ${fname} "<root cause + fix>"`);
56
+ lines.push(` • ${fname} ← ${idList} — /lesson --file ${fname} "<root cause + fix>"`);
49
57
  }
50
58
  return lines.join('\n');
51
59
  }
52
60
 
61
+ // B1 (v2.83): structured per-episode "tricky fix just happened" detector. Lifts
62
+ // the inline error→fix nudge that used to live in hook.mjs flushEpisode into
63
+ // the lib so all save-prompt hints share one home + the same wording rules.
64
+ //
65
+ // Detection (mirrors pre-v2.83 hook.mjs:194 heuristic):
66
+ // • has at least one entry with isError=true
67
+ // • has at least one entry using an edit tool
68
+ // • entries.length >= 3 (rules out single-typo fixes that don't need a lesson)
69
+ // Returns null when any condition fails or when no edited files are recoverable
70
+ // from the entry list (defensive — episodes flushed mid-tool can have empties).
71
+ const MIN_BUGFIX_ENTRIES = 3;
72
+ const MAX_DISPLAY_FILES = 3;
73
+
74
+ export function buildUnsavedBugfixHint(episode) {
75
+ if (!episode) return null;
76
+ const entries = episode.entries;
77
+ if (!Array.isArray(entries) || entries.length < MIN_BUGFIX_ENTRIES) return null;
78
+
79
+ let hasError = false;
80
+ let hasEdit = false;
81
+ const editedFiles = new Set();
82
+ for (const e of entries) {
83
+ if (!e) continue;
84
+ if (e.isError) hasError = true;
85
+ if (EDIT_TOOLS.has(e.tool)) {
86
+ hasEdit = true;
87
+ for (const f of e.files || []) editedFiles.add(f);
88
+ }
89
+ }
90
+ if (!hasError || !hasEdit || editedFiles.size === 0) return null;
91
+
92
+ const files = [...editedFiles];
93
+ const displayed = files.slice(0, MAX_DISPLAY_FILES).map(f => basename(f));
94
+ const firstFname = basename(files[0]);
95
+ return `[mem] ⚠ Unsaved bugfix-shape: error+edit across ${files.length} file(s) in ${entries.length} entries (${displayed.join(', ')}). Save now if it was a real fix: /lesson --file ${firstFname} "<root cause + fix>"`;
96
+ }
97
+
53
98
  // Path scheme MUST mirror scripts/pre-tool-recall.js cooldownPathFor() — drift
54
99
  // silently zeros cite-back across the release. Pinned by tests/cite-back-hint.test.mjs
55
100
  // 'sanitizes the sessionId the same way pre-tool-recall.js does'.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.82.1",
3
+ "version": "2.83.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/scoring-sql.mjs CHANGED
@@ -126,3 +126,52 @@ export function noisePenaltyClause(alias = 'o') {
126
126
  export function notLowSignalTitleClause(alias = 'o') {
127
127
  return buildNotLowSignalSql(alias);
128
128
  }
129
+
130
+ // ─── Cite-history factor (A1, v2.83) ────────────────────────────────────────
131
+ //
132
+ // Closes the citation-decay → ranking loop. The Stop hook citation-decay
133
+ // already maintains cited_count (Promote on cite) and uncited_streak (bump on
134
+ // uncited; reset on cite or demote-at-3). Before A1, both columns affected
135
+ // only the importance ±1 dial — through `(0.5 + 0.5·importance)` that's a
136
+ // ≤2× swing and saturates fast. This factor lets ranking respond directly to
137
+ // observed agent behavior on the obs itself.
138
+ //
139
+ // Formula: clamp(0.4, 3.0, 1 + 0.2·cited_count − 0.25·uncited_streak)
140
+ // Distribution:
141
+ // cited=0, streak=0 → 1.0 (fresh obs, neutral)
142
+ // cited=5, streak=0 → 2.0
143
+ // cited≥10, streak=0 → 3.0 (capped — one viral obs can't dominate)
144
+ // cited=0, streak=2 → 0.5
145
+ // cited=0, streak=3+ → 0.4 (floored; citation-decay resets streak at 3
146
+ // after demoting importance, so steady-state
147
+ // streak is bounded by [0,2])
148
+ //
149
+ // Disjoint from noisePenaltyClause: noise penalty uses
150
+ // `injection_count vs access_count` (passive inject vs any access);
151
+ // cite_factor uses `cited_count vs uncited_streak` — same-source signal
152
+ // maintained only by the citation-decay loop. Both apply multiplicatively;
153
+ // order doesn't affect ORDER BY relevance ASC.
154
+ export const CITE_FACTOR_MIN = 0.4;
155
+ export const CITE_FACTOR_MAX = 3.0;
156
+ export const CITE_FACTOR_PER_CITE = 0.2;
157
+ export const CITE_FACTOR_PER_STREAK = 0.25;
158
+
159
+ export function citeFactorClause(alias = 'o') {
160
+ const a = alias ? `${alias}.` : '';
161
+ return `(
162
+ MAX(${CITE_FACTOR_MIN},
163
+ MIN(${CITE_FACTOR_MAX},
164
+ 1.0
165
+ + ${CITE_FACTOR_PER_CITE} * COALESCE(${a}cited_count, 0)
166
+ - ${CITE_FACTOR_PER_STREAK} * COALESCE(${a}uncited_streak, 0)
167
+ )
168
+ )
169
+ )`;
170
+ }
171
+
172
+ export function citeFactorJs(row) {
173
+ const cited = (row && typeof row.cited_count === 'number') ? row.cited_count : 0;
174
+ const streak = (row && typeof row.uncited_streak === 'number') ? row.uncited_streak : 0;
175
+ const raw = 1.0 + CITE_FACTOR_PER_CITE * cited - CITE_FACTOR_PER_STREAK * streak;
176
+ return Math.max(CITE_FACTOR_MIN, Math.min(CITE_FACTOR_MAX, raw));
177
+ }
@@ -16,6 +16,12 @@ import { recordHookError } from '../lib/hook-telemetry.mjs';
16
16
  const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
17
17
  const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(DATA_DIR, 'claude-mem-lite.db');
18
18
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
19
+ // A3 (v2.83): cross-hook dedup window — must mirror DEDUP_STALE_MS in
20
+ // scripts/prompt-search-utils.mjs. UPS writes
21
+ // `runtime/.claude-mem-injected-<project>` after each inject; we read it to
22
+ // drop IDs the agent already saw in this 5-min window. Standalone fast-path
23
+ // (#8447) so we inline the constant rather than importing the helper.
24
+ const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
19
25
  // v2.33.1: cooldown path is session-scoped so same-file-twice within one
20
26
  // session never re-injects (was: global file, 5-min window). Cross-session:
21
27
  // fresh file, fresh nudges — this is intended. No session_id → fall back to
@@ -58,6 +64,50 @@ function entryTimestamp(v) {
58
64
  return 0;
59
65
  }
60
66
 
67
+ // A3 (v2.83): cross-hook injected-IDs store. UPS writes
68
+ // `runtime/.claude-mem-injected-<project>` with {ids, ts, count}. We read
69
+ // inside the staleness window, filter overlaps from PreToolUse output, then
70
+ // merge back so the next UPS sees what we emitted too.
71
+ function crossHookInjectedFile(project) {
72
+ return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
73
+ }
74
+
75
+ function readCrossHookInjected(project) {
76
+ try {
77
+ const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
78
+ const { ids, ts } = JSON.parse(raw);
79
+ if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
80
+ if (!Array.isArray(ids)) return new Set();
81
+ return new Set(ids.map(String));
82
+ } catch { return new Set(); }
83
+ }
84
+
85
+ function mergeCrossHookInjected(project, newIds) {
86
+ if (!newIds || newIds.length === 0) return;
87
+ try {
88
+ mkdirSync(RUNTIME_DIR, { recursive: true });
89
+ const file = crossHookInjectedFile(project);
90
+ let prev = { ids: [], ts: 0, count: 0 };
91
+ try {
92
+ const raw = readFileSync(file, 'utf8');
93
+ const parsed = JSON.parse(raw);
94
+ // Within the staleness window: union. Outside: replace (fresh session).
95
+ if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS) {
96
+ prev = parsed;
97
+ }
98
+ } catch { /* fresh file */ }
99
+ const ids = [...new Set([
100
+ ...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
101
+ ...newIds.map(String),
102
+ ])];
103
+ writeFileSync(file, JSON.stringify({
104
+ ids,
105
+ ts: Date.now(),
106
+ count: (prev.count || 0) + 1,
107
+ }));
108
+ } catch { /* silent — dedup is best-effort */ }
109
+ }
110
+
61
111
  function writeCooldown(cooldownPath, data, isSessionScoped) {
62
112
  try {
63
113
  mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -228,10 +278,20 @@ try {
228
278
  `).all(project, cutoff, `%"${fnameEscaped}"%`, `%"${filePathEscaped}"%`);
229
279
  } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
230
280
 
281
+ // A3 (v2.83): cross-hook dedup. UPS may have already injected some of
282
+ // these obs ids this prompt — re-emitting wastes the PreToolUse slot
283
+ // (and inflates context). Drop ids found in the cross-hook injected file
284
+ // inside the staleness window; keep file-cooldown unchanged (the same
285
+ // file might also re-warrant a different lesson next session).
286
+ const crossHookSeen = readCrossHookInjected(project);
287
+ const dedupedRows = crossHookSeen.size > 0
288
+ ? [...rows, ...eventRows].filter(r => !crossHookSeen.has(String(r.id)))
289
+ : [...rows, ...eventRows];
290
+
231
291
  // Merge: observations first (they carry richer lesson_learned), then events.
232
292
  // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
233
293
  const mergeCap = isRead ? 1 : 3;
234
- const allRows = [...rows, ...eventRows].slice(0, mergeCap);
294
+ const allRows = dedupedRows.slice(0, mergeCap);
235
295
 
236
296
  // v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
237
297
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
@@ -291,6 +351,11 @@ try {
291
351
  // file. Empty array on no-lesson branches keeps the schema uniform.
292
352
  cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id) };
293
353
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
354
+ // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
355
+ // file so the next UPS prompt skips them too. Always write, even on
356
+ // empty allRows, so the file's ts stays fresh for the no-op case where
357
+ // we'd otherwise drift outside the dedup window.
358
+ mergeCrossHookInjected(project, allRows.map(r => r.id));
294
359
  } catch (e) {
295
360
  // Silent failure — never block editing, but record for self-observation.
296
361
  recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
7
  import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate } from '../utils.mjs';
8
+ import { citeFactorClause } from '../scoring-sql.mjs';
8
9
  import { cjkPrecisionOk } from '../nlp.mjs';
9
10
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
10
11
  import { join } from 'path';
@@ -216,6 +217,11 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
216
217
  // v26 P0: noise penalty shrinks relevance magnitude for obs with high
217
218
  // inject:access ratio (auto-injected often, never cited/opened). See
218
219
  // docs/p0-injection-noise-baseline.txt.
220
+ // A1 (v2.83): cite_factor closes the citation-decay → ranking loop. Obs the
221
+ // assistant cited in past sessions (cited_count > 0) get boosted; obs with
222
+ // accumulating uncited_streak get dampened upstream of importance-decay.
223
+ // Disjoint signal from noise_penalty (which uses injection_count vs
224
+ // access_count) — see scoring-sql.mjs::citeFactorClause for the math.
219
225
  const sql = `
220
226
  SELECT o.id, o.type, o.title, o.lesson_learned,
221
227
  ${OBS_BM25} as bm25_raw,
@@ -223,7 +229,8 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
223
229
  * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
224
230
  * ${TYPE_QUALITY_CASE}
225
231
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
226
- * ${noisePenaltyClause('o')} as relevance
232
+ * ${noisePenaltyClause('o')}
233
+ * ${citeFactorClause('o')} as relevance
227
234
  FROM observations_fts
228
235
  JOIN observations o ON o.id = observations_fts.rowid
229
236
  WHERE observations_fts MATCH ?