oh-memos-mcp 3.1.4 → 3.1.5
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 +234 -0
- package/dist/api-client.js +14 -5
- package/dist/handlers/memory.d.ts +16 -0
- package/dist/handlers/memory.js +24 -5
- package/dist/memory-tier-boundary.d.ts +146 -0
- package/dist/memory-tier-boundary.js +373 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,240 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [3.1.5] - 2026-08-26
|
|
10
|
+
|
|
11
|
+
3.1.1~3.1.4 都是「仅 MCP server」的版本,Python 包与容器镜像停在 3.1.0。
|
|
12
|
+
**3.1.5 两侧同时有改动**,三个版本串(npm 包、`pyproject.toml`、`src/oh_memos/__init__.py`)
|
|
13
|
+
因此重新对齐到 3.1.5。Python/容器侧的内容见仓库根的 `docs/CHANGELOG.md`。
|
|
14
|
+
|
|
15
|
+
### 🧾 `memos_get` 的 not-found 文案指向真正的成因
|
|
16
|
+
|
|
17
|
+
旧文案让用户「核对 id 是否正确(从 `memos_search` 结果复制)」,但实测中最常见的成因恰恰是
|
|
18
|
+
**从搜索结果复制来的 id 过一会儿就失效**:`POST /search` 返回的是 `WorkingMemory` 层的副本 id,
|
|
19
|
+
该层按 `user_name` 只保留 20 条(后端 `manager.py` 的 `memory_size["WorkingMemory"]`),
|
|
20
|
+
每次检索都重写并淘汰最旧的。实测 `oh_memos_cube` 的那 20 条只覆盖 22 分钟。
|
|
21
|
+
|
|
22
|
+
因此新文案把 `memos_list_v2` 指出来是有实质区别的:它返回 `LongTermMemory` 的 id,
|
|
23
|
+
那一层不淘汰(实测同一批 id 隔天仍可取回)。措辞上不把淘汰断言成事实 ——
|
|
24
|
+
Lite 模式(本地 JSONL)没有 `WorkingMemory` 层,那里的 not-found 就是单纯的 id 不存在。
|
|
25
|
+
|
|
26
|
+
`notFoundText` 同时改为导出,便于测试直接断言文案而不经由 handler。
|
|
27
|
+
|
|
28
|
+
### 🌐 API 客户端保留 400 响应体
|
|
29
|
+
|
|
30
|
+
`apiCallWithRetry` 此前把非 2xx 一律丢弃响应体,于是后端在 400 里给出的
|
|
31
|
+
校验错误说明拿不到,工具层只能报一个无信息的失败。现在 400 单独放行并解析 JSON,
|
|
32
|
+
后端的错误描述可以原样呈现给调用方。
|
|
33
|
+
|
|
34
|
+
### 🔒 分层过滤的强制机制
|
|
35
|
+
|
|
36
|
+
同一个缺陷已复发 7 次:新增一条检索路径,忘了把 `WorkingMemory` 短期副本滤掉,
|
|
37
|
+
于是把一个几分钟后就失效的 id 交给了 agent。每次都靠 review 抓到,这次改成结构性拦截。
|
|
38
|
+
|
|
39
|
+
**为什么普通单测拦不住**:实测把所有层级过滤切断后,319 项 vitest 依然全绿。
|
|
40
|
+
单测问的是"过滤函数算得对不对",而缺陷形态是"过滤函数没被调用" —— 两个问题不同。
|
|
41
|
+
|
|
42
|
+
新增 `src/memory-tier-boundary.ts`(清单)与同名 `.test.ts`(守卫,18 项)。
|
|
43
|
+
清单把 `handlers/index.ts` 的 28 条分派路由逐条分类并附实测证据,守卫扫描源码与分派表做双向比对。
|
|
44
|
+
四条 fail-closed 棘轮,均以变异测试验证过确实会红:
|
|
45
|
+
|
|
46
|
+
- 分派表新增路由却没在清单里分类 → 失败;清单有而分派表已无(陈旧条目)→ 也失败
|
|
47
|
+
- 静默新增已知缺口 → 失败(`KNOWN_GAPS` 为冻结集合,计数上限硬编码)
|
|
48
|
+
- 修好缺口却忘了从 `KNOWN_GAPS` 移除 → 失败,否则集合会膨胀成一张无人清理的豁免名单
|
|
49
|
+
- `CYPHER_PROJECTION_BASELINE` 按严格相等断言,修好了不调基线同样失败 —— 棘轮只能往紧的方向走
|
|
50
|
+
|
|
51
|
+
**覆盖了第二种复发形态**:过滤接上了,但构造 metadata 时漏掉 `memory_type`,
|
|
52
|
+
判定函数恒读到 `undefined` 并判为可见。`search.ts` 曾因此让四个调用点的过滤同时空转,
|
|
53
|
+
而没有任何测试失败。守卫现在会检查投影记忆的 Cypher `RETURN` 是否带出该字段。
|
|
54
|
+
|
|
55
|
+
**已记录但尚未修的缺口**(`memos_think`、`memos_graph` 的三条路由):
|
|
56
|
+
`think.ts` 上朴素过滤会把语义召回从 15 条砍到 5 条,正确解法是按 `metadata.key`
|
|
57
|
+
换成持久层孪生节点的 id,而不是加过滤。不变式的准确表述是"永不把易失 id 交给 agent",
|
|
58
|
+
"过滤掉 `WorkingMemory`" 只是其中一种(有损的)满足方式。
|
|
59
|
+
|
|
60
|
+
门禁:vitest 491 通过(新增 18),`tsc --noEmit` 无输出。
|
|
61
|
+
|
|
62
|
+
## [3.1.4] - 2026-08-24
|
|
63
|
+
|
|
64
|
+
### 🐛 修复:同源节点列表混入 scheduler 短期副本
|
|
65
|
+
|
|
66
|
+
3.1.3 上线后实测:同源列表里每个 `key` **成对出现** —— 一条被切成 6 段的记忆列出了 12 条同源。
|
|
67
|
+
|
|
68
|
+
后端对每条抽取结果写两个节点:一个 `WorkingMemory` 短期副本 + 一个 `LongTermMemory`
|
|
69
|
+
(少数为 `UserMemory`)持久节点,`key` 与 `created_at` 逐字相同。`findSiblings` 没有滤层级,
|
|
70
|
+
于是短期副本一并列出。
|
|
71
|
+
|
|
72
|
+
**这不是新决策,是 3.1.0 已经做过的决定**(`memory-tier.ts`)。`memos_search` 与
|
|
73
|
+
`memos_list_v2` 早已在滤,`findSiblings` 是 3.1.2 新增的路径 —— 又漏了一处。
|
|
74
|
+
同一形态在本项目已出现五次:**新写的检索路径必须继承既有的分层过滤决定。**
|
|
75
|
+
|
|
76
|
+
逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 时不滤,与 `filterEphemeralTier` 语义一致。
|
|
77
|
+
缺 `memory_type` 视为可见 —— Lite 的 JSONL 不写该字段。
|
|
78
|
+
|
|
79
|
+
#### 测试
|
|
80
|
+
|
|
81
|
+
新增 5 项分层断言(共 48 项),三个变异全部被捕获:不滤层级(回到本次缺陷)、
|
|
82
|
+
忽略逃生开关、忽略 limit。其中「limit 在滤层级之后生效」用交错排列的候选集断言 ——
|
|
83
|
+
顺序反了会让 limit 被随即隐藏的副本吃掉,只返回一半。
|
|
84
|
+
|
|
85
|
+
门禁:vitest 447 passed、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
86
|
+
protocol v2、lite smoke、host-env smoke。
|
|
87
|
+
|
|
88
|
+
## [3.1.3] - 2026-08-24
|
|
89
|
+
|
|
90
|
+
### 🐛 修复:同源碎片区块从不出现 —— `sources` 有两种线上形态
|
|
91
|
+
|
|
92
|
+
3.1.2 上线后实测:原文能正确返回,但「同源碎片」区块**从不出现**。根因是同一个字段
|
|
93
|
+
在两个端点上形态不同:
|
|
94
|
+
|
|
95
|
+
| 端点 | `sources[0]` 的形态 |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `GET /memories/{cube}/{id}`(单条) | **对象** `{type, role, chat_time, content}` |
|
|
98
|
+
| `GET /memories`(列表) | **JSON 字符串** `'{"type":...,"content":"..."}'` |
|
|
99
|
+
|
|
100
|
+
`verbatimOf` 初版只处理对象形态。单条取回走对象 → 原文正常;同源查找的候选集来自
|
|
101
|
+
列表端点 → 全部返回 null → 永远匹配不到。**原文层可用,配对层静默失效。**
|
|
102
|
+
|
|
103
|
+
单测没抓住的原因很直接:fixture 只造了对象形态,两种形态里只测了一种。
|
|
104
|
+
|
|
105
|
+
修法:抽出 `contentOf(entry)` 同时接受对象与 JSON 字符串。非 JSON 字符串返回 null
|
|
106
|
+
而**不猜它是裸原文** —— 否则任意字符串都会被当原文,把无关记忆归成同源。
|
|
107
|
+
坏 JSON 按无 content 处理不抛异常:这是展示层,脏数据不该让 `memos_get` 失败。
|
|
108
|
+
|
|
109
|
+
#### 测试
|
|
110
|
+
|
|
111
|
+
新增 6 项断言与一个 list 形态 fixture(共 43 项)。三个变异全部被捕获,
|
|
112
|
+
其中「不处理字符串形态」即回到本次修复前的行为,另两个覆盖「非 JSON 当裸原文」
|
|
113
|
+
与「坏 JSON 抛异常」。跨形态指纹一致性单独断言 —— 否则单条与列表永远配不上。
|
|
114
|
+
|
|
115
|
+
门禁:vitest 442 passed、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
116
|
+
protocol v2、lite smoke、host-env smoke。
|
|
117
|
+
|
|
118
|
+
## [3.1.2] - 2026-08-23
|
|
119
|
+
|
|
120
|
+
### 📄 `memos_get` 返回原文,不再只返回 LLM 概括
|
|
121
|
+
|
|
122
|
+
开启 LLM 抽取(后端 `MOS_TYPED_SAVE_FAST=false`)时,后端把一次写入拆成多条细粒度记忆:
|
|
123
|
+
每条的 `memory` 字段是 LLM 概括,而**逐字原文完整保留在 `metadata.sources[0].content`**。
|
|
124
|
+
实测一条 1056 字的写入被拆成 5 条,每条 `memory` 约 244 字,5 条共享同一份原文。
|
|
125
|
+
`memos_get` 此前只读 `memory`,于是完整上下文取不回来。
|
|
126
|
+
|
|
127
|
+
这不是取舍问题 —— 库里本来就是两份数据,各有各的用途:
|
|
128
|
+
|
|
129
|
+
| 层 | 内容 | 用途 |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `memory` | LLM 概括(细粒度) | 向量化输入、建边对象 → 决定图谱节点与联想质量 |
|
|
132
|
+
| `metadata.sources[].content` | 逐字原文 | `memos_get` 的完整上下文来源 |
|
|
133
|
+
|
|
134
|
+
**新输出结构**(有原文时):原文作为 `### Content` 正文;LLM 概括降级到
|
|
135
|
+
`### 抽取概括` 区块并保留 —— 它是向量化与建边的实际输入,看得见才能判断图谱节点切得对不对;
|
|
136
|
+
`### 同源碎片` 列出由同一次写入抽出的其他记忆(id + key),从而能看出 LLM 的切分方式。
|
|
137
|
+
无原文时(fast-path 写入)输出与此前完全一致。
|
|
138
|
+
|
|
139
|
+
同源判定用原文全文,不用 `session_id` —— 同一会话里的无关写入会被错误归组。
|
|
140
|
+
|
|
141
|
+
新增 `src/verbatim-source.ts`:`verbatimOf` / `sourceFingerprint` / `findSiblings` /
|
|
142
|
+
`renderVerbatimSections`,四个纯函数,handler 与测试调用同一份实现。
|
|
143
|
+
Full 与 Lite 两条路径都已接线(Lite 的 JSONL 无同源概念,列表恒空)。
|
|
144
|
+
同源查询失败时返回空列表,不影响原文返回。
|
|
145
|
+
|
|
146
|
+
#### 测试
|
|
147
|
+
|
|
148
|
+
新增 `src/verbatim-source.test.ts`(37 项)。**变异验证抓出三个断言缺乏判别力**,
|
|
149
|
+
每一个都是「测试写了但等于没写」的形态:
|
|
150
|
+
|
|
151
|
+
- 接线守卫数两条路径**合计**调用次数 `>= 2`,而 Lite 路径自身就有 2 次,
|
|
152
|
+
砍掉 Full 路径那次仍然通过。改为按路径分别断言。
|
|
153
|
+
- 「原文不截断」用 30 字测试原文,而变异截断到 200 字 —— 改不动它。
|
|
154
|
+
测试原文改为 600+ 字并加尾部标记。**这条正是本次要修的缺陷,却最后才抓住。**
|
|
155
|
+
- `"not-an-array"[0]` 是字符 `"n"`、无 `.content`,宽松实现也返回 null。
|
|
156
|
+
改用伪装成数组的对象才有判别力。
|
|
157
|
+
|
|
158
|
+
顺带删除 `sourceFingerprint` 的长度前缀:变异验证时构造不出它能防住的碰撞,
|
|
159
|
+
无测试支撑的防御代码比没有更糟。
|
|
160
|
+
|
|
161
|
+
12 个变异被捕获(W1「Full 路径不读原文」即回到本次修复前的行为)。
|
|
162
|
+
门禁:vitest 436/436、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
163
|
+
protocol v2、lite smoke。
|
|
164
|
+
|
|
165
|
+
## [3.1.1] - 2026-08-22
|
|
166
|
+
|
|
167
|
+
### 🐛 修复:`memos_context_resume` 每条记忆成对出现
|
|
168
|
+
|
|
169
|
+
3.1.0 装好后 `memos_search` 与 `memos_list_v2` 都已正确隐藏 `WorkingMemory` 短期副本,
|
|
170
|
+
但 `memos_context_resume` 仍成对显示(10 条 = 5 对,内容逐字相同、UUID 不同)。
|
|
171
|
+
|
|
172
|
+
**根因不止是漏接一条路径。** temporal 查询的 Cypher 不返回 `memory_type`,
|
|
173
|
+
构造出的 metadata 只有 `relativity`/`temporal_rank`/`source` —— 于是分层判定永远
|
|
174
|
+
读到 `undefined` 并判为可见,**下游任何过滤对 temporal 记忆都是空转**。
|
|
175
|
+
受影响的是四个调用点,不只是 `memos_context_resume`:
|
|
176
|
+
|
|
177
|
+
| 路径 | 3.1.0 的状态 |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `memos_context_resume` | 成对显示(用户实测发现) |
|
|
180
|
+
| `memos_search` temporal intent(两处) | 有过滤代码,但读不到字段 |
|
|
181
|
+
| `memos_think` | 同上 |
|
|
182
|
+
|
|
183
|
+
后三处一直漏着且不易察觉 —— 过滤代码存在且看起来合理,只是作用对象缺字段。
|
|
184
|
+
|
|
185
|
+
修法:在 Cypher 里排除 `WorkingMemory` 并返回 `memory_type`。这是唯一对四处都成立的做法,
|
|
186
|
+
且 `LIMIT` 在过滤之后 —— 要 N 条就得 N 条真记忆,无需超额取数。
|
|
187
|
+
`memos_context_resume` 的 API 回退路径由服务端施加 limit,无法先过滤,故超额取 3 倍。
|
|
188
|
+
|
|
189
|
+
逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 仍然有效:为真时不加排除条件,
|
|
190
|
+
但仍返回 `memory_type` —— 开关只关过滤,不关可观测性。
|
|
191
|
+
|
|
192
|
+
#### 测试
|
|
193
|
+
|
|
194
|
+
新增 `src/context-resume.test.ts`(22 项),**9 个变异全部被捕获**,
|
|
195
|
+
其中 W1「API 路径不过滤」即回到本次报告的原缺陷。
|
|
196
|
+
|
|
197
|
+
新增 `npm run test:host-env-smoke`:用 MCP host 配置里的**原样环境**驱动 server,
|
|
198
|
+
且不继承外层环境变量。已有的 spread smoke 自带硬编码 Neo4j 凭据兜底,
|
|
199
|
+
因此「host 读不到凭据」这一失败形态它测不出来 —— 实测该形态下检索照常返回记忆、
|
|
200
|
+
但联想标注为 0,属静默降级。
|
|
201
|
+
|
|
202
|
+
门禁:vitest 416/416、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
203
|
+
protocol v2、lite smoke、spread smoke、host-env smoke。
|
|
204
|
+
|
|
205
|
+
## [3.1.0] - 2026-08-22
|
|
206
|
+
|
|
207
|
+
### 🧠 检索排序:衰减、强化、分档去重与图扩散联想
|
|
208
|
+
|
|
209
|
+
本版把检索侧的排序从「纯相似度」改为「相似度 + 时间衰减 + 访问强化」,并加入按类型分档的
|
|
210
|
+
近重复折叠与一跳图扩散联想。
|
|
211
|
+
|
|
212
|
+
#### 记忆层级:`WorkingMemory` 默认隐藏
|
|
213
|
+
|
|
214
|
+
后端 scheduler 会为每条持久记忆写一份 `WorkingMemory` 短期副本,该层按用户仅保留最近 20 条、
|
|
215
|
+
每次检索都会重写淘汰。把这些 id 交给 agent 意味着它拿到的引用几分钟后就失效。
|
|
216
|
+
新增 `src/memory-tier.ts`,`memos_search` 与 `memos_list_v2` 默认滤掉该层;
|
|
217
|
+
逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 可关闭过滤。缺 `memory_type` 视为可见
|
|
218
|
+
(Lite 的 JSONL 不写该字段)。
|
|
219
|
+
|
|
220
|
+
#### 衰减与强化
|
|
221
|
+
|
|
222
|
+
排序分数加入按 `created_at` 的时间衰减与按访问次数的强化项,两者都可用环境变量关掉,
|
|
223
|
+
默认开启。访问计数由 `src/access-tracker.ts` 本地维护,不写回后端。
|
|
224
|
+
|
|
225
|
+
#### 近重复折叠(按类型分档)
|
|
226
|
+
|
|
227
|
+
同类型内容高度重合的记忆折叠为一条并标注折叠数。分档按 `memory_type` ——
|
|
228
|
+
不同类型的相似文本(一条 `DECISION` 与一条 `BUGFIX`)不应互相折叠。
|
|
229
|
+
|
|
230
|
+
#### 一跳图扩散联想(`MEMOS_SPREAD_ACTIVATION`,默认关闭)
|
|
231
|
+
|
|
232
|
+
命中记忆沿知识图谱做一跳扩散,把强关联但关键词不匹配的记忆带进结果并标注来源。
|
|
233
|
+
默认关闭,因为它需要可用的 Neo4j 凭据;读不到凭据时静默返回 0 条联想。
|
|
234
|
+
|
|
235
|
+
#### 检索期注解
|
|
236
|
+
|
|
237
|
+
结果里标注衰减后分数、访问次数与折叠数,使排序结果可解释而非黑箱。
|
|
238
|
+
|
|
239
|
+
#### graph schema 四处缺陷修复
|
|
240
|
+
|
|
241
|
+
`memos_graph` 的 schema 模式修正四处:节点计数口径、边类型去重、孤立节点统计与采样上限。
|
|
242
|
+
|
|
9
243
|
## [3.0.1] - 2026-08-19
|
|
10
244
|
|
|
11
245
|
### 🩹 修复 3.0.0 打包出的多余依赖与失效仓库链接
|
package/dist/api-client.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* HTTP fetch wrapper with timeout, retry, and health check.
|
|
5
5
|
*/
|
|
6
|
-
import { MEMOS_URL, MEMOS_API_WAIT_MAX, MEMOS_TIMEOUT_HEALTH, MEMOS_TIMEOUT_TOOL, logger, registeredCubes } from "./config.js";
|
|
6
|
+
import { MEMOS_URL, MEMOS_API_WAIT_MAX, MEMOS_TIMEOUT_HEALTH, MEMOS_TIMEOUT_TOOL, logger, registeredCubes, } from "./config.js";
|
|
7
7
|
// ============================================================================
|
|
8
8
|
// Fetch with Timeout
|
|
9
9
|
// ============================================================================
|
|
@@ -77,7 +77,7 @@ async function doFetch(method, url, options) {
|
|
|
77
77
|
return { status: response.status, data: null };
|
|
78
78
|
}
|
|
79
79
|
try {
|
|
80
|
-
const data = await response.json();
|
|
80
|
+
const data = (await response.json());
|
|
81
81
|
return { status: response.status, data };
|
|
82
82
|
}
|
|
83
83
|
catch {
|
|
@@ -96,7 +96,9 @@ export async function apiCallWithRetry(method, url, cubeId, options = {}, ensure
|
|
|
96
96
|
const [regSuccess] = await ensureCubeRegistered(cubeId, true);
|
|
97
97
|
if (regSuccess) {
|
|
98
98
|
const retry = await doFetch(method, url, options);
|
|
99
|
-
if (retry.status === 200 &&
|
|
99
|
+
if (retry.status === 200 &&
|
|
100
|
+
retry.data &&
|
|
101
|
+
retry.data.code === 200) {
|
|
100
102
|
return { success: true, data: retry.data, status: 200 };
|
|
101
103
|
}
|
|
102
104
|
return { success: false, data: retry.data, status: 200 };
|
|
@@ -110,11 +112,18 @@ export async function apiCallWithRetry(method, url, cubeId, options = {}, ensure
|
|
|
110
112
|
const [regSuccess] = await ensureCubeRegistered(cubeId, true);
|
|
111
113
|
if (regSuccess) {
|
|
112
114
|
const retry = await doFetch(method, url, options);
|
|
113
|
-
if (retry.status === 200 &&
|
|
115
|
+
if (retry.status === 200 &&
|
|
116
|
+
retry.data &&
|
|
117
|
+
retry.data.code === 200) {
|
|
114
118
|
return { success: true, data: retry.data, status: 200 };
|
|
115
119
|
}
|
|
120
|
+
// 重注册没救回来 —— 说明这不是「cube 未加载」,而是真实的业务错误。
|
|
121
|
+
// 必须把正文带回去:后端把 not-found 也映射成 400(start_api.py 的
|
|
122
|
+
// ValueError handler),唯一能区分的信息就在 message 里。丢掉它会让
|
|
123
|
+
// 调用方只能打印无用的 "HTTP 400"。重试正文优先,缺失则退回首次的。
|
|
124
|
+
return { success: false, data: retry.data ?? data, status: 400 };
|
|
116
125
|
}
|
|
117
|
-
return { success: false, data
|
|
126
|
+
return { success: false, data, status: 400 };
|
|
118
127
|
}
|
|
119
128
|
return { success: false, data, status };
|
|
120
129
|
}
|
|
@@ -7,6 +7,22 @@ import type { TextContent, MemoryNode, SearchData } from "../types.js";
|
|
|
7
7
|
export declare function handleMemosSave(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
8
8
|
export declare function handleMemosList(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
9
9
|
export declare function handleMemosGet(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
10
|
+
/**
|
|
11
|
+
* not-found 文案。
|
|
12
|
+
*
|
|
13
|
+
* 旧文案说「核对 id 是否正确(从 memos_search 结果复制)」,但实测中最常见的
|
|
14
|
+
* 成因恰恰是**从搜索结果复制的 id 过了一会儿就失效**:`POST /search` 返回的
|
|
15
|
+
* 是 `WorkingMemory` 层的副本 id,而该层按 `user_name` 只保留 20 条
|
|
16
|
+
* (`manager.py` 的 `memory_size["WorkingMemory"]`),每次检索都重写并淘汰最旧的。
|
|
17
|
+
* 实测 `oh_memos_cube` 的 20 条只覆盖 22 分钟。
|
|
18
|
+
*
|
|
19
|
+
* 所以把 `memos_list_v2` 指出来是有实质区别的:它返回 `LongTermMemory` 的 id,
|
|
20
|
+
* 那一层不淘汰(实测同一批 id 隔天仍可取回)。
|
|
21
|
+
*
|
|
22
|
+
* 措辞上不把淘汰断言成事实 —— Lite 模式(本地 JSONL)根本没有 WorkingMemory
|
|
23
|
+
* 层,那里的 not-found 就是单纯的 id 不存在。
|
|
24
|
+
*/
|
|
25
|
+
export declare function notFoundText(memoryId: string): string;
|
|
10
26
|
export declare function handleMemosGetStats(arguments_: Record<string, unknown>): Promise<TextContent[]>;
|
|
11
27
|
export declare function extractMemoriesFromData(data: SearchData): MemoryNode[];
|
|
12
28
|
/**
|
package/dist/handlers/memory.js
CHANGED
|
@@ -342,14 +342,33 @@ export async function handleMemosGet(arguments_) {
|
|
|
342
342
|
return apiErrorResponse("Get", `HTTP ${result.status}`);
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
|
-
|
|
345
|
+
/**
|
|
346
|
+
* not-found 文案。
|
|
347
|
+
*
|
|
348
|
+
* 旧文案说「核对 id 是否正确(从 memos_search 结果复制)」,但实测中最常见的
|
|
349
|
+
* 成因恰恰是**从搜索结果复制的 id 过了一会儿就失效**:`POST /search` 返回的
|
|
350
|
+
* 是 `WorkingMemory` 层的副本 id,而该层按 `user_name` 只保留 20 条
|
|
351
|
+
* (`manager.py` 的 `memory_size["WorkingMemory"]`),每次检索都重写并淘汰最旧的。
|
|
352
|
+
* 实测 `oh_memos_cube` 的 20 条只覆盖 22 分钟。
|
|
353
|
+
*
|
|
354
|
+
* 所以把 `memos_list_v2` 指出来是有实质区别的:它返回 `LongTermMemory` 的 id,
|
|
355
|
+
* 那一层不淘汰(实测同一批 id 隔天仍可取回)。
|
|
356
|
+
*
|
|
357
|
+
* 措辞上不把淘汰断言成事实 —— Lite 模式(本地 JSONL)根本没有 WorkingMemory
|
|
358
|
+
* 层,那里的 not-found 就是单纯的 id 不存在。
|
|
359
|
+
*/
|
|
360
|
+
export function notFoundText(memoryId) {
|
|
346
361
|
return [
|
|
347
362
|
`❌ Memory not found: \`${memoryId}\``,
|
|
348
363
|
"",
|
|
349
|
-
"💡
|
|
350
|
-
"-
|
|
351
|
-
"
|
|
352
|
-
"-
|
|
364
|
+
"💡 **可能原因**:",
|
|
365
|
+
"- **id 已失效**(最常见):`memos_search` 返回的是短期层(WorkingMemory)id,",
|
|
366
|
+
" 该层每用户仅保留最近 20 条,新检索会淘汰旧的。取回长期 id 请用 `memos_list_v2`。",
|
|
367
|
+
"- 记忆确实已被删除",
|
|
368
|
+
"- cube 不对:确认传了正确的 `project_path`",
|
|
369
|
+
"",
|
|
370
|
+
"✅ **下一步**: 用 `memos_search` 重新检索拿到当前有效 id,",
|
|
371
|
+
"或用 `memos_list_v2` 拿持久 id。",
|
|
353
372
|
].join("\n");
|
|
354
373
|
}
|
|
355
374
|
// ============================================================================
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分层过滤的**强制机制**:让"新增检索路径漏过滤"无法通过构建。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要结构性强制,而不是继续靠 review
|
|
5
|
+
*
|
|
6
|
+
* 同一个缺陷已复发七次。每次都是新增一条取记忆的路径,忘记过滤掉
|
|
7
|
+
* scheduler 管理的 WorkingMemory 层。`handlers/memory.ts` 的注释记了实测
|
|
8
|
+
* 结论:**切断层级过滤后全部 319 项 vitest 依然全绿** —— 单测对这个缺陷
|
|
9
|
+
* 没有感知能力,因为它测的是"过滤函数正确",不是"过滤被接上了"。
|
|
10
|
+
*
|
|
11
|
+
* ## 真正的不变量
|
|
12
|
+
*
|
|
13
|
+
* 不是"过滤掉 WorkingMemory",而是 **不把易失 id 交给 agent**。
|
|
14
|
+
*
|
|
15
|
+
* WorkingMemory 每用户只保留最近 20 条(`manager.py:75`),新检索会淘汰
|
|
16
|
+
* 旧的 —— 实测窗口只有 22 分钟。agent 拿这种 id 去 `memos_get`,后端
|
|
17
|
+
* `text_mem.get` 抛 ValueError,被 `start_api.py:1493` 的全局 handler 映射成
|
|
18
|
+
* **HTTP 400**。这就是用户报的那个 400 的成因。
|
|
19
|
+
*
|
|
20
|
+
* 过滤只是满足该不变量的**一种**方式,而且是有损的那种:
|
|
21
|
+
* `filterEphemeralTier` 那条"全 ephemeral 则原样返回"的保护,本身就承认
|
|
22
|
+
* 丢弃不可接受 —— 但原样返回又恰好泄露易失 id。它在两个 bug 之间换手。
|
|
23
|
+
*
|
|
24
|
+
* 实测 `POST /search` 返回 **100% WorkingMemory**,所以 search 路径上那个
|
|
25
|
+
* 过滤是**恒空转**的。无损的替代是**孪生解析**:按 `key` 换成持久层的同容
|
|
26
|
+
* id(实测 51/51 命中,内容与 created_at 逐字节相同,持久层重复 key = 0)。
|
|
27
|
+
*
|
|
28
|
+
* ## 两种复发形态,必须分别守
|
|
29
|
+
*
|
|
30
|
+
* 1. **压根没接过滤** —— `think.ts` / `graph.ts` 就是这形态。
|
|
31
|
+
* 2. **过滤接了但读不到字段** —— 更隐蔽。`search.ts:84-104` 记录了实例:
|
|
32
|
+
* temporal Cypher 原先不 RETURN `memory_type`,构造出的 metadata 里没这个
|
|
33
|
+
* 键,`isEphemeralTier()` 永远读到 undefined 判为可见,**四个调用点的过滤
|
|
34
|
+
* 全部空转,且没有任何测试失败**。
|
|
35
|
+
*
|
|
36
|
+
* 所以本模块导出两组判定:`hasTierSignal`(形态 1)与
|
|
37
|
+
* `cypherThreadsMemoryType`(形态 2)。
|
|
38
|
+
*
|
|
39
|
+
* ## fail-closed 是本机制的全部价值
|
|
40
|
+
*
|
|
41
|
+
* 分派表是可枚举的。守卫把清单与实际分派表**逐条比对**:新增一个工具而
|
|
42
|
+
* 没在清单里分类 → 构建失败。"忘记"这个动作本身被挡住,而不是等 review
|
|
43
|
+
* 或等用户实测发现。
|
|
44
|
+
*
|
|
45
|
+
* 已知缺口不藏进豁免名单,而是显式记为 `known-gap` 并冻结集合:静默新增
|
|
46
|
+
* 缺口 → 失败;修好一个 → 必须从集合里删掉。是棘轮,不是地毯。
|
|
47
|
+
*
|
|
48
|
+
* 设计文档:docs/design/memory-retrieval-optimization.md 第 9.6 节
|
|
49
|
+
*/
|
|
50
|
+
/** 一条分派路由的分类。 */
|
|
51
|
+
export type TierClass =
|
|
52
|
+
/** 经过 filterEphemeralTier / prepare* 包装,且信号可静态查到。 */
|
|
53
|
+
"filtered"
|
|
54
|
+
/**
|
|
55
|
+
* 过滤已接线,但"全 ephemeral 则原样返回"会让它空转。
|
|
56
|
+
* 与 `filtered` 分开是因为修复动作不同:这类要等孪生解析落地。
|
|
57
|
+
*/
|
|
58
|
+
| "filtered-noop-risk"
|
|
59
|
+
/** 只回显 agent 自己传进来的 id,不产生新 id。 */
|
|
60
|
+
| "id-passthrough"
|
|
61
|
+
/** 输出里没有任何记忆 id。 */
|
|
62
|
+
| "no-id"
|
|
63
|
+
/** 结构上只可能产出持久层 id(实测确认)。 */
|
|
64
|
+
| "persistent-by-construction"
|
|
65
|
+
/** 确认漏过滤,且已记录原因与修复前提。 */
|
|
66
|
+
| "known-gap";
|
|
67
|
+
/** 清单里的一条。 */
|
|
68
|
+
export interface RouteContract {
|
|
69
|
+
/** 分派键:工具名,或 `工具名:mode/action`。 */
|
|
70
|
+
readonly route: string;
|
|
71
|
+
/** 实现该路由的 handler 文件(相对 `src/handlers/`)。 */
|
|
72
|
+
readonly file: string;
|
|
73
|
+
readonly tierClass: TierClass;
|
|
74
|
+
/**
|
|
75
|
+
* 分类依据。要求写成可核验的形态(行号、实测数字),
|
|
76
|
+
* 不接受"应该没问题"这类无法反驳的说法。
|
|
77
|
+
*/
|
|
78
|
+
readonly evidence: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 该 handler 源码里是否存在可达的层级信号。
|
|
82
|
+
*
|
|
83
|
+
* 只做存在性判断 —— "信号在正确位置"由各 handler 自己的接线测试负责
|
|
84
|
+
* (形态见 `context-resume.test.ts` 的 `describe("handler 接线")`)。
|
|
85
|
+
* 本模块管"一条路由有没有被想过",粒度不同,不要混。
|
|
86
|
+
*/
|
|
87
|
+
export declare function hasTierSignal(source: string): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* 抽出 Cypher 里投影记忆节点的 RETURN 子句。
|
|
90
|
+
*
|
|
91
|
+
* 只取真的在投影记忆的(含 `.memory`),避免把 schema/统计类查询的 RETURN
|
|
92
|
+
* 也拉进来判。
|
|
93
|
+
*/
|
|
94
|
+
export declare function extractMemoryReturns(source: string): readonly string[];
|
|
95
|
+
/**
|
|
96
|
+
* 投影记忆的 Cypher 是否把 `memory_type` 也带出来。
|
|
97
|
+
*
|
|
98
|
+
* 这是形态 2 的守卫。漏了这个字段,下游过滤读到 undefined 判为可见 ——
|
|
99
|
+
* 过滤形同不存在,且没有任何测试会红。
|
|
100
|
+
*/
|
|
101
|
+
export declare function cypherThreadsMemoryType(clause: string): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* 从 `handlers/index.ts` 源码里解析出全部分派路由。
|
|
104
|
+
*
|
|
105
|
+
* 用缩进宽度区分顶层与嵌套 `case`:顶层 4 空格,`memos_graph` /
|
|
106
|
+
* `memos_admin` 内层的 mode/action 是 8 空格。比匹配整个 switch 块稳,
|
|
107
|
+
* 也不需要引 TS AST。
|
|
108
|
+
*
|
|
109
|
+
* 嵌套 case 归属由"最近的顶层 case"决定,产出 `memos_graph:path` 这样的
|
|
110
|
+
* 复合键 —— 每个 mode 各自是一条路由,它们走不同 handler,层级安全性独立。
|
|
111
|
+
*
|
|
112
|
+
* `default:` 也算一条(键为 `工具名:default`),否则 `memos_graph` 的
|
|
113
|
+
* `mode=related`(走 default)会整条漏掉 —— 而它恰好是已确认的缺口之一。
|
|
114
|
+
*/
|
|
115
|
+
export declare function parseDispatchRoutes(indexSource: string): readonly string[];
|
|
116
|
+
/**
|
|
117
|
+
* 全部分派路由的层级契约。
|
|
118
|
+
*
|
|
119
|
+
* 每条的 `evidence` 都来自实测或行号,不是推断。守卫会拿这份清单与
|
|
120
|
+
* `parseDispatchRoutes()` 的结果逐条比对,两边不一致就失败 —— 所以新增
|
|
121
|
+
* 工具时**必须**在这里补一条,否则构建不过。这是整个机制的锚点。
|
|
122
|
+
*/
|
|
123
|
+
export declare const ROUTE_CONTRACTS: readonly RouteContract[];
|
|
124
|
+
/**
|
|
125
|
+
* 当前已知的缺口,**冻结集合**。
|
|
126
|
+
*
|
|
127
|
+
* 与 `ROUTE_CONTRACTS` 里的 `known-gap` 必须完全一致 —— 守卫双向比对:
|
|
128
|
+
* 静默新增缺口 → 失败;修好一个却忘了从这里删 → 也失败。后者同样重要,
|
|
129
|
+
* 否则集合会永久膨胀成一张无人清理的豁免名单。
|
|
130
|
+
*/
|
|
131
|
+
export declare const KNOWN_GAPS: readonly string[];
|
|
132
|
+
/**
|
|
133
|
+
* 形态 2 的基线棘轮:每个文件里"投影了记忆但没带 memory_type"的 RETURN 条数。
|
|
134
|
+
*
|
|
135
|
+
* 为什么要基线而不是一律要求 0:`graph.ts` 那 4 处全部落在 KNOWN_GAPS 已冻结
|
|
136
|
+
* 的三条路由里。若断言恒为 0,套件从第一天起就是红的 —— 一个永远红的守卫等于
|
|
137
|
+
* 没有守卫,下一个人只会把它注释掉。
|
|
138
|
+
*
|
|
139
|
+
* 断言用**严格相等**,不是 `<=`:
|
|
140
|
+
* - 新增一处漏字段的投影 → 数字变大 → 失败,这是要防的复发。
|
|
141
|
+
* - 修好一处 → 数字变小 → 也失败,强制回来把基线调下去。棘轮只能往紧的方向走。
|
|
142
|
+
*
|
|
143
|
+
* 未列入此表的文件,隐含基线为 0:新文件一旦出现这种投影就立刻红。
|
|
144
|
+
*/
|
|
145
|
+
export declare const CYPHER_PROJECTION_BASELINE: Readonly<Record<string, number>>;
|
|
146
|
+
//# sourceMappingURL=memory-tier-boundary.d.ts.map
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分层过滤的**强制机制**:让"新增检索路径漏过滤"无法通过构建。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要结构性强制,而不是继续靠 review
|
|
5
|
+
*
|
|
6
|
+
* 同一个缺陷已复发七次。每次都是新增一条取记忆的路径,忘记过滤掉
|
|
7
|
+
* scheduler 管理的 WorkingMemory 层。`handlers/memory.ts` 的注释记了实测
|
|
8
|
+
* 结论:**切断层级过滤后全部 319 项 vitest 依然全绿** —— 单测对这个缺陷
|
|
9
|
+
* 没有感知能力,因为它测的是"过滤函数正确",不是"过滤被接上了"。
|
|
10
|
+
*
|
|
11
|
+
* ## 真正的不变量
|
|
12
|
+
*
|
|
13
|
+
* 不是"过滤掉 WorkingMemory",而是 **不把易失 id 交给 agent**。
|
|
14
|
+
*
|
|
15
|
+
* WorkingMemory 每用户只保留最近 20 条(`manager.py:75`),新检索会淘汰
|
|
16
|
+
* 旧的 —— 实测窗口只有 22 分钟。agent 拿这种 id 去 `memos_get`,后端
|
|
17
|
+
* `text_mem.get` 抛 ValueError,被 `start_api.py:1493` 的全局 handler 映射成
|
|
18
|
+
* **HTTP 400**。这就是用户报的那个 400 的成因。
|
|
19
|
+
*
|
|
20
|
+
* 过滤只是满足该不变量的**一种**方式,而且是有损的那种:
|
|
21
|
+
* `filterEphemeralTier` 那条"全 ephemeral 则原样返回"的保护,本身就承认
|
|
22
|
+
* 丢弃不可接受 —— 但原样返回又恰好泄露易失 id。它在两个 bug 之间换手。
|
|
23
|
+
*
|
|
24
|
+
* 实测 `POST /search` 返回 **100% WorkingMemory**,所以 search 路径上那个
|
|
25
|
+
* 过滤是**恒空转**的。无损的替代是**孪生解析**:按 `key` 换成持久层的同容
|
|
26
|
+
* id(实测 51/51 命中,内容与 created_at 逐字节相同,持久层重复 key = 0)。
|
|
27
|
+
*
|
|
28
|
+
* ## 两种复发形态,必须分别守
|
|
29
|
+
*
|
|
30
|
+
* 1. **压根没接过滤** —— `think.ts` / `graph.ts` 就是这形态。
|
|
31
|
+
* 2. **过滤接了但读不到字段** —— 更隐蔽。`search.ts:84-104` 记录了实例:
|
|
32
|
+
* temporal Cypher 原先不 RETURN `memory_type`,构造出的 metadata 里没这个
|
|
33
|
+
* 键,`isEphemeralTier()` 永远读到 undefined 判为可见,**四个调用点的过滤
|
|
34
|
+
* 全部空转,且没有任何测试失败**。
|
|
35
|
+
*
|
|
36
|
+
* 所以本模块导出两组判定:`hasTierSignal`(形态 1)与
|
|
37
|
+
* `cypherThreadsMemoryType`(形态 2)。
|
|
38
|
+
*
|
|
39
|
+
* ## fail-closed 是本机制的全部价值
|
|
40
|
+
*
|
|
41
|
+
* 分派表是可枚举的。守卫把清单与实际分派表**逐条比对**:新增一个工具而
|
|
42
|
+
* 没在清单里分类 → 构建失败。"忘记"这个动作本身被挡住,而不是等 review
|
|
43
|
+
* 或等用户实测发现。
|
|
44
|
+
*
|
|
45
|
+
* 已知缺口不藏进豁免名单,而是显式记为 `known-gap` 并冻结集合:静默新增
|
|
46
|
+
* 缺口 → 失败;修好一个 → 必须从集合里删掉。是棘轮,不是地毯。
|
|
47
|
+
*
|
|
48
|
+
* 设计文档:docs/design/memory-retrieval-optimization.md 第 9.6 节
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* 能满足不变量的信号形态。
|
|
52
|
+
*
|
|
53
|
+
* 必须接受多种形态而不是只认一个函数名:`wiki-export.ts:324` 用的是内联
|
|
54
|
+
* `String(meta.memory_type ?? "") === "WorkingMemory"`,只认
|
|
55
|
+
* `filterEphemeralTier` 会把它误判成缺口。
|
|
56
|
+
*/
|
|
57
|
+
const TIER_SIGNALS = [
|
|
58
|
+
/filterEphemeralTier\s*\(/,
|
|
59
|
+
/isEphemeralTier\s*\(/,
|
|
60
|
+
/prepare(?:List|Resume|Search)Memories\s*\(/,
|
|
61
|
+
/resolvePersistentTwins\s*\(/,
|
|
62
|
+
/memory_type\s*\?\?\s*""\s*\)\s*===\s*"WorkingMemory"/,
|
|
63
|
+
/<>\s*'WorkingMemory'/,
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* 该 handler 源码里是否存在可达的层级信号。
|
|
67
|
+
*
|
|
68
|
+
* 只做存在性判断 —— "信号在正确位置"由各 handler 自己的接线测试负责
|
|
69
|
+
* (形态见 `context-resume.test.ts` 的 `describe("handler 接线")`)。
|
|
70
|
+
* 本模块管"一条路由有没有被想过",粒度不同,不要混。
|
|
71
|
+
*/
|
|
72
|
+
export function hasTierSignal(source) {
|
|
73
|
+
return TIER_SIGNALS.some((re) => re.test(source));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 抽出 Cypher 里投影记忆节点的 RETURN 子句。
|
|
77
|
+
*
|
|
78
|
+
* 只取真的在投影记忆的(含 `.memory`),避免把 schema/统计类查询的 RETURN
|
|
79
|
+
* 也拉进来判。
|
|
80
|
+
*/
|
|
81
|
+
export function extractMemoryReturns(source) {
|
|
82
|
+
const returns = [];
|
|
83
|
+
const re = /RETURN\b([\s\S]*?)(?=\n\s*(?:ORDER|LIMIT|MATCH|WHERE|UNION|\}|`))/gi;
|
|
84
|
+
for (const m of source.matchAll(re)) {
|
|
85
|
+
const clause = m[1];
|
|
86
|
+
if (/\.memory\b/.test(clause))
|
|
87
|
+
returns.push(clause);
|
|
88
|
+
}
|
|
89
|
+
return returns;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 投影记忆的 Cypher 是否把 `memory_type` 也带出来。
|
|
93
|
+
*
|
|
94
|
+
* 这是形态 2 的守卫。漏了这个字段,下游过滤读到 undefined 判为可见 ——
|
|
95
|
+
* 过滤形同不存在,且没有任何测试会红。
|
|
96
|
+
*/
|
|
97
|
+
export function cypherThreadsMemoryType(clause) {
|
|
98
|
+
return /memory_type/.test(clause);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 从 `handlers/index.ts` 源码里解析出全部分派路由。
|
|
102
|
+
*
|
|
103
|
+
* 用缩进宽度区分顶层与嵌套 `case`:顶层 4 空格,`memos_graph` /
|
|
104
|
+
* `memos_admin` 内层的 mode/action 是 8 空格。比匹配整个 switch 块稳,
|
|
105
|
+
* 也不需要引 TS AST。
|
|
106
|
+
*
|
|
107
|
+
* 嵌套 case 归属由"最近的顶层 case"决定,产出 `memos_graph:path` 这样的
|
|
108
|
+
* 复合键 —— 每个 mode 各自是一条路由,它们走不同 handler,层级安全性独立。
|
|
109
|
+
*
|
|
110
|
+
* `default:` 也算一条(键为 `工具名:default`),否则 `memos_graph` 的
|
|
111
|
+
* `mode=related`(走 default)会整条漏掉 —— 而它恰好是已确认的缺口之一。
|
|
112
|
+
*/
|
|
113
|
+
export function parseDispatchRoutes(indexSource) {
|
|
114
|
+
const routes = [];
|
|
115
|
+
let currentTop = null;
|
|
116
|
+
let nestedSeen = false;
|
|
117
|
+
for (const line of indexSource.split("\n")) {
|
|
118
|
+
// 字符类必须含数字 —— `memos_list_v2` 带版本号后缀。写成 [a-z_]+ 会让这
|
|
119
|
+
// 条路由整个消失在清单外,而它正是有过滤的路径之一:守卫会变成一份
|
|
120
|
+
// 自称完整、实际漏了一条的名单。这是本机制要防的同一类缺陷,
|
|
121
|
+
// 初版就踩了一次。
|
|
122
|
+
const top = /^ {4}case "([a-z0-9_]+)":/.exec(line);
|
|
123
|
+
if (top) {
|
|
124
|
+
// 上一个顶层 case 没有任何嵌套 → 它本身就是一条路由
|
|
125
|
+
if (currentTop && !nestedSeen)
|
|
126
|
+
routes.push(currentTop);
|
|
127
|
+
currentTop = top[1];
|
|
128
|
+
nestedSeen = false;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const nested = /^ {8}case "([a-z0-9_]+)":/.exec(line);
|
|
132
|
+
if (nested && currentTop) {
|
|
133
|
+
routes.push(`${currentTop}:${nested[1]}`);
|
|
134
|
+
nestedSeen = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (/^ {8}default:/.test(line) && currentTop) {
|
|
138
|
+
routes.push(`${currentTop}:default`);
|
|
139
|
+
nestedSeen = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (currentTop && !nestedSeen)
|
|
143
|
+
routes.push(currentTop);
|
|
144
|
+
return routes;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 全部分派路由的层级契约。
|
|
148
|
+
*
|
|
149
|
+
* 每条的 `evidence` 都来自实测或行号,不是推断。守卫会拿这份清单与
|
|
150
|
+
* `parseDispatchRoutes()` 的结果逐条比对,两边不一致就失败 —— 所以新增
|
|
151
|
+
* 工具时**必须**在这里补一条,否则构建不过。这是整个机制的锚点。
|
|
152
|
+
*/
|
|
153
|
+
export const ROUTE_CONTRACTS = [
|
|
154
|
+
// ---- memory.ts ----
|
|
155
|
+
{
|
|
156
|
+
route: "memos_save",
|
|
157
|
+
file: "memory.ts",
|
|
158
|
+
tierClass: "persistent-by-construction",
|
|
159
|
+
evidence: "实测:POST /memories 返回的 id 在 Neo4j 里是 UserMemory(持久层),不是 WorkingMemory。",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
route: "memos_list_v2",
|
|
163
|
+
file: "memory.ts",
|
|
164
|
+
tierClass: "filtered",
|
|
165
|
+
evidence: "prepareListMemories 内含 filterEphemeralTier;memory.ts:230/293 输出 id。",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
route: "memos_get",
|
|
169
|
+
file: "memory.ts",
|
|
170
|
+
tierClass: "id-passthrough",
|
|
171
|
+
evidence: "memory.ts:344 只消费 agent 传入的 memory_id,不产出新 id。",
|
|
172
|
+
},
|
|
173
|
+
// ---- search.ts ----
|
|
174
|
+
{
|
|
175
|
+
route: "memos_search",
|
|
176
|
+
file: "search.ts",
|
|
177
|
+
tierClass: "filtered-noop-risk",
|
|
178
|
+
evidence: "过滤已接线(search.ts:399/466 输出 id);但实测 POST /search 返回 100% WorkingMemory," +
|
|
179
|
+
"filterEphemeralTier 的全-ephemeral 保护使其恒空转 —— 需孪生解析才能真正满足不变量。",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
route: "memos_suggest",
|
|
183
|
+
file: "search.ts",
|
|
184
|
+
tierClass: "no-id",
|
|
185
|
+
evidence: "只返回建议 query 字符串,输出里无记忆 id。",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
route: "memos_context_resume",
|
|
189
|
+
file: "search.ts",
|
|
190
|
+
tierClass: "filtered",
|
|
191
|
+
evidence: "prepareResumeMemories + buildTemporalCypher 的 tierFilter 双保险;接线由 context-resume.test.ts 守。",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
route: "memos_think",
|
|
195
|
+
file: "think.ts",
|
|
196
|
+
tierClass: "known-gap",
|
|
197
|
+
evidence: "think.ts:214 逐条渲染 `> id:`,无任何层级信号。实测:朴素过滤会把语义召回整段丢掉" +
|
|
198
|
+
"(15 → 5,掉 10 条)—— /search 只贡献 ephemeral,temporal 贡献持久,全-ephemeral 保护不触发。" +
|
|
199
|
+
"修复前提是孪生解析,不是加过滤。",
|
|
200
|
+
},
|
|
201
|
+
// ---- graph.ts ----
|
|
202
|
+
{
|
|
203
|
+
route: "memos_graph:default",
|
|
204
|
+
file: "graph.ts",
|
|
205
|
+
tierClass: "known-gap",
|
|
206
|
+
evidence: "mode=related。graph.ts:396 把 search 返回的 id 当 Cypher 种子,无层级信号。" +
|
|
207
|
+
"更严重:实测 172 个 WorkingMemory 节点共 0 条边,该查询恒空,永远静默退化到关键词兜底 —— " +
|
|
208
|
+
"是功能失效,不只是 id 泄露。",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
route: "memos_graph:path",
|
|
212
|
+
file: "graph.ts",
|
|
213
|
+
tierClass: "known-gap",
|
|
214
|
+
evidence: "graph.ts:271-273 按 id 取路径并 RETURN 节点 id,无层级信号。",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
route: "memos_graph:impact",
|
|
218
|
+
file: "graph.ts",
|
|
219
|
+
tierClass: "known-gap",
|
|
220
|
+
evidence: "graph.ts:419-421 RETURN source/target id,无层级信号。",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
route: "memos_graph:schema",
|
|
224
|
+
file: "graph.ts",
|
|
225
|
+
tierClass: "no-id",
|
|
226
|
+
evidence: "graph.ts:571 只输出计数与类型分布(memory_types),不含任何记忆 id。",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
route: "memos_graph:import",
|
|
230
|
+
file: "graph.ts",
|
|
231
|
+
tierClass: "no-id",
|
|
232
|
+
evidence: "Graphify 校验为 dry-run,只回报校验结果,不写库也不吐 id。",
|
|
233
|
+
},
|
|
234
|
+
// ---- wiki 往返 ----
|
|
235
|
+
{
|
|
236
|
+
route: "memos_export_wiki",
|
|
237
|
+
file: "wiki-export.ts",
|
|
238
|
+
tierClass: "filtered",
|
|
239
|
+
evidence: 'wiki-export.ts:324 内联 String(meta.memory_type ?? "") === "WorkingMemory" 过滤;' +
|
|
240
|
+
"id 写入文件而非 agent 输出。",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
route: "memos_import_wiki",
|
|
244
|
+
file: "wiki-import.ts",
|
|
245
|
+
tierClass: "id-passthrough",
|
|
246
|
+
evidence: "wiki-import.ts:271 消费 wiki 页面里已有的 id,不产出新 id。",
|
|
247
|
+
},
|
|
248
|
+
// ---- 本地文件类,不经 API ----
|
|
249
|
+
{
|
|
250
|
+
route: "memos_canvas",
|
|
251
|
+
file: "canvas.ts",
|
|
252
|
+
tierClass: "id-passthrough",
|
|
253
|
+
evidence: "canvas.ts:90 只回显 agent 传入的 `mem:<id>` ref,不检索记忆。",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
route: "memos_distill_skill",
|
|
257
|
+
file: "skill.ts",
|
|
258
|
+
tierClass: "id-passthrough",
|
|
259
|
+
evidence: "skill.ts:12 消费 agent 传入的 memory_ids 作为证据来源。",
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
route: "memos_list_skill_candidates",
|
|
263
|
+
file: "skill.ts",
|
|
264
|
+
tierClass: "no-id",
|
|
265
|
+
evidence: "只列 candidate 文件名与状态,不含记忆 id。",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
route: "memos_review_skill_candidate",
|
|
269
|
+
file: "skill.ts",
|
|
270
|
+
tierClass: "no-id",
|
|
271
|
+
evidence: "只改 candidate 的审阅状态,输出不含记忆 id。",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
route: "memos_install_skill_candidate",
|
|
275
|
+
file: "skill.ts",
|
|
276
|
+
tierClass: "no-id",
|
|
277
|
+
evidence: "只写 SKILL.md 并回报路径,输出不含记忆 id。",
|
|
278
|
+
},
|
|
279
|
+
// ---- admin.ts ----
|
|
280
|
+
{
|
|
281
|
+
route: "memos_admin:list_cubes",
|
|
282
|
+
file: "admin.ts",
|
|
283
|
+
tierClass: "no-id",
|
|
284
|
+
evidence: "admin.ts:91 输出的是 cube id,不是记忆 id。",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
route: "memos_admin:register_cube",
|
|
288
|
+
file: "admin.ts",
|
|
289
|
+
tierClass: "no-id",
|
|
290
|
+
evidence: "admin.ts:107 handleMemosRegisterCube 全部返回分支只输出 cube id 与注册状态;" +
|
|
291
|
+
"grep 该函数体无 .memory / 记忆 id 投影,不经过记忆读取路径。",
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
route: "memos_admin:create_user",
|
|
295
|
+
file: "admin.ts",
|
|
296
|
+
tierClass: "no-id",
|
|
297
|
+
evidence: "admin.ts:206 handleMemosCreateUser 只输出 user id 与建号/已存在状态;" +
|
|
298
|
+
"不查询记忆节点,无 id 外泄面。",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
route: "memos_admin:validate_cubes",
|
|
302
|
+
file: "admin.ts",
|
|
303
|
+
tierClass: "no-id",
|
|
304
|
+
evidence: "admin.ts:252 handleMemosValidateCubes 输出 config.json 校验/修复行(admin.ts:327 results.join)," +
|
|
305
|
+
"逐条是 cube 配置项而非记忆,无记忆 id。",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
route: "memos_admin:stats",
|
|
309
|
+
file: "memory.ts",
|
|
310
|
+
tierClass: "no-id",
|
|
311
|
+
evidence: "handleMemosGetStats 只输出各类型计数。",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
route: "memos_admin:capabilities",
|
|
315
|
+
file: "admin.ts",
|
|
316
|
+
tierClass: "no-id",
|
|
317
|
+
evidence: "只输出 Full/Lite 能力矩阵,静态文本。",
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
route: "memos_admin:calendar",
|
|
321
|
+
file: "calendar.ts",
|
|
322
|
+
tierClass: "no-id",
|
|
323
|
+
evidence: "实测 grep 确认 calendar.ts 不输出任何记忆 id。注意它有另一个独立缺陷:" +
|
|
324
|
+
"calendar.ts:65 读顶层 m.memory_type ?? m.type,但 API 顶层只有 " +
|
|
325
|
+
"['id','memory','metadata','project_name'] —— 两者恒 undefined,类型恒渲染成 NOTE。" +
|
|
326
|
+
"那是业务类型轴的 bug,与本模块的层级轴无关,另行修。",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
route: "memos_admin:default",
|
|
330
|
+
file: "index.ts",
|
|
331
|
+
tierClass: "no-id",
|
|
332
|
+
evidence: "未知 action 的错误分支,只回报可用 action 列表。",
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
route: "memos_delete",
|
|
336
|
+
file: "admin.ts",
|
|
337
|
+
tierClass: "id-passthrough",
|
|
338
|
+
evidence: "admin.ts:350-352 消费 agent 传入的 memory_id/memory_ids。",
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
/**
|
|
342
|
+
* 当前已知的缺口,**冻结集合**。
|
|
343
|
+
*
|
|
344
|
+
* 与 `ROUTE_CONTRACTS` 里的 `known-gap` 必须完全一致 —— 守卫双向比对:
|
|
345
|
+
* 静默新增缺口 → 失败;修好一个却忘了从这里删 → 也失败。后者同样重要,
|
|
346
|
+
* 否则集合会永久膨胀成一张无人清理的豁免名单。
|
|
347
|
+
*/
|
|
348
|
+
export const KNOWN_GAPS = [
|
|
349
|
+
"memos_think",
|
|
350
|
+
"memos_graph:default",
|
|
351
|
+
"memos_graph:path",
|
|
352
|
+
"memos_graph:impact",
|
|
353
|
+
];
|
|
354
|
+
/**
|
|
355
|
+
* 形态 2 的基线棘轮:每个文件里"投影了记忆但没带 memory_type"的 RETURN 条数。
|
|
356
|
+
*
|
|
357
|
+
* 为什么要基线而不是一律要求 0:`graph.ts` 那 4 处全部落在 KNOWN_GAPS 已冻结
|
|
358
|
+
* 的三条路由里。若断言恒为 0,套件从第一天起就是红的 —— 一个永远红的守卫等于
|
|
359
|
+
* 没有守卫,下一个人只会把它注释掉。
|
|
360
|
+
*
|
|
361
|
+
* 断言用**严格相等**,不是 `<=`:
|
|
362
|
+
* - 新增一处漏字段的投影 → 数字变大 → 失败,这是要防的复发。
|
|
363
|
+
* - 修好一处 → 数字变小 → 也失败,强制回来把基线调下去。棘轮只能往紧的方向走。
|
|
364
|
+
*
|
|
365
|
+
* 未列入此表的文件,隐含基线为 0:新文件一旦出现这种投影就立刻红。
|
|
366
|
+
*/
|
|
367
|
+
export const CYPHER_PROJECTION_BASELINE = {
|
|
368
|
+
// search.ts 已修完(见 search.ts:84-104 的复发记录),必须保持 0。
|
|
369
|
+
"search.ts": 0,
|
|
370
|
+
// graph.ts:273 path / graph.ts:407,419 default / graph.ts:688 impact
|
|
371
|
+
"graph.ts": 4,
|
|
372
|
+
};
|
|
373
|
+
//# sourceMappingURL=memory-tier-boundary.js.map
|