recallnest 3.0.1 → 4.0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +42 -17
  3. package/README_CN.md +31 -15
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +10 -2
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +434 -68
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer.ts +11 -2
  28. package/src/distill-lock.ts +246 -74
  29. package/src/dream-pipeline.ts +3 -1
  30. package/src/env-config.ts +118 -0
  31. package/src/flock.ts +97 -0
  32. package/src/forget-engine.ts +342 -58
  33. package/src/gateway-server.ts +4 -0
  34. package/src/ingest-plan.ts +56 -0
  35. package/src/ingest.ts +69 -104
  36. package/src/kg-contradiction.ts +753 -0
  37. package/src/kg-store.ts +1 -1
  38. package/src/known-item-eval.ts +455 -0
  39. package/src/llm-client.ts +13 -26
  40. package/src/llm-consolidation.ts +2 -2
  41. package/src/mcp-tool-deps.ts +6 -0
  42. package/src/mcp-tools-advanced.ts +80 -45
  43. package/src/mcp-tools-core.ts +23 -17
  44. package/src/mcp-tools-governance.ts +13 -7
  45. package/src/memory-assets.ts +144 -8
  46. package/src/memory-boundaries.ts +19 -0
  47. package/src/memory-evolution.ts +9 -0
  48. package/src/memory-lint.ts +1 -1
  49. package/src/memory-output.ts +276 -14
  50. package/src/memory-reconcile.ts +1437 -0
  51. package/src/memory-schema.ts +11 -0
  52. package/src/memory-store-port.ts +1 -0
  53. package/src/minis-archive.ts +151 -0
  54. package/src/noise-filter.ts +15 -2
  55. package/src/pii-detector.ts +31 -1
  56. package/src/pivot-apply.ts +3 -4
  57. package/src/preference-matcher.ts +40 -28
  58. package/src/prospective-memory.ts +7 -4
  59. package/src/retriever.ts +297 -57
  60. package/src/retroactive-boost.ts +3 -2
  61. package/src/runtime-config.ts +6 -1
  62. package/src/scope-policy.ts +1 -0
  63. package/src/session-distiller.ts +6 -2
  64. package/src/session-output.ts +11 -0
  65. package/src/session-schema.ts +5 -2
  66. package/src/session-store.ts +57 -9
  67. package/src/store-receipts.ts +109 -0
  68. package/src/store-write-budget.ts +37 -0
  69. package/src/store.ts +298 -57
  70. package/src/text-fingerprint.ts +25 -0
  71. package/src/trigger-store.ts +443 -0
  72. package/src/ui-server.ts +22 -21
  73. package/src/version-manager.ts +19 -5
  74. package/src/write-verifier.ts +16 -11
package/README_CN.md CHANGED
@@ -10,13 +10,13 @@
10
10
 
11
11
  [![GitHub](https://img.shields.io/github/stars/AliceLJY/recallnest?style=social)](https://github.com/AliceLJY/recallnest)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
13
- [![Runtime](https://img.shields.io/badge/Runtime-Bun_|_Node.js_22+-f9f1e1?logo=bun)](https://bun.sh)
13
+ [![Runtime](https://img.shields.io/badge/Runtime-Bun-f9f1e1?logo=bun)](https://bun.sh)
14
14
  [![LanceDB](https://img.shields.io/badge/LanceDB-Vector+FTS-orange)](https://lancedb.com)
15
15
  [![MCP](https://img.shields.io/badge/MCP-44_tools-blue)](https://modelcontextprotocol.io)
16
16
  [![CI](https://github.com/AliceLJY/recallnest/actions/workflows/ci.yml/badge.svg)](https://github.com/AliceLJY/recallnest/actions/workflows/ci.yml)
17
17
  [![CC Plugin](https://img.shields.io/badge/Claude_Code-Plugin-blueviolet)](https://github.com/AliceLJY/recallnest)
18
18
 
19
- [English](README.md) | **简体中文** | [Roadmap](ROADMAP.md)
19
+ [English](README.md) | **简体中文** | [Roadmap](ROADMAP.md) | [项目主页](https://aliceljy.github.io/recallnest/)
20
20
 
21
21
  </div>
22
22
 
@@ -50,6 +50,10 @@ Hits : 5
50
50
  - **`imgs : …`** —— 那个 session 里有 52 张图,**一张都没进数据库**。这一行的存在只是让你知道
51
51
  那儿有东西可看,而产生它没花一次模型调用、没多一个向量、没占一份存储。
52
52
 
53
+ > 上面这一行来自「对话原文入库」。2026-10-06 起这一步默认关:`ingest` 不再把对话记录切块写进库,
54
+ > 默认配置下库里只有你存进去的记忆、提炼产物和记忆文件,原始对话交给专门的会话检索工具去查。
55
+ > 要让库里重新出现这样的行,见下文「索引已有对话」。
56
+
53
57
  最后这条就是整套做法的缩影:**只存"让一样东西能被找到"所需的部分,不存"将来可能被问到"的一切。**
54
58
  完整的推理——包括最显然的那种实现错在哪两个地方——在
55
59
  [图片:可寻址,而不是被编码](#图片可寻址而不是被编码)。
@@ -70,7 +74,7 @@ Hits : 5
70
74
 
71
75
  | 能力 | 说明 |
72
76
  |---|---|
73
- | **混合检索** | 6 通道:向量 + BM25 + L0/L1/L2 多向量 + KG 图(PPR) |
77
+ | **混合检索** | 6 通道:向量 + BM25 + L0/L1/L2 多向量 + KG 图(PPR)。默认只走向量(2026-06 起),BM25 融合与图扩展要在配置里打开 |
74
78
  | **4 套检索策略** | default、writing、debug、fact-check —— 按任务类型调优 |
75
79
  | **会话连续性** | `checkpoint_session` + `resume_context`(full/light/summary 三种模式)+ 仓库状态守卫 |
76
80
  | **会话蒸馏** | 3 层对话压缩:微缩 → LLM 结构化摘要 → 知识提取 |
@@ -122,7 +126,7 @@ Hits : 5
122
126
  ────────────────────── ─────────────────────── ──────────────────────────── ──────────────────────
123
127
 
124
128
  Claude Code MCP over stdio Retriever LanceDB
125
- Codex ───▶ 44 tools, 3 tiers ───▶ vector + BM25 + RRF ───▶ vector + columnar
129
+ Codex ───▶ 44 tools, 3 tiers ───▶ vector · hybrid opt-in ───▶ vector + columnar
126
130
  Kimi · Antigravity Classifier · 6 categories
127
131
  Doubao desktop Context composer
128
132
  HTTP API :4318 resume_context Jina embeddings v5
@@ -134,6 +138,9 @@ Hits : 5
134
138
 
135
139
  > 图内保留英文术语:等宽字体下中文占两格,混排会把对齐打乱。四段从左到右分别是
136
140
  > 客户端、接入层(MCP / HTTP / 只读网关)、核心引擎、存储层。
141
+ >
142
+ > Retriever 一格:`retrieval.mode` 默认是 `"vector"`(纯向量检索);在 `config.json` 里改成 `"hybrid"`
143
+ > 后,会加上 BM25 全文检索,并与向量结果做加权分数融合(默认 `vectorWeight` 0.7 / `bm25Weight` 0.3)。
137
144
 
138
145
  ### 内部设计
139
146
 
@@ -222,7 +229,7 @@ npm install -g recallnest # 全局安装
222
229
  recallnest doctor
223
230
  ```
224
231
 
225
- 支持 Node.js 22+(通过 tsx)或 Bun,无需 clone 仓库。
232
+ 需要 [Bun](https://bun.sh):RecallNest 不能在 Node.js 上运行。npm 装的启动脚本只负责转交给 Bun,找不到 `bun` 时会给出安装方法后退出。无需 clone 仓库。
226
233
 
227
234
  ### 方式 C:手动安装
228
235
 
@@ -277,6 +284,11 @@ bun run seed:continuity
277
284
  bun run src/cli.ts doctor
278
285
  ```
279
286
 
287
+ 2026-10-06 起,`ingest` 默认只做两件事:对账记忆文件(`sources.memory`),以及把 Minis 投递目录里的文件挪进
288
+ `data/minis-archive`。六个对话来源(cc / codex / kimi / gemini / desktop / minis)不切片、不嵌入、不写库。
289
+ 要把对话原文也索引进来,在运行 `ingest` 的环境里设 `RECALLNEST_TRANSCRIPT_INGEST=on`(只认 `on` 这一个写法)。
290
+ 关着期间挪进存档的 Minis 对话不会自动补进来,要补就把文件从 `data/minis-archive` 拷回投递目录再跑。
291
+
280
292
  ---
281
293
 
282
294
  ## 图片:可寻址,而不是被编码
@@ -410,7 +422,7 @@ RecallNest 提供两种接口:
410
422
  | `data_checkup` | 运行数据质量健康检查 |
411
423
  | `dream` | 离线记忆整合(聚类、合并、修剪) |
412
424
  | `memory_lint` | 记忆质量检查:矛盾、重复、过期、孤儿 |
413
- | `forget_memory` | 级联删除记忆 + KG 清理 + Pin 归档 + 审计 |
425
+ | `forget_memory` | 删除记忆,连同它的 KG 三元组、trigger 行和由它生成的 pin 的索引副本,pin 文件归档,删后回读核对,写审计 |
414
426
  | `export_graph` | 导出交互式 HTML 知识图谱 |
415
427
 
416
428
  </details>
@@ -480,7 +492,7 @@ bun run src/cli.ts lint --scope project:myapp # 指定 scope
480
492
  bun run src/cli.ts graph --open # 导出并打开知识图谱
481
493
  bun run src/cli.ts graph --max-nodes 50 # 较小的图
482
494
 
483
- # 导入与诊断
495
+ # 导入与诊断(对话原文默认不入库,见「索引已有对话」)
484
496
  bun run src/cli.ts ingest --source all
485
497
  bun run src/cli.ts doctor
486
498
  ```
@@ -515,17 +527,21 @@ bun run src/ui-server.ts
515
527
 
516
528
  ## 最近更新
517
529
 
518
- **v3.0** 把运行时下限提到 **Node 22**(本次唯一的破坏性变更,Bun 用户不受影响),
519
- 并给"合成出来的结论"开了一条通往稳定记忆的路:`dream` 产出的洞察现在可以凭它自己
520
- 那套已验证的证据被提升,而不是永远卡在证据层、下游谁也不敢依赖。
530
+ **v4.0** 只在 **Bun** 上运行,跨进程锁交给了内核。以前 RecallNest 靠锁文件里的
531
+ pid 和时间戳判断锁是不是过期了,判断过期就删掉重建——两个进程同时看到同一个过期锁,
532
+ 就可能都以为自己拿到了(八个进程抢一把锁,四十轮里最多有三十轮不止一个赢家)。
533
+ 现在用 `flock(2)`:进程退出或被杀,内核自动放锁;压力测试每 15 到 60 毫秒杀掉一个
534
+ 写入进程、连续六秒,临界区里从没出现过两个写方。
521
535
 
522
- 同批还修了一个限流回复引发的无限请求风暴——实测在一个正让我们慢下来的接口上
523
- 五秒打了六万多次请求。它是被新加的 HTTP 契约测试抓出来的:那些测试让真实的客户端类
524
- 去打一个本地回环服务,而不是把 SDK 打桩糊过去。
536
+ 这一版其余的修复,针对的都是会悄悄出错的数据:并发更新把刚删掉的记忆写回来、
537
+ 历史行指向不存在的记录、密钥留在 metadata 里还随 trigger 发给嵌入接口、
538
+ 搜 `git push 规则`、`#22` 这类查询直接返回空、写入被拒却在回执里报成已保存。
525
539
 
526
- 已有的 LanceDB 数据原地打开,不需要导出导入。
540
+ **升级:**先装 Bun,再停掉全部 RecallNest 进程后启动 v4——新旧两版的锁不能同时跑。
541
+ 有几处默认值和 HTTP 返回变了,清单在 CHANGELOG 的 v4.0.0 一节开头。
542
+ 已有的 LanceDB 数据原地打开。
527
543
 
528
- **完整变更历史(v3.0 到 v1.0,含每个版本的升级说明)在
544
+ **完整变更历史(v4.0 到 v1.0,含每个版本的升级说明)在
529
545
  [CHANGELOG.md](CHANGELOG.md)。**
530
546
 
531
547
 
package/assets/ui/app.js CHANGED
@@ -808,7 +808,12 @@ async function cleanDirtyBriefs() {
808
808
  await loadPins();
809
809
  await loadStats();
810
810
  renderMainSurface();
811
- statusLine.textContent = data.count > 0 ? 'Dirty briefs archived.' : 'No dirty briefs found.';
811
+ const unreadable = (data.readErrors || []).length;
812
+ if (unreadable > 0) {
813
+ statusLine.textContent = `${data.count > 0 ? 'Dirty briefs archived; ' : ''}${unreadable} brief file(s) could not be read.`;
814
+ } else {
815
+ statusLine.textContent = data.count > 0 ? 'Dirty briefs archived.' : 'No dirty briefs found.';
816
+ }
812
817
  } catch (error) {
813
818
  resultOutput.textContent = String(error.message || error);
814
819
  statusLine.textContent = 'Dirty brief cleanup failed.';
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * RecallNest CLI launcher — cross-runtime compatible
3
+ * RecallNest CLI launcher.
4
4
  *
5
- * Priority: bun (native TS, fastest) → node + tsx (fallback)
5
+ * RecallNest runs on Bun only: its cross-process lock calls flock(2) through bun:ffi
6
+ * (src/flock.ts), which Node cannot load. This launcher is plain JavaScript so that `npx` /
7
+ * a global npm install can start it under any runtime; it only ever hands off to Bun, and
8
+ * without Bun it exits with instructions instead of trying another runtime.
6
9
  */
7
10
  import { spawn, execFileSync } from "node:child_process";
8
11
  import { dirname, join } from "node:path";
9
12
  import { fileURLToPath } from "node:url";
10
- import { existsSync } from "node:fs";
11
13
 
12
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
15
  const cli = join(__dirname, "..", "src", "cli.ts");
@@ -22,41 +24,22 @@ function hasBun() {
22
24
  }
23
25
  }
24
26
 
25
- function run(command, commandArgs) {
26
- const child = spawn(command, commandArgs, { stdio: "inherit" });
27
- child.on("close", (code) => process.exit(code || 0));
28
- child.on("error", (err) => {
29
- console.error(`Failed to start ${command}: ${err.message}`);
30
- process.exit(1);
31
- });
32
- }
33
-
34
- if (hasBun()) {
35
- // Native bun — fastest path
36
- run("bun", ["run", cli, ...args]);
37
- } else {
38
- // Fallback: node + tsx
39
- const tsxBin = join(__dirname, "..", "node_modules", ".bin", "tsx");
40
- if (existsSync(tsxBin)) {
41
- run(tsxBin, [cli, ...args]);
42
- } else {
43
- // tsx not found in local node_modules, try global
44
- try {
45
- execFileSync("tsx", ["--version"], { stdio: "ignore" });
46
- run("tsx", [cli, ...args]);
47
- } catch {
48
- console.error(`
49
- RecallNest requires either Bun or tsx to run TypeScript.
50
-
51
- Option A (recommended):
52
- curl -fsSL https://bun.sh/install | bash
27
+ if (!hasBun()) {
28
+ console.error(`
29
+ RecallNest requires Bun (https://bun.sh) — it does not run on Node.js.
53
30
 
54
- Option B:
55
- npm install -g tsx
31
+ Install Bun:
32
+ curl -fsSL https://bun.sh/install | bash # macOS / Linux
33
+ brew install oven-sh/bun/bun # or with Homebrew
56
34
 
57
- Then retry your command.
35
+ Then open a new shell (so \`bun\` is on PATH) and retry your command.
58
36
  `);
59
- process.exit(1);
60
- }
61
- }
37
+ process.exit(1);
62
38
  }
39
+
40
+ const child = spawn("bun", ["run", cli, ...args], { stdio: "inherit" });
41
+ child.on("close", (code) => process.exit(code || 0));
42
+ child.on("error", (err) => {
43
+ console.error(`Failed to start bun: ${err.message}`);
44
+ process.exit(1);
45
+ });
@@ -225,6 +225,14 @@ Store a new durable memory entry.
225
225
 
226
226
  If the same `canonicalKey` is already occupied by a different durable category, RecallNest returns `disposition = "conflict"` plus a `conflictId` instead of silently creating a second durable owner.
227
227
 
228
+ `stored` is `true` (status `201`) only when the write landed new content: `disposition` is `stored`, `updated` or `promoted`. Otherwise the status is `200` and `stored` is `false`:
229
+
230
+ | `disposition` | Meaning | `id` |
231
+ |---|---|---|
232
+ | `deduped` | The content already exists; no new row was written | the existing memory |
233
+ | `conflict` | Canonical key owned by another category; parked in the conflict store | the existing owner |
234
+ | `rejected` | Turned away by admission control; `rejectionReason` says why (`text_too_short`, `noise_detected`, `importance_too_low`, `rate_limited`) | a placeholder, no row exists |
235
+
228
236
  For slot-aware preferences, RecallNest now derives structured keys instead of collapsing everything into a broader same-topic key. Examples:
229
237
 
230
238
  - atomic brand-item preference: `我喜欢吃麦当劳的麦辣鸡翅` -> `preferences:brand-item:麦当劳:麦辣鸡翅`
@@ -47,9 +47,12 @@ See [memory-boundary-contract.md](./memory-boundary-contract.md).
47
47
  │ │
48
48
  │ ┌─────────────┐ ┌──────────────┐ ┌────────────────────┐ │
49
49
  │ │ Retriever │ │ Classifier │ │ Query Expander │ │
50
- │ │ (hybrid: │ │ (6 categories│ │ (synonym + │ │
51
- │ │ vector + │ │ auto-assign)│ │ semantic expand) │ │
52
- │ │ BM25 + RRF) │ │ │ │ │ │
50
+ │ │ (default: │ │ (6 categories│ │ (synonym + │ │
51
+ │ │ vector-only;│ │ auto-assign)│ │ semantic expand) │ │
52
+ │ │ optional │ │ │ │ │ │
53
+ │ │ hybrid: │ │ │ │ │ │
54
+ │ │ weighted │ │ │ │ │ │
55
+ │ │ vector+BM25)│ │ │ │ │ │
53
56
  │ └──────┬───────┘ └──────────────┘ └────────────────────┘ │
54
57
  │ │ │
55
58
  │ ┌──────┴───────┐ ┌──────────────┐ ┌────────────────────┐ │
@@ -116,13 +119,15 @@ Query Results
116
119
  │
117
120
  ▼
118
121
  ┌──────────────┐ ┌──────────────┐ ┌───────────────┐
119
- │ Query │───►│ Hybrid │───►│ Post-process │──► Top-K
120
- │ Expander │ │ Retrieval │ │ │ Results
121
- │ (synonyms) │ │ │ │ - Decay │
122
- └──────────────┘ │ Vector: 0.7 │ │ - Access boost│
123
- │ BM25: 0.3 │ │ - Score floor │
124
- │ RRF merge │ │ - Dedup │
125
- └──────────────┘ └───────────────┘
122
+ │ Query │───►│ Retrieval │───►│ Post-process │──► Top-K
123
+ │ Expander │ │ default: │ │ │ Results
124
+ │ (synonyms) │ │ vector-only │ │ - Decay │
125
+ └──────────────┘ │ hybrid mode: │ │ - Access boost│
126
+ │ Vector: 0.7 │ │ - Score floor │
127
+ │ BM25: 0.3 │ │ - Dedup │
128
+ │ weighted │ └───────────────┘
129
+ │ fusion │
130
+ └──────────────┘
126
131
  ```
127
132
 
128
133
  ## Key Design Decisions
@@ -131,8 +136,8 @@ Query Results
131
136
  |----------|-----------|
132
137
  | **LanceDB** (not SQLite/Postgres) | Native vector search, columnar storage, zero-config, single-file DB |
133
138
  | **Jina v5** (not OpenAI embeddings) | Task-aware embeddings (query vs passage), better multilingual, 1024-dim sweet spot |
134
- | **Hybrid retrieval** (vector + BM25) | Vector alone misses keyword matches; BM25 alone misses semantic similarity |
135
- | **RRF fusion** | Reciprocal Rank Fusion is parameter-free and robust across score distributions |
139
+ | **Hybrid retrieval** (vector + BM25, optional) | Vector alone misses keyword matches; BM25 alone misses semantic similarity. Default `retrieval.mode` is `"vector"` (vector-only search; `DEFAULT_RETRIEVAL_CONFIG` in `src/retriever.ts`, `config.json.example`). Set `retrieval.mode` to `"hybrid"` in `config.json` to add BM25 full-text search; if the store has no full-text index, retrieval stays vector-only |
140
+ | **Weighted score fusion** (hybrid mode only) | Applies only when `retrieval.mode` is `"hybrid"` (default is `"vector"`). Hybrid mode combines vector and BM25 scores as a weighted average (defaults `vectorWeight: 0.7`, `bm25Weight: 0.3`), with a 5% bonus when both legs return the same memory; a memory found by only one leg is scored by that leg alone (BM25-only hits get ×1.15 on short queries). For short queries (≤ 4 tokens) the vector weight is multiplied by 0.7 and the BM25 weight by 1.5, capped at 0.6 (`fuseResults()` in `src/retriever.ts`) |
136
141
  | **Weibull decay** (not exponential) | Better models human forgetting: slow start, accelerating fade |
137
142
  | **6 categories** (not free-form tags) | Structured enough for filtering and lifecycle rules, simple enough to auto-classify |
138
143
  | **HTTP API + MCP** (not just MCP) | MCP is great for CLI tools, but HTTP API works with any language/framework |
@@ -146,7 +151,7 @@ src/
146
151
  ├── mcp-server.ts # MCP server (stdio transport)
147
152
  ├── cli.ts # CLI entry point (lm command)
148
153
  ├── store.ts # LanceDB storage layer
149
- ├── retriever.ts # Hybrid retrieval (vector + BM25 + RRF)
154
+ ├── retriever.ts # Hybrid retrieval (vector + BM25, weighted score fusion)
150
155
  ├── embedder.ts # Jina embedding client
151
156
  ├── ingest.ts # Multi-source ingestion pipeline
152
157
  ├── chunker.ts # Text chunking with noise filtering
@@ -4,7 +4,9 @@
4
4
  "query": "mem0 对 recallnest 有没有借鉴作用",
5
5
  "profile": "fact-check",
6
6
  "limit": 8,
7
- "targets": ["a2fe9b62"],
7
+ "targets": [
8
+ "a2fe9b62"
9
+ ],
8
10
  "expectTopK": 3,
9
11
  "notes": "A 单目标:mem0→recallnest 借鉴审计结论该被召回。"
10
12
  },
@@ -13,7 +15,9 @@
13
15
  "query": "recallnest 本地两个目录反复踩坑是怎么解决的",
14
16
  "profile": "debug",
15
17
  "limit": 8,
16
- "targets": ["dbcb317a"],
18
+ "targets": [
19
+ "dbcb317a"
20
+ ],
17
21
  "expectTopK": 3,
18
22
  "notes": "A 单目标:dual-dir 符号链接统一 case(dbcb317a, cases, imp 0.9, baseline rank1)。原 target e40ab64b 系标注错——e40ab64b 实为『RecallNest 演进优先 workflow pattern』,与本 query 无关;dbcb317a 才是『~/recallnest→~/Projects/recallnest symlink 统一』。"
19
23
  },
@@ -22,8 +26,13 @@
22
26
  "query": "recallnest 的 capture 是纯 heuristic 吗,提取到底用不用 LLM",
23
27
  "profile": "fact-check",
24
28
  "limit": 8,
25
- "targets": ["727da608"],
26
- "expectOrder": ["727da608", "a2fe9b62"],
29
+ "targets": [
30
+ "727da608"
31
+ ],
32
+ "expectOrder": [
33
+ "727da608",
34
+ "a2fe9b62"
35
+ ],
27
36
  "expectTopK": 3,
28
37
  "notes": "B 新旧对:727da608(cases,imp0.8) 正文明确『修正 a2fe9b62 中 capture 纯heuristic/zero-LLM 的不准确表述』,是本 query 准确答案;a2fe9b62(events,imp0.72,mem0借鉴审计) 含被纠正的旧表述。expectOrder 测『两者同时召回时准确版 727 须排在旧版 a2 前』——纯排名对比(rank 唯一,<与≤等价),不靠结构化 supersede。校准修正:原 notes『验 confidence/recency/decay』机制声称不成立(二者无 evolution 链/降权链,靠内容质量排序);且主话题不重合(727=ingest去重史 / a2=mem0审计),实测 a2 常落选(baseline rank=null),故本 case 当前主要测 727 召回质量+弱新旧序保护,非真正新旧竞争。理想 B 类应换一对同话题、会同时召回的新旧记忆(P1 后续)。"
29
38
  },
@@ -32,8 +41,12 @@
32
41
  "query": "工作记忆要不要做 scope 隔离",
33
42
  "profile": "default",
34
43
  "limit": 8,
35
- "targets": ["491c8b30"],
36
- "hardNegatives": ["b59e86b2"],
44
+ "targets": [
45
+ "491c8b30"
46
+ ],
47
+ "hardNegatives": [
48
+ "b59e86b2"
49
+ ],
37
50
  "expectTopK": 2,
38
51
  "notes": "C 干扰项:491c8b30(元决策『工作记忆不做 scope 隔离』,baseline rank1) 应召回。hardNegative b59e86b2(entities,imp0.65,scope=codex:019cfba5) 是 codex 端关于 scope 模型/associative access/隔离的设计讨论片段——词面同话题、别处语境(非 Alice 元决策),baseline rank2 进了 top。补此 hardNeg 后本 case 在当前裸 retrieve 下会 fail,暴露『跨-scope 旁支讨论被召回进 top』的真问题(契合 P0 scope 主题)。"
39
52
  },
@@ -42,7 +55,13 @@
42
55
  "query": "Alice 的写作风格偏好是什么",
43
56
  "profile": "writing",
44
57
  "limit": 6,
45
- "expectContentAny": ["口语化", "不端着", "剧评腔", "书面非口语", "可自嘲"],
58
+ "expectContentAny": [
59
+ "口语化",
60
+ "不端着",
61
+ "剧评腔",
62
+ "书面非口语",
63
+ "可自嘲"
64
+ ],
46
65
  "expectTopK": 3,
47
66
  "notes": "D 稳定偏好:不钉 id,召回文本命中要点即算对。"
48
67
  },
@@ -51,8 +70,87 @@
51
70
  "query": "Alice 的配图审美偏好",
52
71
  "profile": "default",
53
72
  "limit": 6,
54
- "expectContentAny": ["手绘", "撞色", "高对比", "静物无人物", "涂鸦"],
73
+ "expectContentAny": [
74
+ "手绘",
75
+ "撞色",
76
+ "高对比",
77
+ "静物无人物",
78
+ "涂鸦"
79
+ ],
55
80
  "expectTopK": 3,
56
81
  "notes": "D 稳定偏好:同上。"
82
+ },
83
+ {
84
+ "name": "canary-assoc-onboarding-written",
85
+ "query": "接新的 AI 是不是又要从头讲一遍",
86
+ "profile": "default",
87
+ "scope": "memory:pivot",
88
+ "limit": 8,
89
+ "targets": [
90
+ "5a9affa2"
91
+ ],
92
+ "expectTopK": 5,
93
+ "notes": "assoc 对照组:query 就是正文尾巴写下的『她以后会这么问起』原句。09-22 基线 #1。"
94
+ },
95
+ {
96
+ "name": "canary-assoc-onboarding-zero",
97
+ "query": "新接的那个 app 答应会遵守约定,结果每次开新对话都当没听过",
98
+ "profile": "default",
99
+ "scope": "memory:pivot",
100
+ "limit": 8,
101
+ "targets": [
102
+ "5a9affa2"
103
+ ],
104
+ "expectTopK": 5,
105
+ "notes": "assoc 零词面重合:三层规则结构 / 口头承诺不是机制。09-22 基线 #1(path=vector)。"
106
+ },
107
+ {
108
+ "name": "canary-assoc-rebuild-pool",
109
+ "query": "要不要把老的那十几万条清一清",
110
+ "profile": "default",
111
+ "scope": "memory:pivot",
112
+ "limit": 8,
113
+ "targets": [
114
+ "9f908ad2"
115
+ ],
116
+ "expectTopK": 5,
117
+ "notes": "assoc:08-01 从零建池不修补存量、12.9 万条不删。09-22 基线 #1。"
118
+ },
119
+ {
120
+ "name": "canary-assoc-commit-gate",
121
+ "query": "记忆写进去之前要不要先让人审一下再入库",
122
+ "profile": "default",
123
+ "scope": "memory:pivot",
124
+ "limit": 8,
125
+ "targets": [
126
+ "545562b9",
127
+ "a81ccffb"
128
+ ],
129
+ "expectTopK": 5,
130
+ "notes": "assoc:提案/提交分离早就有 + deepbrain 否决。09-22 基线 #1 / #5。"
131
+ },
132
+ {
133
+ "name": "canary-assoc-benchmark-tmem",
134
+ "query": "T-Mem 论文里那个榜单我们能不能也上去比比",
135
+ "profile": "default",
136
+ "scope": "memory:pivot",
137
+ "limit": 8,
138
+ "targets": [
139
+ "64df5691"
140
+ ],
141
+ "expectTopK": 5,
142
+ "notes": "assoc 已知坏样本①:07-25 公开 benchmark 否决,正文尾巴已带『她以后可能会这样问起』句。09-22 基线 #7(limit 5 漏)。"
143
+ },
144
+ {
145
+ "name": "canary-assoc-benchmark-tencent",
146
+ "query": "腾讯那篇论文的榜我们要不要也去排个名",
147
+ "profile": "default",
148
+ "scope": "memory:pivot",
149
+ "limit": 8,
150
+ "targets": [
151
+ "64df5691"
152
+ ],
153
+ "expectTopK": 5,
154
+ "notes": "assoc 已知坏样本②:同上目标,措辞换成『榜 / 排名』。09-22 基线 top-10 不出现。"
57
155
  }
58
- ]
156
+ ]
@@ -113,6 +113,8 @@ async function handleTool(
113
113
  }),
114
114
  });
115
115
  if (!res.ok) return `Error: ${res.status} ${await res.text()}`;
116
+ const data = await res.json() as { stored: boolean; disposition: string; id: string; rejectionReason?: string };
117
+ if (!data.stored) return `Memory not stored (${data.disposition}: ${data.rejectionReason ?? data.id}).`;
116
118
  return "Memory stored successfully.";
117
119
  }
118
120
 
@@ -78,6 +78,10 @@ def store_memory(text: str, category: str = "events") -> str:
78
78
  },
79
79
  )
80
80
  r.raise_for_status()
81
+ data = r.json()
82
+ if not data.get("stored"):
83
+ reason = data.get("rejectionReason") or data.get("id")
84
+ return f"Memory not stored ({data.get('disposition')}: {reason})."
81
85
  return "Memory stored successfully."
82
86
 
83
87
 
@@ -79,6 +79,10 @@ async def store_memory(text: str, category: str = "events") -> str:
79
79
  },
80
80
  )
81
81
  r.raise_for_status()
82
+ data = r.json()
83
+ if not data.get("stored"):
84
+ reason = data.get("rejectionReason") or data.get("id")
85
+ return f"Memory not stored ({data.get('disposition')}: {reason})."
82
86
  return "Memory stored successfully."
83
87
 
84
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recallnest",
3
- "version": "3.0.1",
3
+ "version": "4.0.0",
4
4
  "description": "RecallNest — MCP-native, local-first memory search and distillation for AI conversations",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,6 +46,7 @@
46
46
  "eval": "bun run src/eval.ts",
47
47
  "eval:continuity": "bun run src/eval.ts --mode continuity --cases eval/continuity/cases.json",
48
48
  "eval:canary": "bun run src/eval.ts --mode canary --cases eval/cases-canary.json",
49
+ "eval:known-item": "bun run eval/known-item.ts run",
49
50
  "profiles": "bun run src/cli.ts profiles",
50
51
  "doctor": "bun run src/cli.ts doctor",
51
52
  "demo": "bun run src/cli.ts demo",
@@ -61,10 +62,17 @@
61
62
  "engines": {
62
63
  "node": ">=22"
63
64
  },
65
+ "overrides": {
66
+ "fast-uri": "^3.1.8",
67
+ "qs": "^6.16.0",
68
+ "hono": "^4.13.7",
69
+ "ip-address": "^10.7.1",
70
+ "proxy-addr": "^2.0.8"
71
+ },
64
72
  "dependencies": {
65
73
  "@hono/node-server": "1.19.17",
66
74
  "@lancedb/lancedb": "^0.26.0",
67
- "@modelcontextprotocol/sdk": "^1.30.0",
75
+ "@modelcontextprotocol/sdk": "^1.31.0",
68
76
  "commander": "^12.0.0",
69
77
  "openai": "^7.5.0",
70
78
  "tsx": "^4.19.0",
@@ -104,6 +104,7 @@ const ACTIVITY_LOCK_KEY = "activity-stats";
104
104
  const ACTIVITY_LOCK_OPTS = { onBusy: "wait" as const, waitTimeoutMs: 2_000, expireMs: 30_000 };
105
105
 
106
106
  async function withStatsLock<T>(fn: () => T): Promise<T> {
107
+ // lock-wait: no store-write inside — fn only reads and rewrites the activity stats file.
107
108
  const outcome = await withLock(ACTIVITY_LOCK_KEY, async () => fn(), ACTIVITY_LOCK_OPTS);
108
109
  // onBusy:"wait" resolves ran:true or throws on timeout; ran:false unreachable.
109
110
  if (!outcome.ran) throw new Error("activity-stats lock unavailable");
@@ -74,3 +74,26 @@ export function shouldSkipRetrieval(query: string, minLength?: number): boolean
74
74
 
75
75
  return false;
76
76
  }
77
+
78
+ /**
79
+ * 检索入口用的判定:这条检索要不要直接返回空。
80
+ *
81
+ * 上面那套规则是「一条聊天消息值不值得自动检索」的判定(省嵌入调用),只该管自动召回。
82
+ * 人主动发起的查询(MCP search_memory、CLI、HTTP、本地 UI)以 `git` / `docker` / `test` 开头、
83
+ * 纯数字或 `#22`、不含中文又短于 15 字的短语,都是正常的查法;被闸掉时返回的空结果和
84
+ * 「库里确实没有」看不出区别。所以:
85
+ * - `trivialQueryGate` 显式给了就按它;
86
+ * - 没给时只在 `source === "auto-recall"` 时开闸——`source` 同时管着三处强化写入,
87
+ * 有些人主动发起的检索(search_memory 的相关 scope 旁路、known-item eval)为了不写库也标成
88
+ * auto-recall,它们用 `trivialQueryGate: false` 把闸关掉;
89
+ * - 空串与纯空白不管哪条路都跳过。
90
+ */
91
+ export function shouldSkipRetrievalFor(context: {
92
+ query: string;
93
+ source?: "manual" | "auto-recall" | "cli";
94
+ trivialQueryGate?: boolean;
95
+ }): boolean {
96
+ if (context.query.trim().length === 0) return true;
97
+ const gate = context.trivialQueryGate ?? context.source === "auto-recall";
98
+ return gate && shouldSkipRetrieval(context.query);
99
+ }
package/src/api-server.ts CHANGED
@@ -7,10 +7,13 @@
7
7
  */
8
8
 
9
9
  import { createComponentResolver, loadConfig, loadDotEnv } from "./runtime-config.js";
10
+ import { HTTP_WRITE_IDLE_TIMEOUT_S } from "./store-write-budget.js";
10
11
  import { DurableMemoryCategorySchema } from "./memory-schema.js";
11
12
  import { persistCaseMemory, persistMemory, persistMemoryBatch, persistWorkflowPattern, promoteMemory } from "./capture-engine.js";
12
13
  import { buildSessionCheckpointResult } from "./session-engine.js";
14
+ import { buildStoreHttpResponse } from "./store-receipts.js";
13
15
  import { SessionCheckpointStore } from "./session-store.js";
16
+ import { mirrorCheckpointWithDeadline } from "./checkpoint-mirror.js";
14
17
  import { composeResumeContext } from "./context-composer.js";
15
18
  import { extractMemoryProvenance } from "./memory-boundaries.js";
16
19
  import { ConflictStatusSchema } from "./conflict-schema.js";
@@ -242,17 +245,8 @@ async function handleStore(request: Request): Promise<Response> {
242
245
  try {
243
246
  const { store, embedder } = getComponents();
244
247
  const stored = await persistMemory({ store, embedder, conflictStore, auditLogger }, body);
245
-
246
- return jsonResponse({
247
- id: stored.id,
248
- stored: stored.disposition !== "conflict",
249
- disposition: stored.disposition,
250
- storedAt: stored.storedAt,
251
- category: stored.category,
252
- scope: stored.resolvedScope,
253
- canonicalKey: stored.canonicalKey,
254
- conflictId: stored.conflictId,
255
- }, 201);
248
+ const response = buildStoreHttpResponse(stored);
249
+ return jsonResponse(response.body, response.status);
256
250
  } catch (err) {
257
251
  const message = err instanceof Error ? err.message : String(err);
258
252
  return errorResponse(400, message);
@@ -454,6 +448,11 @@ async function handleCheckpoint(request: Request): Promise<Response> {
454
448
  ...result,
455
449
  record: stored,
456
450
  }));
451
+ // Mirror into the memory table so search can find it; never throws, capped wait.
452
+ await mirrorCheckpointWithDeadline(() => {
453
+ const { store, embedder } = getComponents();
454
+ return { store, embedder };
455
+ }, stored);
457
456
  return jsonResponse(stored, 201);
458
457
  } catch (err) {
459
458
  const message = err instanceof Error ? err.message : String(err);
@@ -651,6 +650,9 @@ const port = clampInt(envConfig.apiPortRaw(), 4318, 1, 65535);
651
650
 
652
651
  const server = Bun.serve({
653
652
  port,
653
+ // Writes can wait up to 30 s for store-write before they start; Bun's 10 s default would drop
654
+ // the connection while the handler still commits.
655
+ idleTimeout: HTTP_WRITE_IDLE_TIMEOUT_S,
654
656
  hostname: LOCAL_HTTP_HOSTNAME,
655
657
  async fetch(request) {
656
658
  const policy = await enforceLocalHttpRequestPolicy(request);
package/src/asset-sync.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Embedder } from "./embedder.js";
2
- import type { MemoryStore } from "./store.js";
3
- import type { BriefAsset, MemoryAsset, PinAsset } from "./memory-assets.js";
2
+ import type { MemoryEntry, MemoryStore } from "./store.js";
3
+ import { buildPinAsset, savePinAsset, type BriefAsset, type MemoryAsset, type PinAsset } from "./memory-assets.js";
4
+ import type { RetrievalProfileName } from "./retrieval-profiles.js";
4
5
 
5
6
  function buildPinnedAssetText(asset: PinAsset): string {
6
7
  const tags = asset.tags.join(", ");
@@ -77,3 +78,26 @@ export async function indexAsset(
77
78
  }
78
79
 
79
80
  export const indexPinnedAsset = indexAsset;
81
+
82
+ /**
83
+ * pin_memory(MCP 工具、CLI `pin`、本地 UI `/api/pin` 共用):先把这条记忆的 importance 提到至少 0.95,
84
+ * update() 返回 null(行在查到之后被删掉了)就返回 null——不写 pin 文件、不建索引副本,由调用方报 not found;
85
+ * 写成功才用 update() 返回的那一行生成 pin。2026-10-17 起 update() 不再把被删的行插回来,
86
+ * 原先这里不看返回值,会给一条已经不存在的记忆留下 pin 文件和索引副本。
87
+ */
88
+ export async function pinMemory(
89
+ store: MemoryStore,
90
+ embedder: Embedder,
91
+ entry: MemoryEntry,
92
+ scopeFilter: string[] | undefined,
93
+ options: { title?: string; summary?: string; query?: string; profile?: RetrievalProfileName },
94
+ save: (asset: PinAsset) => string = savePinAsset,
95
+ ): Promise<{ asset: PinAsset; path: string; entry: MemoryEntry } | null> {
96
+ const updated = await store.update(entry.id, { importance: Math.max(entry.importance || 0.7, 0.95) }, scopeFilter);
97
+ if (!updated) return null;
98
+ const score = updated.importance || 0.7;
99
+ const asset = buildPinAsset({ entry: updated, score, sources: { fused: { score } } }, options);
100
+ const path = save(asset);
101
+ await indexAsset(store, embedder, asset);
102
+ return { asset, path, entry: updated };
103
+ }