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.
@@ -10,9 +10,9 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.88.0",
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. Alternative to claude-mem with 600x lower cost."
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.88.0",
4
- "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. Alternative to claude-mem with 600x lower cost.",
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), and the hybrid FTS5 + TF-IDF retriever benchmarks at 0.88 Recall@10 / 0.96 Precision@10.
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) | **7-10x fewer** |
37
- | Tokens per call | 1,000-5,000 (raw JSON + history) | 200-500 (summaries only) | **5-10x smaller** |
38
- | Total tokens | ~100K-250K | ~1K-4K | **50-100x less** |
39
- | Model cost | Sonnet ($3/$15 per M) | Haiku ($0.25/$1.25 per M) | **12x cheaper** |
40
- | Combined savings | | | **600x+ lower cost** |
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 倍(综合成本下降 600 倍),FTS5 + TF-IDF 混合检索在 30 个查询的基准上达到 **Recall@10 = 0.88 / Precision@10 = 0.96**。
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) | **减少 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 倍+** |
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) > 0.7)) {
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) > 0.85)) {
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
- const MERGE_JACCARD_LOW = 0.4;
335
- const MERGE_JACCARD_HIGH = 0.85;
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 < MERGE_JACCARD_HIGH) {
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]) < FUZZY_THRESHOLD) continue;
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];
@@ -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
- if (nextStreak >= UNCITED_STREAK_THRESHOLD) {
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 {
@@ -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
- `[mem] ⚠ Cite-back: edited ${matches.length} file(s) with ${totalLessons} prior lesson(s) this session. Save now if any was the root cause:`,
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
+ }
@@ -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
- // NOTE: the summary INSERT still omits the observation_vectors write, matching
14
- // pre-extraction behavior. Fixing that (audit P5) is now a single change here
15
- // instead of three but it is a behavior change, intentionally NOT bundled.
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;
@@ -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
- export const SIMILARITY_THRESHOLD = 0.7;
23
- export const MINHASH_PRE_THRESHOLD = 0.5;
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;
@@ -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.88.0",
4
- "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. Alternative to claude-mem with 600x lower cost.",
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
- export const CURRENT_SCHEMA_VERSION = 35;
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
- CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE ON events BEGIN
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, VOCAB_DIM);
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: VOCAB_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 > MIN_COSINE_SIMILARITY) results.push({ id: row.observation_id, similarity: 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 = 60) {
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));