claude-mem-lite 2.88.0 → 2.89.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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +11 -9
- package/README.zh-CN.md +8 -8
- package/haiku-client.mjs +20 -10
- package/hook-llm.mjs +4 -3
- package/hook-optimize.mjs +7 -3
- package/hook.mjs +10 -4
- package/lib/citation-tracker.mjs +61 -1
- package/lib/cite-back-hint.mjs +39 -1
- package/lib/compress-core.mjs +24 -4
- package/lib/dedup-constants.mjs +35 -0
- package/lib/maintain-core.mjs +5 -2
- package/lib/save-observation.mjs +1 -1
- package/mem-cli.mjs +1 -1
- package/package.json +3 -2
- package/schema.mjs +25 -2
- package/search-engine.mjs +2 -1
- package/server.mjs +1 -1
- package/source-files.mjs +5 -0
- package/tfidf.mjs +12 -8
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.89.0",
|
|
14
14
|
"source": "./",
|
|
15
|
-
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services.
|
|
15
|
+
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
17
17
|
]
|
|
18
18
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services.
|
|
3
|
+
"version": "2.89.0",
|
|
4
|
+
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
|
7
7
|
},
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
`claude-mem-lite` is a **persistent memory** (also called *long-term memory* or *cross-session context*) system for **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic's CLI coding agent. It runs as an **[MCP](https://modelcontextprotocol.io/) server** plus a set of Claude Code hooks, automatically capturing coding observations, decisions, and bug fixes during sessions, then providing hybrid full-text + semantic search to recall them later.
|
|
6
6
|
|
|
7
|
-
Compared to general-purpose LLM memory frameworks like [`mem0`](https://github.com/mem0ai/mem0) or the MCP reference [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) server, claude-mem-lite is purpose-built for Claude Code's hook lifecycle: episode batching cuts LLM calls 7–10× vs the original [claude-mem](https://github.com/thedotmack/claude-mem) (600× lower total cost),
|
|
7
|
+
Compared to general-purpose LLM memory frameworks like [`mem0`](https://github.com/mem0ai/mem0) or the MCP reference [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) server, claude-mem-lite is purpose-built for Claude Code's hook lifecycle: episode batching cuts LLM calls 7–10× vs the original [claude-mem](https://github.com/thedotmack/claude-mem) (an estimated ~600× lower total cost — see the cost model below; this is an architecture estimate, not a measured benchmark), while the hybrid FTS5 + TF-IDF retriever benchmarks at 0.88 Recall@10 / 0.96 Precision@10.
|
|
8
8
|
|
|
9
9
|
> 中文简介:claude-mem-lite 是 Claude Code 的轻量级**持久化记忆 / 长期记忆 / 跨会话上下文**插件,基于 MCP 协议 + 钩子机制,自动捕获编码会话中的决策、修复和上下文,并通过 FTS5 + TF-IDF 混合检索召回。详见 [中文 README](README.zh-CN.md)。
|
|
10
10
|
|
|
@@ -29,15 +29,17 @@ A ground-up redesign of [claude-mem](https://github.com/thedotmack/claude-mem),
|
|
|
29
29
|
|
|
30
30
|
### Token & cost efficiency
|
|
31
31
|
|
|
32
|
-
For a typical 50-tool-call session
|
|
32
|
+
For a typical 50-tool-call session (illustrative cost model — the ratios below are
|
|
33
|
+
architecture estimates derived from batch size, token counts, and model pricing, **not**
|
|
34
|
+
a measured end-to-end benchmark):
|
|
33
35
|
|
|
34
|
-
| | claude-mem | claude-mem-lite | Ratio |
|
|
36
|
+
| | claude-mem | claude-mem-lite | Ratio (estimated) |
|
|
35
37
|
|---|---|---|---|
|
|
36
|
-
| LLM calls | ~50 (every tool use) | ~5-8 (per episode) |
|
|
37
|
-
| Tokens per call | 1,000-5,000 (raw JSON + history) | 200-500 (summaries only) |
|
|
38
|
-
| Total tokens | ~100K-250K | ~1K-4K |
|
|
39
|
-
| Model cost | Sonnet ($3/$15 per M) | Haiku ($0.25/$1.25 per M) |
|
|
40
|
-
| Combined savings | | |
|
|
38
|
+
| LLM calls | ~50 (every tool use) | ~5-8 (per episode) | **~7-10x fewer** |
|
|
39
|
+
| Tokens per call | 1,000-5,000 (raw JSON + history) | 200-500 (summaries only) | **~5-10x smaller** |
|
|
40
|
+
| Total tokens | ~100K-250K | ~1K-4K | **~50-100x less** |
|
|
41
|
+
| Model cost | Sonnet ($3/$15 per M) | Haiku ($0.25/$1.25 per M) | **~12x cheaper** |
|
|
42
|
+
| Combined savings | | | **~600x lower cost (estimated)** |
|
|
41
43
|
|
|
42
44
|
### Quality comparison
|
|
43
45
|
|
|
@@ -681,7 +683,7 @@ No. Claude Code's `CLAUDE.md` and `MEMORY.md` files act as static instruction me
|
|
|
681
683
|
|
|
682
684
|
### Why "lite"? What did the original claude-mem do differently?
|
|
683
685
|
|
|
684
|
-
The original called an LLM on every tool use with raw JSON inputs. claude-mem-lite batches 5–10 operations per LLM call, uses a smaller model (Haiku), and runs a deterministic code-level filter before sending anything to the model. Net result: ~600× lower cost with equivalent search quality. See the [Architecture comparison](#architecture-comparison) above.
|
|
686
|
+
The original called an LLM on every tool use with raw JSON inputs. claude-mem-lite batches 5–10 operations per LLM call, uses a smaller model (Haiku), and runs a deterministic code-level filter before sending anything to the model. Net result: an estimated ~600× lower cost (an architecture estimate from the cost model above, not a measured benchmark) with equivalent search quality. See the [Architecture comparison](#architecture-comparison) above.
|
|
685
687
|
|
|
686
688
|
### Does this work cross-project? Cross-machine?
|
|
687
689
|
|
package/README.zh-CN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
`claude-mem-lite` 是 **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**(Anthropic 官方 CLI 编程代理)的 **持久化记忆系统**(也称 **长期记忆 / 跨会话上下文 / Claude Code 记忆插件**)。它以 **[MCP](https://modelcontextprotocol.io/) 服务器** + Claude Code 钩子(hooks)的形式运行,在编码会话中自动捕获观察记录、决策、bug 修复,并通过 FTS5 全文检索 + TF-IDF 向量的混合检索召回历史上下文。
|
|
6
6
|
|
|
7
|
-
与 [`mem0`](https://github.com/mem0ai/mem0)、MCP 官方参考实现的 [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) 服务器等通用 LLM 记忆框架相比,claude-mem-lite 专为 Claude Code 的钩子生命周期定制:episode 批处理把 LLM 调用量相比原版 [claude-mem](https://github.com/thedotmack/claude-mem) 减少 7-10
|
|
7
|
+
与 [`mem0`](https://github.com/mem0ai/mem0)、MCP 官方参考实现的 [`memory`](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) 服务器等通用 LLM 记忆框架相比,claude-mem-lite 专为 Claude Code 的钩子生命周期定制:episode 批处理把 LLM 调用量相比原版 [claude-mem](https://github.com/thedotmack/claude-mem) 减少 7-10 倍(综合成本估算下降约 600 倍 —— 见下方成本模型,属架构估算而非实测基准);FTS5 + TF-IDF 混合检索在 30 个查询的基准上达到 **Recall@10 = 0.88 / Precision@10 = 0.96**。
|
|
8
8
|
|
|
9
9
|
无需外部服务。单一 SQLite 数据库。开销极低。
|
|
10
10
|
|
|
@@ -27,15 +27,15 @@
|
|
|
27
27
|
|
|
28
28
|
### Token 与成本效率
|
|
29
29
|
|
|
30
|
-
以典型的 50
|
|
30
|
+
以典型的 50 次工具调用的会话为例(成本模型示意 —— 下列比率由批大小、token 量与模型定价**估算**得出,并非端到端实测):
|
|
31
31
|
|
|
32
|
-
| | claude-mem | claude-mem-lite |
|
|
32
|
+
| | claude-mem | claude-mem-lite | 比率(估算) |
|
|
33
33
|
|---|---|---|---|
|
|
34
|
-
| LLM 调用次数 | ~50(每次工具使用) | ~5-8(按 episode) |
|
|
35
|
-
| 每次调用 token | 1,000-5,000(原始 JSON + 历史) | 200-500(仅摘要) |
|
|
36
|
-
| 总 token 量 | ~100K-250K | ~1K-4K |
|
|
37
|
-
| 模型成本 | Sonnet ($3/$15 每百万) | Haiku ($0.25/$1.25 每百万) |
|
|
38
|
-
| 综合节省 | | |
|
|
34
|
+
| LLM 调用次数 | ~50(每次工具使用) | ~5-8(按 episode) | **约减少 7-10 倍** |
|
|
35
|
+
| 每次调用 token | 1,000-5,000(原始 JSON + 历史) | 200-500(仅摘要) | **约减少 5-10 倍** |
|
|
36
|
+
| 总 token 量 | ~100K-250K | ~1K-4K | **约减少 50-100 倍** |
|
|
37
|
+
| 模型成本 | Sonnet ($3/$15 每百万) | Haiku ($0.25/$1.25 每百万) | **约便宜 12 倍** |
|
|
38
|
+
| 综合节省 | | | **成本降低约 600 倍(估算)** |
|
|
39
39
|
|
|
40
40
|
### 质量对比
|
|
41
41
|
|
package/haiku-client.mjs
CHANGED
|
@@ -20,6 +20,14 @@ const MODEL_MAP = {
|
|
|
20
20
|
sonnet: 'claude-sonnet-4-5-20250929',
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
// Every background LLM call here is fixed-schema extraction / classification
|
|
24
|
+
// (episode→JSON, type/merge classification, synonym + metadata extraction) whose
|
|
25
|
+
// output is consumed deterministically (JSON.parse, MinHash dedup). Pin temperature
|
|
26
|
+
// to 0 so the provider default (~1.0) doesn't inject wording variance that breaks
|
|
27
|
+
// JSON parsing or defeats the wording-sensitive MinHash near-duplicate detector.
|
|
28
|
+
// A call that genuinely needs sampling can pass opts.temperature to override.
|
|
29
|
+
const DEFAULT_LLM_TEMPERATURE = 0;
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* Resolve the LLM model to use for background calls.
|
|
25
33
|
* Reads CLAUDE_MEM_MODEL env var, defaults to 'haiku'.
|
|
@@ -143,7 +151,7 @@ export function flattenForCLI(input) {
|
|
|
143
151
|
* @param {number} [opts.maxTokens=500] Max tokens in response
|
|
144
152
|
* @returns {Promise<{text: string}|null>} Response or null on failure
|
|
145
153
|
*/
|
|
146
|
-
export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500 } = {}) {
|
|
154
|
+
export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
|
|
147
155
|
if (!prompt) return null;
|
|
148
156
|
|
|
149
157
|
const mode = detectMode();
|
|
@@ -160,8 +168,8 @@ export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500 } = {
|
|
|
160
168
|
let primary = null;
|
|
161
169
|
try {
|
|
162
170
|
primary = mode === 'api'
|
|
163
|
-
? await callHaikuAPI(prompt, { timeout, maxTokens })
|
|
164
|
-
: await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens });
|
|
171
|
+
? await callHaikuAPI(prompt, { timeout, maxTokens, temperature })
|
|
172
|
+
: await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
|
|
165
173
|
} catch (e) {
|
|
166
174
|
debugCatch(e, `callHaiku:${mode}`);
|
|
167
175
|
}
|
|
@@ -198,7 +206,7 @@ export async function callHaikuJSON(prompt, opts) {
|
|
|
198
206
|
* @param {number} [opts.maxTokens=1000] Max tokens in response
|
|
199
207
|
* @returns {Promise<{text: string}|null>} Response or null on failure
|
|
200
208
|
*/
|
|
201
|
-
export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000 } = {}) {
|
|
209
|
+
export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
|
|
202
210
|
if (!prompt) return null;
|
|
203
211
|
const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
|
|
204
212
|
const mode = detectMode();
|
|
@@ -214,8 +222,8 @@ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 1500
|
|
|
214
222
|
let primary = null;
|
|
215
223
|
try {
|
|
216
224
|
primary = mode === 'api'
|
|
217
|
-
? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens })
|
|
218
|
-
: await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens });
|
|
225
|
+
? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
|
|
226
|
+
: await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
|
|
219
227
|
} catch (e) {
|
|
220
228
|
debugCatch(e, `callLLMWithModel:${mode}:${resolvedModel}`);
|
|
221
229
|
}
|
|
@@ -239,7 +247,7 @@ export async function callModelJSON(prompt, model = 'haiku', opts) {
|
|
|
239
247
|
return parseJsonFromLLM(result.text);
|
|
240
248
|
}
|
|
241
249
|
|
|
242
|
-
async function callModelAPI(prompt, model, { timeout, maxTokens }) {
|
|
250
|
+
async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
|
|
243
251
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
244
252
|
if (!apiKey) return null;
|
|
245
253
|
|
|
@@ -252,6 +260,7 @@ async function callModelAPI(prompt, model, { timeout, maxTokens }) {
|
|
|
252
260
|
const body = {
|
|
253
261
|
model: modelId,
|
|
254
262
|
max_tokens: maxTokens,
|
|
263
|
+
temperature,
|
|
255
264
|
messages: [{ role: 'user', content: user }],
|
|
256
265
|
};
|
|
257
266
|
// System slot is constant per call type (instructions, schema, type taxonomy)
|
|
@@ -312,7 +321,7 @@ function callModelCLI(prompt, model, { timeout }) {
|
|
|
312
321
|
|
|
313
322
|
// ─── API Mode ────────────────────────────────────────────────────────────────
|
|
314
323
|
|
|
315
|
-
async function callHaikuAPI(prompt, { timeout, maxTokens }) {
|
|
324
|
+
async function callHaikuAPI(prompt, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
|
|
316
325
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
317
326
|
if (!apiKey) return null;
|
|
318
327
|
|
|
@@ -325,6 +334,7 @@ async function callHaikuAPI(prompt, { timeout, maxTokens }) {
|
|
|
325
334
|
const body = {
|
|
326
335
|
model: modelId,
|
|
327
336
|
max_tokens: maxTokens,
|
|
337
|
+
temperature,
|
|
328
338
|
messages: [{ role: 'user', content: user }],
|
|
329
339
|
};
|
|
330
340
|
// See callModelAPI: cache_control on the constant system slot.
|
|
@@ -365,7 +375,7 @@ async function callHaikuAPI(prompt, { timeout, maxTokens }) {
|
|
|
365
375
|
// `cache_control` field has no OpenAI-format equivalent and is omitted.
|
|
366
376
|
// `tier` is the resolved model tier ('haiku'|'sonnet'); OPENROUTER_MODEL can
|
|
367
377
|
// override the resulting slug entirely (see resolveOpenRouterModel).
|
|
368
|
-
async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens }) {
|
|
378
|
+
async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
|
|
369
379
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
370
380
|
if (!apiKey) return null;
|
|
371
381
|
|
|
@@ -387,7 +397,7 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens }) {
|
|
|
387
397
|
// Optional OpenRouter attribution headers (ignored by the API if absent).
|
|
388
398
|
'X-Title': 'claude-mem-lite',
|
|
389
399
|
},
|
|
390
|
-
body: JSON.stringify({ model, max_tokens: maxTokens, messages }),
|
|
400
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, temperature, messages }),
|
|
391
401
|
signal: controller.signal,
|
|
392
402
|
});
|
|
393
403
|
|
package/hook-llm.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
13
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
14
14
|
import { getVocabulary, computeVector } from './tfidf.mjs';
|
|
15
|
+
import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
15
16
|
import {
|
|
16
17
|
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
17
18
|
sessionFile, getSessionId, openDb, callLLM, sleep,
|
|
@@ -148,7 +149,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
148
149
|
ORDER BY created_at_epoch DESC LIMIT 10
|
|
149
150
|
`).all(project, fiveMinAgo);
|
|
150
151
|
|
|
151
|
-
if (obs.title && recent.some(r => jaccardSimilarity(r.title, obs.title) >
|
|
152
|
+
if (obs.title && recent.some(r => jaccardSimilarity(r.title, obs.title) > DEDUP_JACCARD_THRESHOLD)) {
|
|
152
153
|
return null; // dedup: Jaccard title match
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -173,8 +174,8 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
173
174
|
WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
|
|
174
175
|
ORDER BY created_at_epoch DESC LIMIT 60
|
|
175
176
|
`).all(project, threeDaysAgo, fiveMinAgo);
|
|
176
|
-
if (extRecent.some(r => jaccardSimilarity(r.title, obs.title) >
|
|
177
|
-
return null; // dedup: low-signal Jaccard match
|
|
177
|
+
if (extRecent.some(r => jaccardSimilarity(r.title, obs.title) > AUTO_MERGE_THRESHOLD)) {
|
|
178
|
+
return null; // dedup: low-signal Jaccard match (stricter cutoff for degraded titles)
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
|
package/hook-optimize.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { callModelJSON } from './haiku-client.mjs';
|
|
|
13
13
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
14
14
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
15
15
|
import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
|
|
16
|
+
import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
16
17
|
import { DB_DIR } from './schema.mjs';
|
|
17
18
|
|
|
18
19
|
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
@@ -331,8 +332,9 @@ export async function executeNormalize(db, force = false, { project } = {}) {
|
|
|
331
332
|
// ─── Task 3: Cluster-merge ─────────────────────────────────────────────────
|
|
332
333
|
|
|
333
334
|
const MERGE_TIME_WINDOW_MS = 30 * 86400000;
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
// Merge-review band [MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD): titles in this
|
|
336
|
+
// Jaccard range are LLM-reviewed for merge; at/above AUTO_MERGE_THRESHOLD they'd
|
|
337
|
+
// already auto-merge elsewhere, below MERGE_JACCARD_LOW they're too dissimilar.
|
|
336
338
|
|
|
337
339
|
export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
338
340
|
const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
|
|
@@ -363,12 +365,14 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
|
363
365
|
if (Math.abs(rows[i].created_at_epoch - rows[j].created_at_epoch) > MERGE_TIME_WINDOW_MS) continue;
|
|
364
366
|
|
|
365
367
|
if (rows[i].minhash_sig && rows[j].minhash_sig) {
|
|
368
|
+
// 0.8 slack: the MinHash estimate is noisy, so pre-filter a band below
|
|
369
|
+
// MERGE_JACCARD_LOW rather than at it, to avoid dropping true candidates.
|
|
366
370
|
const est = estimateJaccardFromMinHash(rows[i].minhash_sig, rows[j].minhash_sig);
|
|
367
371
|
if (est < MERGE_JACCARD_LOW * 0.8) continue;
|
|
368
372
|
}
|
|
369
373
|
|
|
370
374
|
const titleSim = jaccardSimilarity(rows[i].title, rows[j].title);
|
|
371
|
-
if (titleSim >= MERGE_JACCARD_LOW && titleSim <
|
|
375
|
+
if (titleSim >= MERGE_JACCARD_LOW && titleSim < AUTO_MERGE_THRESHOLD) {
|
|
372
376
|
cluster.push(rows[j]);
|
|
373
377
|
used.add(rows[j].id);
|
|
374
378
|
}
|
package/hook.mjs
CHANGED
|
@@ -63,7 +63,8 @@ import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-
|
|
|
63
63
|
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
64
64
|
import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
|
|
65
65
|
import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
|
|
66
|
-
import { loadCiteBackForEpisode, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
|
|
66
|
+
import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
|
|
67
|
+
import { MINHASH_PREFILTER, FUZZY_DEDUP_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
67
68
|
// plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
|
|
68
69
|
// from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
|
|
69
70
|
// crash on static import. Degrade gracefully to no-op when the module is absent.
|
|
@@ -542,6 +543,12 @@ async function handleStop() {
|
|
|
542
543
|
// contract test in tests/citation-tracker-userprompt.test.mjs covers it.
|
|
543
544
|
try {
|
|
544
545
|
const injected = extractAllInjected(transcriptPath);
|
|
546
|
+
// P5 ①: cite-back signals — observations whose warned file the agent
|
|
547
|
+
// edited this session. Union into injected so they're resolved (they
|
|
548
|
+
// were injected via pre-tool-recall) and, below, into cited so the
|
|
549
|
+
// edit promotes them even without a literal #NN in text.
|
|
550
|
+
const citeBackIds = extractCiteBackSignals(transcriptPath);
|
|
551
|
+
for (const id of citeBackIds) injected.add(id);
|
|
545
552
|
if (injected.size > 0) {
|
|
546
553
|
// Text-floor gate: skip decay on tool-only Stops. Without this,
|
|
547
554
|
// a turn that ends on tool_use locks every injected obs as
|
|
@@ -554,6 +561,7 @@ async function handleStop() {
|
|
|
554
561
|
debugLog('DEBUG', 'handleStop', `citation-decay: skipped (no main-thread assistant text yet, injected=${injected.size})`);
|
|
555
562
|
} else {
|
|
556
563
|
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
564
|
+
for (const id of citeBackIds) citedMain.add(id);
|
|
557
565
|
const r = applyCitationDecay(db, project, injected, citedMain, sessionId);
|
|
558
566
|
debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
|
|
559
567
|
}
|
|
@@ -864,8 +872,6 @@ async function handleSessionStart() {
|
|
|
864
872
|
if (!process.env.CLAUDE_MEM_SKIP_AUTO_DEDUP_FUZZY) {
|
|
865
873
|
const SCAN_LIMIT = 500;
|
|
866
874
|
const FUZZY_MAX_MERGES = 20;
|
|
867
|
-
const FUZZY_THRESHOLD = 0.95;
|
|
868
|
-
const MINHASH_PREFILTER = 0.7;
|
|
869
875
|
const recent = db.prepare(`
|
|
870
876
|
SELECT id, title, importance, created_at_epoch
|
|
871
877
|
FROM observations
|
|
@@ -885,7 +891,7 @@ async function handleSessionStart() {
|
|
|
885
891
|
for (let j = i + 1; j < recent.length; j++) {
|
|
886
892
|
if (!minhashes[j] || removed.has(recent[j].id)) continue;
|
|
887
893
|
if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PREFILTER) continue;
|
|
888
|
-
if (jaccardSimilarity(titles[i], titles[j]) <
|
|
894
|
+
if (jaccardSimilarity(titles[i], titles[j]) < FUZZY_DEDUP_THRESHOLD) continue;
|
|
889
895
|
// Keep the higher-importance row; tiebreak by older (lower id wins access history)
|
|
890
896
|
const keep = (recent[i].importance ?? 1) >= (recent[j].importance ?? 1) ? recent[i] : recent[j];
|
|
891
897
|
const remove = keep === recent[i] ? recent[j] : recent[i];
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -387,6 +387,42 @@ const IMPORTANCE_CAP = 3;
|
|
|
387
387
|
const IMPORTANCE_FLOOR = 0;
|
|
388
388
|
const UNCITED_STREAK_THRESHOLD = 3;
|
|
389
389
|
|
|
390
|
+
// Adoption-rate gate (P5 ②). A project's cite-rate is SUM(cited_count) /
|
|
391
|
+
// SUM(decay_seen_count) over its non-superseded observations: of every decay
|
|
392
|
+
// resolution this project has ever produced, what fraction were citations.
|
|
393
|
+
// Below ADOPTION_THRESHOLD with at least ADOPTION_MIN_SEEN resolutions on record,
|
|
394
|
+
// the project has demonstrably not adopted the #NN convention, so we suppress
|
|
395
|
+
// DEMOTION (never promotion) — see the construct-validity note on
|
|
396
|
+
// applyCitationDecay. MIN_SEEN keeps the gate dormant for low-data projects so
|
|
397
|
+
// the established behavior is preserved until there's enough signal to judge.
|
|
398
|
+
const ADOPTION_THRESHOLD = 0.02;
|
|
399
|
+
const ADOPTION_MIN_SEEN = 8;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Compute a project's citation-adoption snapshot: total citations vs total decay
|
|
403
|
+
* resolutions on record, and their ratio. Read-only; safe to call before the
|
|
404
|
+
* decay transaction (the gate decision is made on the pre-mutation snapshot).
|
|
405
|
+
*
|
|
406
|
+
* @param {import('better-sqlite3').Database} db
|
|
407
|
+
* @param {string} project
|
|
408
|
+
* @returns {{cited: number, seen: number, rate: number}}
|
|
409
|
+
*/
|
|
410
|
+
export function computeCitationAdoption(db, project) {
|
|
411
|
+
const empty = { cited: 0, seen: 0, rate: 0 };
|
|
412
|
+
if (!db || !project) return empty;
|
|
413
|
+
try {
|
|
414
|
+
const row = db.prepare(`
|
|
415
|
+
SELECT COALESCE(SUM(cited_count), 0) AS cited,
|
|
416
|
+
COALESCE(SUM(decay_seen_count), 0) AS seen
|
|
417
|
+
FROM observations
|
|
418
|
+
WHERE project = ? AND superseded_at IS NULL
|
|
419
|
+
`).get(project);
|
|
420
|
+
const cited = row?.cited || 0;
|
|
421
|
+
const seen = row?.seen || 0;
|
|
422
|
+
return { cited, seen, rate: seen > 0 ? cited / seen : 0 };
|
|
423
|
+
} catch (e) { debugCatch(e, 'computeCitationAdoption'); return empty; }
|
|
424
|
+
}
|
|
425
|
+
|
|
390
426
|
/**
|
|
391
427
|
* Apply the citation-feedback loop for one session: for each injected obs id,
|
|
392
428
|
* decide cited vs uncited and mutate importance/streak/cited_count per spec.
|
|
@@ -398,6 +434,20 @@ const UNCITED_STREAK_THRESHOLD = 3;
|
|
|
398
434
|
* - cross-project IDs are silently ignored by the WHERE clause.
|
|
399
435
|
* - MEM_DISABLE_CITATION_DECAY=1 disables all writes; returns zeros.
|
|
400
436
|
*
|
|
437
|
+
* CONSTRUCT-VALIDITY ASSUMPTION (P5): a "citation" is operationally two signals,
|
|
438
|
+
* neither of which is ground-truth behavioral impact:
|
|
439
|
+
* 1. the literal `#NN` token appears in main-thread assistant text (citedIds), and
|
|
440
|
+
* 2. (cite-back) the agent edited a file a prior lesson #NN had warned about —
|
|
441
|
+
* unioned into citedIds by the Stop handler before this call.
|
|
442
|
+
* Signal 2 was added because signal 1 alone penalizes projects that act on a
|
|
443
|
+
* lesson without typing its id. Even so, both are proxies. For a project that has
|
|
444
|
+
* never cited anything (cite-rate below ADOPTION_THRESHOLD over ≥ADOPTION_MIN_SEEN
|
|
445
|
+
* resolutions), demotion is suppressed: absent any positive signal we cannot
|
|
446
|
+
* distinguish "useless lesson" from "useful lesson in a project that doesn't use
|
|
447
|
+
* the #NN convention," and a false demotion is the costlier error. The gate trades
|
|
448
|
+
* missed demotions (stale lessons linger) for avoided false demotions. Promotion
|
|
449
|
+
* is never gated — a single citation lifts the project's rate and re-enables decay.
|
|
450
|
+
*
|
|
401
451
|
* @param {import('better-sqlite3').Database} db
|
|
402
452
|
* @param {string} project
|
|
403
453
|
* @param {Set<number>|Iterable<number>} injectedIds
|
|
@@ -413,6 +463,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
413
463
|
if (injected.size === 0) return empty;
|
|
414
464
|
const cited = citedIds instanceof Set ? citedIds : new Set(citedIds || []);
|
|
415
465
|
|
|
466
|
+
// Adoption gate (snapshot taken before any mutation this run). Suppress only
|
|
467
|
+
// demotion; promotion always proceeds. Threshold overridable via env.
|
|
468
|
+
const adoption = computeCitationAdoption(db, project);
|
|
469
|
+
const envThreshold = Number.parseFloat(process.env.CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD);
|
|
470
|
+
const adoptionThreshold = Number.isFinite(envThreshold) && envThreshold >= 0 ? envThreshold : ADOPTION_THRESHOLD;
|
|
471
|
+
const suppressDemotion = adoption.seen >= ADOPTION_MIN_SEEN && adoption.rate < adoptionThreshold;
|
|
472
|
+
|
|
416
473
|
const selectStmt = db.prepare(
|
|
417
474
|
'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ?'
|
|
418
475
|
);
|
|
@@ -457,7 +514,10 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
457
514
|
promoted++;
|
|
458
515
|
} else {
|
|
459
516
|
const nextStreak = (row.uncited_streak || 0) + 1;
|
|
460
|
-
|
|
517
|
+
// Demote only when the streak is up AND the project has demonstrably
|
|
518
|
+
// adopted citations. A non-adopting project advances the streak (idempotent
|
|
519
|
+
// bookkeeping) but never loses importance — see construct-validity note.
|
|
520
|
+
if (nextStreak >= UNCITED_STREAK_THRESHOLD && !suppressDemotion) {
|
|
461
521
|
updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
|
|
462
522
|
demoted++;
|
|
463
523
|
} else {
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -17,6 +17,11 @@ import { EDIT_TOOLS } from '../utils.mjs';
|
|
|
17
17
|
|
|
18
18
|
const MAX_FILES = 2;
|
|
19
19
|
|
|
20
|
+
// Leader literal for the cite-back hint. Shared by the builder (below) and the
|
|
21
|
+
// Stop-time signal extractor (extractCiteBackSignals) so the two can never drift
|
|
22
|
+
// — the extractor finds hint emissions by this exact prefix.
|
|
23
|
+
const CITE_BACK_HINT_LEADER = '[mem] ⚠ Cite-back:';
|
|
24
|
+
|
|
20
25
|
export function buildCiteBackHint(episode, cooldown) {
|
|
21
26
|
if (!episode || !cooldown) return null;
|
|
22
27
|
const entries = episode.entries;
|
|
@@ -48,7 +53,7 @@ export function buildCiteBackHint(episode, cooldown) {
|
|
|
48
53
|
// numeric framing is measurably harder to dismiss than a hedged hint.
|
|
49
54
|
const totalLessons = matches.reduce((sum, m) => sum + m.ids.length, 0);
|
|
50
55
|
const lines = [
|
|
51
|
-
|
|
56
|
+
`${CITE_BACK_HINT_LEADER} edited ${matches.length} file(s) with ${totalLessons} prior lesson(s) this session. Save now if any was the root cause:`,
|
|
52
57
|
];
|
|
53
58
|
for (const m of matches) {
|
|
54
59
|
const fname = basename(m.file);
|
|
@@ -242,3 +247,36 @@ export function loadCiteBackForEpisode(episode, runtimeDir) {
|
|
|
242
247
|
}
|
|
243
248
|
return buildCiteBackHint(episode, cooldown);
|
|
244
249
|
}
|
|
250
|
+
|
|
251
|
+
// ─── extractCiteBackSignals (P5 ①) ──────────────────────────────────────────
|
|
252
|
+
// Stop-time positive-citation signal. Scans the transcript for cite-back hint
|
|
253
|
+
// emissions (PostToolUse attachment.stdout carrying CITE_BACK_HINT_LEADER — the
|
|
254
|
+
// same source countUnsavedBugfixShape reads) and collects the `#NN` lesson ids
|
|
255
|
+
// they name. Each id is an observation whose warned file the agent actually
|
|
256
|
+
// EDITED this session — a behavioral citation even when the agent never typed
|
|
257
|
+
// #NN. The Stop handler unions these into the cited set passed to
|
|
258
|
+
// applyCitationDecay (lib/citation-tracker.mjs), so acting on a lesson promotes
|
|
259
|
+
// it and lifts the project's adoption rate. Returns an empty set on missing path.
|
|
260
|
+
const CITE_BACK_ID_RE = /#(\d{1,7})\b/g;
|
|
261
|
+
|
|
262
|
+
export function extractCiteBackSignals(transcriptPath) {
|
|
263
|
+
const ids = new Set();
|
|
264
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return ids;
|
|
265
|
+
let raw;
|
|
266
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
|
|
267
|
+
for (const line of raw.split('\n')) {
|
|
268
|
+
if (!line.trim()) continue;
|
|
269
|
+
let entry;
|
|
270
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
271
|
+
if (entry.type !== 'attachment') continue;
|
|
272
|
+
const stdout = entry.attachment?.stdout || '';
|
|
273
|
+
if (!stdout.includes(CITE_BACK_HINT_LEADER)) continue;
|
|
274
|
+
CITE_BACK_ID_RE.lastIndex = 0;
|
|
275
|
+
let m;
|
|
276
|
+
while ((m = CITE_BACK_ID_RE.exec(stdout))) {
|
|
277
|
+
const id = Number(m[1]);
|
|
278
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return ids;
|
|
282
|
+
}
|
package/lib/compress-core.mjs
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
// granularity (CLI/MCP wrap all groups in one transaction; the hook transacts
|
|
11
11
|
// each group). They no longer re-implement the mutation.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// The summary INSERT also writes its TF-IDF observation_vectors row in the same
|
|
14
|
+
// (caller-owned) transaction — fixed once here rather than in all three call
|
|
15
|
+
// sites. Without it, FTS-miss queries that fall back to vector recall (CJK /
|
|
16
|
+
// concept / paraphrase) could never reach compressed summaries; the LLM
|
|
17
|
+
// smart-compress path already wrote vectors, so the deterministic path was the
|
|
18
|
+
// sole gap (audit P6).
|
|
16
19
|
|
|
17
|
-
import { isoWeekKey, COMPRESSED_AUTO } from '../utils.mjs';
|
|
20
|
+
import { isoWeekKey, COMPRESSED_AUTO, debugCatch } from '../utils.mjs';
|
|
21
|
+
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
18
22
|
import { scrubRecord } from './scrub-record.mjs';
|
|
19
23
|
|
|
20
24
|
/**
|
|
@@ -90,6 +94,22 @@ export function compressGroup(db, proj, obs) {
|
|
|
90
94
|
`).run(sessionId, proj, safe.text, dominantType, safe.title, safe.narrative, medianDate.toISOString(), medianEpoch);
|
|
91
95
|
const summaryId = Number(summaryResult.lastInsertRowid);
|
|
92
96
|
|
|
97
|
+
// TF-IDF vector for the summary so it is reachable by vector recall (parity
|
|
98
|
+
// with save-observation.mjs and the LLM smart-compress path). Best-effort:
|
|
99
|
+
// vocab may be uninitialized on a fresh DB — a failure here must not abort the
|
|
100
|
+
// compression the caller is transacting.
|
|
101
|
+
try {
|
|
102
|
+
const vocab = getVocabulary(db);
|
|
103
|
+
if (vocab) {
|
|
104
|
+
const vec = computeVector(`${safe.title} ${safe.narrative}`, vocab);
|
|
105
|
+
if (vec) {
|
|
106
|
+
db.prepare(
|
|
107
|
+
'INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)'
|
|
108
|
+
).run(summaryId, Buffer.from(vec.buffer), vocab.version, medianEpoch);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (e) { debugCatch(e, 'compress-vector'); }
|
|
112
|
+
|
|
93
113
|
const obsIds = obs.map((o) => o.id);
|
|
94
114
|
const obsPh = obsIds.map(() => '?').join(',');
|
|
95
115
|
db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${obsPh})`).run(summaryId, ...obsIds);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Dedup / merge similarity thresholds — single source of truth (P10).
|
|
2
|
+
//
|
|
3
|
+
// All values are Jaccard-space (word-set overlap, 0..1) unless noted. They were
|
|
4
|
+
// scattered as bare literals and duplicate local consts across save-observation,
|
|
5
|
+
// maintain-core, hook-llm, hook-optimize, mem-cli, server, and hook; converging
|
|
6
|
+
// them here removes the drift risk and gives the P7 benchmark named knobs.
|
|
7
|
+
// Vector-side constants (VOCAB_DIM / MIN_COSINE_SIMILARITY / RRF_K) deliberately
|
|
8
|
+
// stay in tfidf.mjs next to the search engine that consumes them.
|
|
9
|
+
//
|
|
10
|
+
// Pure constants only — no imports, so nothing can import-cycle through this.
|
|
11
|
+
|
|
12
|
+
// 0.7: near-duplicate cutoff for save-time dedup (5-min window, lib/save-observation)
|
|
13
|
+
// and the hook-llm tier-1 title dedup. Catches "Modified X" / "Fixed X" restatements
|
|
14
|
+
// (~70% word overlap) without collapsing distinct-but-related observations.
|
|
15
|
+
export const DEDUP_JACCARD_THRESHOLD = 0.7;
|
|
16
|
+
|
|
17
|
+
// 0.85: high-confidence auto-merge cutoff (maintain + optimize cluster-merge, CLI/MCP
|
|
18
|
+
// dedup preview). Pairs at or above this merge without an LLM merge-decision call.
|
|
19
|
+
export const AUTO_MERGE_THRESHOLD = 0.85;
|
|
20
|
+
|
|
21
|
+
// 0.4: low bound of the LLM-review merge band [0.4, 0.85) in hook-optimize. Below it,
|
|
22
|
+
// a pair is too dissimilar to be worth a merge-decision call.
|
|
23
|
+
export const MERGE_JACCARD_LOW = 0.4;
|
|
24
|
+
|
|
25
|
+
// 0.5: MinHash estimated-Jaccard pre-filter for the maintain O(n²) scan — skip the
|
|
26
|
+
// exact-Jaccard compare when the cheap signature estimate is already below this.
|
|
27
|
+
export const MINHASH_PRE_THRESHOLD = 0.5;
|
|
28
|
+
|
|
29
|
+
// 0.7: MinHash pre-filter for the hook post-inject fuzzy-dedup pass. Stricter than
|
|
30
|
+
// maintain's 0.5 to keep the inline inject path cheap (it runs in the hot Stop path).
|
|
31
|
+
export const MINHASH_PREFILTER = 0.7;
|
|
32
|
+
|
|
33
|
+
// 0.95: strict title-Jaccard cutoff for the hook post-inject fuzzy-dedup pass — only
|
|
34
|
+
// collapse near-identical titles inline; anything softer waits for the maintain sweep.
|
|
35
|
+
export const FUZZY_DEDUP_THRESHOLD = 0.95;
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
|
|
15
15
|
import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
|
|
16
16
|
import { rebuildVocabulary, computeVector, _resetVocabCache } from '../tfidf.mjs';
|
|
17
|
+
import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC } from './dedup-constants.mjs';
|
|
17
18
|
|
|
18
19
|
export const STALE_AGE_MS = 30 * 86400000;
|
|
19
20
|
export const OP_CAP = 1000;
|
|
20
21
|
export const SCAN_LIMIT = 500;
|
|
21
22
|
export const DUPLICATE_LIMIT = 50;
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Back-compat: maintain-core historically exported these names; both now source
|
|
24
|
+
// their value from the single canonical lib/dedup-constants.mjs.
|
|
25
|
+
export const SIMILARITY_THRESHOLD = DEDUP_JACCARD_THRESHOLD;
|
|
26
|
+
export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
|
|
24
27
|
// A memory injected this many times with zero citations is "pinned noise" that
|
|
25
28
|
// the regular decay op can't touch (decay protects injection_count>0).
|
|
26
29
|
export const PINNED_INJ_THRESHOLD = 8;
|
package/lib/save-observation.mjs
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { jaccardSimilarity, scrubSecrets, computeMinHash, cjkBigrams, getCurrentBranch, debugCatch } from '../utils.mjs';
|
|
15
15
|
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
16
|
+
import { DEDUP_JACCARD_THRESHOLD } from './dedup-constants.mjs';
|
|
16
17
|
|
|
17
18
|
const DEDUP_WINDOW_MS = 5 * 60 * 1000;
|
|
18
19
|
const DEDUP_RECENT_LIMIT = 50;
|
|
19
|
-
const DEDUP_JACCARD_THRESHOLD = 0.7;
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Save a new observation if it isn't a near-duplicate of one saved within the
|
package/mem-cli.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
34
34
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
35
35
|
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
|
|
36
36
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
37
|
+
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
37
38
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
38
39
|
import {
|
|
39
40
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
@@ -1845,7 +1846,6 @@ function cmdMaintain(db, args) {
|
|
|
1845
1846
|
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
|
|
1846
1847
|
out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
|
|
1847
1848
|
if (duplicates.length > 0) {
|
|
1848
|
-
const AUTO_MERGE_THRESHOLD = 0.85;
|
|
1849
1849
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
1850
1850
|
const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
|
|
1851
1851
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services.
|
|
3
|
+
"version": "2.89.0",
|
|
4
|
+
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
7
7
|
"engines": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"lib/save-observation.mjs",
|
|
70
70
|
"lib/compress-core.mjs",
|
|
71
71
|
"lib/maintain-core.mjs",
|
|
72
|
+
"lib/dedup-constants.mjs",
|
|
72
73
|
"lib/deferred-work.mjs",
|
|
73
74
|
"lib/upgrade-banner.mjs",
|
|
74
75
|
"lib/scrub-record.mjs",
|
package/schema.mjs
CHANGED
|
@@ -54,7 +54,14 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
|
54
54
|
// re-runs the v28 observation_vectors cleanup) to clear the backlog leaked while
|
|
55
55
|
// the warm-start fast-path left foreign_keys OFF. LATEST_MIGRATION_COLUMN is
|
|
56
56
|
// unchanged (no new column) — decay_seen_count still exists at v35.
|
|
57
|
-
|
|
57
|
+
// v36 (v2.89.0): no DDL — narrows events_fts_au to `AFTER UPDATE OF title, body`.
|
|
58
|
+
// The events FTS triggers (v2.31) were hand-written inline and inherited the
|
|
59
|
+
// pre-v27 broad `AFTER UPDATE ON events` form, so every importance / accessed_count
|
|
60
|
+
// / citation-decay bump thrashed events_fts (delete+reinsert) and reintroduced the
|
|
61
|
+
// SQLITE_CORRUPT_VTAB blast radius v27 fixed for the other FTS tables. Version
|
|
62
|
+
// bumped to force one migration pass; the conditional drop below replaces the
|
|
63
|
+
// legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
|
|
64
|
+
export const CURRENT_SCHEMA_VERSION = 36;
|
|
58
65
|
|
|
59
66
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
60
67
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
@@ -399,6 +406,20 @@ export function initSchema(db) {
|
|
|
399
406
|
}
|
|
400
407
|
} catch { /* non-critical */ }
|
|
401
408
|
|
|
409
|
+
// v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
|
|
410
|
+
// triggers were hand-written inline (below) rather than via ensureFTS, so
|
|
411
|
+
// events_fts_au inherited the broad `AFTER UPDATE ON events` form and fires on
|
|
412
|
+
// every non-indexed bump (importance / accessed_count / citation-decay). Drop
|
|
413
|
+
// the legacy trigger when its stored DDL lacks the scoped `UPDATE OF` clause so
|
|
414
|
+
// the CREATE TRIGGER IF NOT EXISTS below reinstates the scoped form (handles
|
|
415
|
+
// re-run + fresh-DB: undefined row on a fresh DB is a no-op).
|
|
416
|
+
try {
|
|
417
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`).get();
|
|
418
|
+
if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
|
|
419
|
+
db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
|
|
420
|
+
}
|
|
421
|
+
} catch { /* non-critical — recreated below */ }
|
|
422
|
+
|
|
402
423
|
// ─── v2.31 T6: events table + FTS5 (activity namespace) ───────────────────
|
|
403
424
|
// Independent namespace for bugfix/lesson/bug/discovery/refactor/feature/
|
|
404
425
|
// observation/decision types. Isolated from observations to avoid polluting
|
|
@@ -443,7 +464,9 @@ export function initSchema(db) {
|
|
|
443
464
|
VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
|
|
444
465
|
END;
|
|
445
466
|
|
|
446
|
-
|
|
467
|
+
-- v36: scoped to title, body (the FTS-indexed columns) so non-indexed bumps
|
|
468
|
+
-- (importance / accessed_count / citation-decay) no longer thrash events_fts.
|
|
469
|
+
CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE OF title, body ON events BEGIN
|
|
447
470
|
INSERT INTO events_fts(events_fts, rowid, title, body, event_type, project)
|
|
448
471
|
VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
|
|
449
472
|
INSERT INTO events_fts(rowid, title, body, event_type, project)
|
package/search-engine.mjs
CHANGED
|
@@ -257,11 +257,12 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
257
257
|
project: args.project ?? null,
|
|
258
258
|
type: args.obs_type ?? null,
|
|
259
259
|
vocabVersion: vocab.version,
|
|
260
|
+
minCosine: ctx.minCosine, // undefined → MIN_COSINE_SIMILARITY (benchmark sweep override)
|
|
260
261
|
});
|
|
261
262
|
if (vecResults.length === 0) return results;
|
|
262
263
|
|
|
263
264
|
if (results.length > 0) {
|
|
264
|
-
const rrfRanking = rrfMerge(results, vecResults);
|
|
265
|
+
const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
|
|
265
266
|
const resultMap = new Map(results.map(r => [r.id, r]));
|
|
266
267
|
for (const vr of vecResults) {
|
|
267
268
|
if (!resultMap.has(vr.id)) {
|
package/server.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
|
36
36
|
import { searchResources } from './registry-retriever.mjs';
|
|
37
37
|
import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
|
|
38
38
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
39
|
+
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
39
40
|
import {
|
|
40
41
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
41
42
|
resolveDeferredIds, closeDeferredItems,
|
|
@@ -1250,7 +1251,6 @@ server.registerTool(
|
|
|
1250
1251
|
` Pending purge (idle-marked): ${stats.pendingPurge}`,
|
|
1251
1252
|
];
|
|
1252
1253
|
if (duplicates.length > 0) {
|
|
1253
|
-
const AUTO_MERGE_THRESHOLD = 0.85;
|
|
1254
1254
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
1255
1255
|
const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
|
|
1256
1256
|
|
package/source-files.mjs
CHANGED
|
@@ -86,6 +86,11 @@ export const SOURCE_FILES = [
|
|
|
86
86
|
// Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
|
|
87
87
|
// and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
|
|
88
88
|
'lib/maintain-core.mjs',
|
|
89
|
+
// P10 dedup/merge threshold constants — single source of truth for the Jaccard
|
|
90
|
+
// dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
|
|
91
|
+
// hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
|
|
92
|
+
// missing it from the manifest would break those paths on auto-update.
|
|
93
|
+
'lib/dedup-constants.mjs',
|
|
89
94
|
// v2.70 deferred-work: carry-forward TODO primitives. Statically imported by
|
|
90
95
|
// server.mjs (mem_defer family) and mem-cli.mjs (defer subcommand).
|
|
91
96
|
'lib/deferred-work.mjs',
|
package/tfidf.mjs
CHANGED
|
@@ -10,6 +10,10 @@ import { createHash } from 'crypto';
|
|
|
10
10
|
export const VOCAB_DIM = 512;
|
|
11
11
|
export const MIN_COSINE_SIMILARITY = 0.05;
|
|
12
12
|
export const VECTOR_SCAN_LIMIT = 500;
|
|
13
|
+
// Reciprocal Rank Fusion constant. Higher k flattens the rank-position weighting
|
|
14
|
+
// (BM25 and vector lists contribute more equally); lower k lets the top few ranks
|
|
15
|
+
// dominate. 60 is the de-facto RRF default and balances the two retrievers here.
|
|
16
|
+
export const RRF_K = 60;
|
|
13
17
|
|
|
14
18
|
const VOCAB_STOP_WORDS = new Set([
|
|
15
19
|
...BASE_STOP_WORDS,
|
|
@@ -192,7 +196,7 @@ export function _resetVocabCache() { _vocabCache = null; }
|
|
|
192
196
|
* @param {object} db - better-sqlite3 database
|
|
193
197
|
* @returns {{ terms: Map<string, {index: number, idf: number}>, version: string, dim: number } | null}
|
|
194
198
|
*/
|
|
195
|
-
export function buildVocabulary(db) {
|
|
199
|
+
export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
|
|
196
200
|
const rows = db.prepare(`
|
|
197
201
|
SELECT title, narrative, concepts FROM observations
|
|
198
202
|
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
@@ -217,7 +221,7 @@ export function buildVocabulary(db) {
|
|
|
217
221
|
.filter(([term, freq]) => !isNoiseTerm(term) && freq >= 2)
|
|
218
222
|
.map(([term, freq]) => ({ term, df: freq, idf: idf(freq), ig: freq * idf(freq) }))
|
|
219
223
|
.sort((a, b) => b.ig - a.ig)
|
|
220
|
-
.slice(0,
|
|
224
|
+
.slice(0, dim);
|
|
221
225
|
|
|
222
226
|
// Build terms map with index and IDF
|
|
223
227
|
const terms = new Map();
|
|
@@ -229,7 +233,7 @@ export function buildVocabulary(db) {
|
|
|
229
233
|
const termList = sortedTerms.map(e => e.term).join(',');
|
|
230
234
|
const version = createHash('md5').update(termList).digest('hex').slice(0, 12);
|
|
231
235
|
|
|
232
|
-
const vocab = { terms, version, dim
|
|
236
|
+
const vocab = { terms, version, dim };
|
|
233
237
|
_vocabCache = vocab;
|
|
234
238
|
return vocab;
|
|
235
239
|
}
|
|
@@ -239,8 +243,8 @@ export function buildVocabulary(db) {
|
|
|
239
243
|
* @param {object} db - better-sqlite3 database
|
|
240
244
|
* @returns {object|null} The new vocabulary
|
|
241
245
|
*/
|
|
242
|
-
export function rebuildVocabulary(db) {
|
|
243
|
-
const vocab = buildVocabulary(db);
|
|
246
|
+
export function rebuildVocabulary(db, opts) {
|
|
247
|
+
const vocab = buildVocabulary(db, opts);
|
|
244
248
|
if (!vocab) return null;
|
|
245
249
|
|
|
246
250
|
const insertStmt = db.prepare(
|
|
@@ -358,7 +362,7 @@ export function cosineSimilarity(a, b) {
|
|
|
358
362
|
const VECTOR_TIME_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
|
|
359
363
|
const VECTOR_MIN_RESULTS = 50; // fallback to full scan if time-window yields fewer
|
|
360
364
|
|
|
361
|
-
export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit = VECTOR_SCAN_LIMIT }) {
|
|
365
|
+
export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit = VECTOR_SCAN_LIMIT, minCosine = MIN_COSINE_SIMILARITY }) {
|
|
362
366
|
if (!queryVec) return [];
|
|
363
367
|
|
|
364
368
|
const now = Date.now();
|
|
@@ -403,7 +407,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
|
|
|
403
407
|
for (const row of rows) {
|
|
404
408
|
const vec = new Float32Array(row.vector.buffer.slice(row.vector.byteOffset, row.vector.byteOffset + row.vector.byteLength));
|
|
405
409
|
const sim = cosineSimilarity(queryVec, vec);
|
|
406
|
-
if (sim >
|
|
410
|
+
if (sim > minCosine) results.push({ id: row.observation_id, similarity: sim });
|
|
407
411
|
}
|
|
408
412
|
results.sort((a, b) => b.similarity - a.similarity);
|
|
409
413
|
return results.slice(0, 20);
|
|
@@ -418,7 +422,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
|
|
|
418
422
|
* @param {number} k - RRF constant (default 60)
|
|
419
423
|
* @returns {{ id: number, rrfScore: number }[]}
|
|
420
424
|
*/
|
|
421
|
-
export function rrfMerge(bm25Results, vectorResults, k =
|
|
425
|
+
export function rrfMerge(bm25Results, vectorResults, k = RRF_K) {
|
|
422
426
|
const scores = new Map();
|
|
423
427
|
bm25Results.forEach((r, i) => {
|
|
424
428
|
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i + 1));
|