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.
- package/CHANGELOG.md +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
package/README_CN.md
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
[](https://github.com/AliceLJY/recallnest)
|
|
12
12
|
[](LICENSE)
|
|
13
|
-
[](https://bun.sh)
|
|
14
14
|
[](https://lancedb.com)
|
|
15
15
|
[](https://modelcontextprotocol.io)
|
|
16
16
|
[](https://github.com/AliceLJY/recallnest/actions/workflows/ci.yml)
|
|
17
17
|
[](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
|
|
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
|
-
|
|
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` |
|
|
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
|
-
**
|
|
519
|
-
|
|
520
|
-
|
|
530
|
+
**v4.0** 只在 **Bun** 上运行,跨进程锁交给了内核。以前 RecallNest 靠锁文件里的
|
|
531
|
+
pid 和时间戳判断锁是不是过期了,判断过期就删掉重建——两个进程同时看到同一个过期锁,
|
|
532
|
+
就可能都以为自己拿到了(八个进程抢一把锁,四十轮里最多有三十轮不止一个赢家)。
|
|
533
|
+
现在用 `flock(2)`:进程退出或被杀,内核自动放锁;压力测试每 15 到 60 毫秒杀掉一个
|
|
534
|
+
写入进程、连续六秒,临界区里从没出现过两个写方。
|
|
521
535
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
536
|
+
这一版其余的修复,针对的都是会悄悄出错的数据:并发更新把刚删掉的记忆写回来、
|
|
537
|
+
历史行指向不存在的记录、密钥留在 metadata 里还随 trigger 发给嵌入接口、
|
|
538
|
+
搜 `git push 规则`、`#22` 这类查询直接返回空、写入被拒却在回执里报成已保存。
|
|
525
539
|
|
|
526
|
-
|
|
540
|
+
**升级:**先装 Bun,再停掉全部 RecallNest 进程后启动 v4——新旧两版的锁不能同时跑。
|
|
541
|
+
有几处默认值和 HTTP 返回变了,清单在 CHANGELOG 的 v4.0.0 一节开头。
|
|
542
|
+
已有的 LanceDB 数据原地打开。
|
|
527
543
|
|
|
528
|
-
**完整变更历史(
|
|
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
|
-
|
|
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.';
|
package/bin/recallnest.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* RecallNest CLI launcher
|
|
3
|
+
* RecallNest CLI launcher.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/docs/api-reference.md
CHANGED
|
@@ -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:麦当劳:麦辣鸡翅`
|
package/docs/architecture.md
CHANGED
|
@@ -47,9 +47,12 @@ See [memory-boundary-contract.md](./memory-boundary-contract.md).
|
|
|
47
47
|
│ │
|
|
48
48
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────┐ │
|
|
49
49
|
│ │ Retriever │ │ Classifier │ │ Query Expander │ │
|
|
50
|
-
│ │ (
|
|
51
|
-
│ │ vector
|
|
52
|
-
│ │
|
|
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 │───►│
|
|
120
|
-
│ Expander │ │
|
|
121
|
-
│ (synonyms) │ │
|
|
122
|
-
└──────────────┘ │
|
|
123
|
-
│
|
|
124
|
-
│
|
|
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
|
-
| **
|
|
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
|
|
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
|
package/eval/cases-canary.json
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
"query": "mem0 对 recallnest 有没有借鉴作用",
|
|
5
5
|
"profile": "fact-check",
|
|
6
6
|
"limit": 8,
|
|
7
|
-
"targets": [
|
|
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": [
|
|
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": [
|
|
26
|
-
|
|
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": [
|
|
36
|
-
|
|
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
|
+
"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.
|
|
75
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
68
76
|
"commander": "^12.0.0",
|
|
69
77
|
"openai": "^7.5.0",
|
|
70
78
|
"tsx": "^4.19.0",
|
package/src/activity-counter.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|