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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -7
- package/README.zh-CN.md +13 -7
- package/hook-memory.mjs +6 -1
- package/hook.mjs +6 -10
- package/lib/cite-back-hint.mjs +47 -2
- package/package.json +1 -1
- package/scoring-sql.mjs +49 -0
- package/scripts/pre-tool-recall.js +66 -1
- package/scripts/user-prompt-search.js +8 -1
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
|
-
> **
|
|
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 +
|
|
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
|
|
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
|
-
-
|
|
317
|
-
|
|
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
|
-
| `
|
|
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
|
-
>
|
|
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 #
|
|
266
|
+
claude-mem-lite adopt --status # 列出已 adopt / 已禁用项目 + 当前 gate 快照
|
|
267
267
|
claude-mem-lite adopt --dry-run # 只打印不写入
|
|
268
|
-
claude-mem-lite
|
|
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
|
-
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
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({
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -41,15 +41,60 @@ export function buildCiteBackHint(episode, cooldown) {
|
|
|
41
41
|
|
|
42
42
|
if (matches.length === 0) return null;
|
|
43
43
|
|
|
44
|
-
|
|
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} —
|
|
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
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 =
|
|
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')}
|
|
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 ?
|