oh-memos-mcp 3.1.4 → 3.1.6
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 +317 -0
- package/README.md +1 -1
- package/dist/api-client.js +14 -5
- package/dist/canvas-format.js +18 -8
- package/dist/canvas-store.d.ts +24 -0
- package/dist/canvas-store.js +101 -5
- package/dist/handlers/canvas.js +102 -19
- 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/dist/tools-registry.d.ts +2 -0
- package/dist/tools-registry.js +186 -42
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,323 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [3.1.6] - 2026-08-26
|
|
10
|
+
|
|
11
|
+
仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
|
|
12
|
+
|
|
13
|
+
### 🔤 `memos_canvas` 的 ref 里,引号读不回来
|
|
14
|
+
|
|
15
|
+
`ref` 走 JSON 转义而非标签转义,因为 Windows 路径的反斜杠必须逐字返回,
|
|
16
|
+
否则锚点失效。但 `renderRef` 在 `JSON.stringify` 之后又替换了一次裸引号:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
JSON.stringify(ref).slice(1, -1).replace(/"/g, "\\u0022") // 旧
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`JSON.stringify` 已经把 `"` 写成 `\"`。那个 `replace` 只匹配裸引号,
|
|
23
|
+
**JSON 留下的反斜杠还在前面**,于是落盘成 `\\u0022`;`parseRef` 走 `JSON.parse`,
|
|
24
|
+
`\\` 解成一个字面反斜杠,取回 `"` 而不是 `"`。少匹配了一个反斜杠。
|
|
25
|
+
|
|
26
|
+
现在匹配 `\"` 整体:`.replace(/\\"/g, "\\u0022")`。引号仍须离开标签 ——
|
|
27
|
+
`"` 会提前终止它所在的 Mermaid `["..."]` —— 但这次是**替换**已转义的序列,
|
|
28
|
+
不是在它上面再叠一层。
|
|
29
|
+
|
|
30
|
+
反斜杠一侧一直是对的:`\\` 就是 JSON 在磁盘上的正确表示,`parseRef` 会解回单反斜杠。
|
|
31
|
+
只有引号这一条路径坏了。
|
|
32
|
+
|
|
33
|
+
### 🗑️ `memos_canvas` 新增 `delete` 动作
|
|
34
|
+
|
|
35
|
+
此前只有 open/update/show/list,清理必须手动删 `.mmd`。而画布的定位是**短期**状态,
|
|
36
|
+
注定会产生废弃文件 —— 加之全局规则禁止手工改动记忆文件,等于没有合法的清理路径。
|
|
37
|
+
|
|
38
|
+
`action="delete"` 在有未完成节点(doing/todo/blocked)时**拒绝**并给出逃生口,
|
|
39
|
+
全 done 或空画布直接删:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
❌ Canvas '000-x' still has 1 unfinished node (0 doing · 0 todo · 1 blocked)
|
|
43
|
+
Review it first: memos_canvas(action="show", name="000-x")
|
|
44
|
+
Delete anyway: memos_canvas(action="delete", name="000-x", confirm=true)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**没有套用 `MEMOS_ENABLE_DELETE`**。那道门是保护长期记忆的:走 API、有 embedding 成本、
|
|
48
|
+
且 Lite 模式下整个禁用。画布是本地文件、不走 API,而 Lite 模式下它恰恰是少数可用功能之一 ——
|
|
49
|
+
套上那道门等于在最需要清理的模式里砍掉清理能力。`confirm` 这种「拒绝并给出下一步」
|
|
50
|
+
与 `MAX_NODES`、非法 status 是同一个既有模式。
|
|
51
|
+
|
|
52
|
+
删除是硬删,不做回收站:画布存在的意义就是任务收尾后被丢弃,
|
|
53
|
+
回收站只会攒下它本该清掉的文件。`deleteCanvas` 复用 `canvasPath`,遍历防护自动继承。
|
|
54
|
+
|
|
55
|
+
### 🔢 前缀水位线:`delete` 破坏了「前缀永不复用」
|
|
56
|
+
|
|
57
|
+
加上 delete 之后有两个 e2e 断言失败。**不是断言写错** —— `nextPrefix` 用现存文件算
|
|
58
|
+
`max+1`,在只增不减的世界里「前缀永不复用」自动成立,delete 打破了这个不变量:
|
|
59
|
+
删掉最高前缀的画布后,那个前缀会被重新发放。
|
|
60
|
+
|
|
61
|
+
后果正是 `nextPrefix` 自己注释里警告过的:前缀嵌在它铸出的每个节点 id 里,
|
|
62
|
+
commit message 或记忆正文引用的 `001-N3` 会变得指向两个不同画布的节点。
|
|
63
|
+
|
|
64
|
+
修法是 `{cube}/canvas/.prefix-hwm` 水位线文件,`saveCanvas` 与 `deleteCanvas` 都抬升它,
|
|
65
|
+
只增不减。delete 侧必须抬升:文件是该前缀唯一的记录,删掉后若水位线仍低于它,
|
|
66
|
+
就会被重新发放。旧 cube 没有这个文件时(`readPrefixHwm` 返回 -1),
|
|
67
|
+
从现存文件恢复下限,所以升级不会因为水位线缺失而复用前缀。
|
|
68
|
+
|
|
69
|
+
水位线损坏或不可读时退回扫描文件,与 `parseCanvas` 一致 ——
|
|
70
|
+
画布是恢复态,读取时机正是刚丢失上下文的那一刻,抛异常是最坏的选择。
|
|
71
|
+
|
|
72
|
+
### 🧪 测试
|
|
73
|
+
|
|
74
|
+
canvas 单测 41 → 45(format)、28 → 44(store),canvas e2e 15 → 31 项。
|
|
75
|
+
两个修复都做了反向验证,还原实现后对应用例确实变红:转义 3 项、水位线 1 项(含 delete 侧那次抬升)。
|
|
76
|
+
|
|
77
|
+
e2e 补了「旧 cube(无水位线)删除后不复用前缀」这条真实升级路径 ——
|
|
78
|
+
它跨 store 与 handler,单测碰不到。
|
|
79
|
+
|
|
80
|
+
顺带改掉一个自己写坏的用例:名字叫 "does not free the prefix it used",
|
|
81
|
+
断言却在验证前缀被释放了。名字与断言矛盾的测试比没有测试更坏。
|
|
82
|
+
|
|
83
|
+
`memos_canvas` 的 schema 改了(action enum 加 `delete`,新增 `confirm`),
|
|
84
|
+
两处冻结的哈希同步重算:`schema-semantic-baseline.json` 与
|
|
85
|
+
`protocol-contract.test.ts` 的 `EXPECTED_SCHEMA_HASHES`。
|
|
86
|
+
|
|
87
|
+
门禁:vitest 511 passed、tsc、canvas e2e 31 项、pack 契约 104 文件、
|
|
88
|
+
schema budget +0.8%(限 5%)、semantic 快照 17 工具、protocol v2、lite smoke。
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## [3.1.5] - 2026-08-26
|
|
93
|
+
|
|
94
|
+
3.1.1~3.1.4 都是「仅 MCP server」的版本,Python 包与容器镜像停在 3.1.0。
|
|
95
|
+
**3.1.5 两侧同时有改动**,三个版本串(npm 包、`pyproject.toml`、`src/oh_memos/__init__.py`)
|
|
96
|
+
因此重新对齐到 3.1.5。Python/容器侧的内容见仓库根的 `docs/CHANGELOG.md`。
|
|
97
|
+
|
|
98
|
+
### 🧾 `memos_get` 的 not-found 文案指向真正的成因
|
|
99
|
+
|
|
100
|
+
旧文案让用户「核对 id 是否正确(从 `memos_search` 结果复制)」,但实测中最常见的成因恰恰是
|
|
101
|
+
**从搜索结果复制来的 id 过一会儿就失效**:`POST /search` 返回的是 `WorkingMemory` 层的副本 id,
|
|
102
|
+
该层按 `user_name` 只保留 20 条(后端 `manager.py` 的 `memory_size["WorkingMemory"]`),
|
|
103
|
+
每次检索都重写并淘汰最旧的。实测 `oh_memos_cube` 的那 20 条只覆盖 22 分钟。
|
|
104
|
+
|
|
105
|
+
因此新文案把 `memos_list_v2` 指出来是有实质区别的:它返回 `LongTermMemory` 的 id,
|
|
106
|
+
那一层不淘汰(实测同一批 id 隔天仍可取回)。措辞上不把淘汰断言成事实 ——
|
|
107
|
+
Lite 模式(本地 JSONL)没有 `WorkingMemory` 层,那里的 not-found 就是单纯的 id 不存在。
|
|
108
|
+
|
|
109
|
+
`notFoundText` 同时改为导出,便于测试直接断言文案而不经由 handler。
|
|
110
|
+
|
|
111
|
+
### 🌐 API 客户端保留 400 响应体
|
|
112
|
+
|
|
113
|
+
`apiCallWithRetry` 此前把非 2xx 一律丢弃响应体,于是后端在 400 里给出的
|
|
114
|
+
校验错误说明拿不到,工具层只能报一个无信息的失败。现在 400 单独放行并解析 JSON,
|
|
115
|
+
后端的错误描述可以原样呈现给调用方。
|
|
116
|
+
|
|
117
|
+
### 🔒 分层过滤的强制机制
|
|
118
|
+
|
|
119
|
+
同一个缺陷已复发 7 次:新增一条检索路径,忘了把 `WorkingMemory` 短期副本滤掉,
|
|
120
|
+
于是把一个几分钟后就失效的 id 交给了 agent。每次都靠 review 抓到,这次改成结构性拦截。
|
|
121
|
+
|
|
122
|
+
**为什么普通单测拦不住**:实测把所有层级过滤切断后,319 项 vitest 依然全绿。
|
|
123
|
+
单测问的是"过滤函数算得对不对",而缺陷形态是"过滤函数没被调用" —— 两个问题不同。
|
|
124
|
+
|
|
125
|
+
新增 `src/memory-tier-boundary.ts`(清单)与同名 `.test.ts`(守卫,18 项)。
|
|
126
|
+
清单把 `handlers/index.ts` 的 28 条分派路由逐条分类并附实测证据,守卫扫描源码与分派表做双向比对。
|
|
127
|
+
四条 fail-closed 棘轮,均以变异测试验证过确实会红:
|
|
128
|
+
|
|
129
|
+
- 分派表新增路由却没在清单里分类 → 失败;清单有而分派表已无(陈旧条目)→ 也失败
|
|
130
|
+
- 静默新增已知缺口 → 失败(`KNOWN_GAPS` 为冻结集合,计数上限硬编码)
|
|
131
|
+
- 修好缺口却忘了从 `KNOWN_GAPS` 移除 → 失败,否则集合会膨胀成一张无人清理的豁免名单
|
|
132
|
+
- `CYPHER_PROJECTION_BASELINE` 按严格相等断言,修好了不调基线同样失败 —— 棘轮只能往紧的方向走
|
|
133
|
+
|
|
134
|
+
**覆盖了第二种复发形态**:过滤接上了,但构造 metadata 时漏掉 `memory_type`,
|
|
135
|
+
判定函数恒读到 `undefined` 并判为可见。`search.ts` 曾因此让四个调用点的过滤同时空转,
|
|
136
|
+
而没有任何测试失败。守卫现在会检查投影记忆的 Cypher `RETURN` 是否带出该字段。
|
|
137
|
+
|
|
138
|
+
**已记录但尚未修的缺口**(`memos_think`、`memos_graph` 的三条路由):
|
|
139
|
+
`think.ts` 上朴素过滤会把语义召回从 15 条砍到 5 条,正确解法是按 `metadata.key`
|
|
140
|
+
换成持久层孪生节点的 id,而不是加过滤。不变式的准确表述是"永不把易失 id 交给 agent",
|
|
141
|
+
"过滤掉 `WorkingMemory`" 只是其中一种(有损的)满足方式。
|
|
142
|
+
|
|
143
|
+
门禁:vitest 491 通过(新增 18),`tsc --noEmit` 无输出。
|
|
144
|
+
|
|
145
|
+
## [3.1.4] - 2026-08-24
|
|
146
|
+
|
|
147
|
+
### 🐛 修复:同源节点列表混入 scheduler 短期副本
|
|
148
|
+
|
|
149
|
+
3.1.3 上线后实测:同源列表里每个 `key` **成对出现** —— 一条被切成 6 段的记忆列出了 12 条同源。
|
|
150
|
+
|
|
151
|
+
后端对每条抽取结果写两个节点:一个 `WorkingMemory` 短期副本 + 一个 `LongTermMemory`
|
|
152
|
+
(少数为 `UserMemory`)持久节点,`key` 与 `created_at` 逐字相同。`findSiblings` 没有滤层级,
|
|
153
|
+
于是短期副本一并列出。
|
|
154
|
+
|
|
155
|
+
**这不是新决策,是 3.1.0 已经做过的决定**(`memory-tier.ts`)。`memos_search` 与
|
|
156
|
+
`memos_list_v2` 早已在滤,`findSiblings` 是 3.1.2 新增的路径 —— 又漏了一处。
|
|
157
|
+
同一形态在本项目已出现五次:**新写的检索路径必须继承既有的分层过滤决定。**
|
|
158
|
+
|
|
159
|
+
逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 时不滤,与 `filterEphemeralTier` 语义一致。
|
|
160
|
+
缺 `memory_type` 视为可见 —— Lite 的 JSONL 不写该字段。
|
|
161
|
+
|
|
162
|
+
#### 测试
|
|
163
|
+
|
|
164
|
+
新增 5 项分层断言(共 48 项),三个变异全部被捕获:不滤层级(回到本次缺陷)、
|
|
165
|
+
忽略逃生开关、忽略 limit。其中「limit 在滤层级之后生效」用交错排列的候选集断言 ——
|
|
166
|
+
顺序反了会让 limit 被随即隐藏的副本吃掉,只返回一半。
|
|
167
|
+
|
|
168
|
+
门禁:vitest 447 passed、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
169
|
+
protocol v2、lite smoke、host-env smoke。
|
|
170
|
+
|
|
171
|
+
## [3.1.3] - 2026-08-24
|
|
172
|
+
|
|
173
|
+
### 🐛 修复:同源碎片区块从不出现 —— `sources` 有两种线上形态
|
|
174
|
+
|
|
175
|
+
3.1.2 上线后实测:原文能正确返回,但「同源碎片」区块**从不出现**。根因是同一个字段
|
|
176
|
+
在两个端点上形态不同:
|
|
177
|
+
|
|
178
|
+
| 端点 | `sources[0]` 的形态 |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `GET /memories/{cube}/{id}`(单条) | **对象** `{type, role, chat_time, content}` |
|
|
181
|
+
| `GET /memories`(列表) | **JSON 字符串** `'{"type":...,"content":"..."}'` |
|
|
182
|
+
|
|
183
|
+
`verbatimOf` 初版只处理对象形态。单条取回走对象 → 原文正常;同源查找的候选集来自
|
|
184
|
+
列表端点 → 全部返回 null → 永远匹配不到。**原文层可用,配对层静默失效。**
|
|
185
|
+
|
|
186
|
+
单测没抓住的原因很直接:fixture 只造了对象形态,两种形态里只测了一种。
|
|
187
|
+
|
|
188
|
+
修法:抽出 `contentOf(entry)` 同时接受对象与 JSON 字符串。非 JSON 字符串返回 null
|
|
189
|
+
而**不猜它是裸原文** —— 否则任意字符串都会被当原文,把无关记忆归成同源。
|
|
190
|
+
坏 JSON 按无 content 处理不抛异常:这是展示层,脏数据不该让 `memos_get` 失败。
|
|
191
|
+
|
|
192
|
+
#### 测试
|
|
193
|
+
|
|
194
|
+
新增 6 项断言与一个 list 形态 fixture(共 43 项)。三个变异全部被捕获,
|
|
195
|
+
其中「不处理字符串形态」即回到本次修复前的行为,另两个覆盖「非 JSON 当裸原文」
|
|
196
|
+
与「坏 JSON 抛异常」。跨形态指纹一致性单独断言 —— 否则单条与列表永远配不上。
|
|
197
|
+
|
|
198
|
+
门禁:vitest 442 passed、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
199
|
+
protocol v2、lite smoke、host-env smoke。
|
|
200
|
+
|
|
201
|
+
## [3.1.2] - 2026-08-23
|
|
202
|
+
|
|
203
|
+
### 📄 `memos_get` 返回原文,不再只返回 LLM 概括
|
|
204
|
+
|
|
205
|
+
开启 LLM 抽取(后端 `MOS_TYPED_SAVE_FAST=false`)时,后端把一次写入拆成多条细粒度记忆:
|
|
206
|
+
每条的 `memory` 字段是 LLM 概括,而**逐字原文完整保留在 `metadata.sources[0].content`**。
|
|
207
|
+
实测一条 1056 字的写入被拆成 5 条,每条 `memory` 约 244 字,5 条共享同一份原文。
|
|
208
|
+
`memos_get` 此前只读 `memory`,于是完整上下文取不回来。
|
|
209
|
+
|
|
210
|
+
这不是取舍问题 —— 库里本来就是两份数据,各有各的用途:
|
|
211
|
+
|
|
212
|
+
| 层 | 内容 | 用途 |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| `memory` | LLM 概括(细粒度) | 向量化输入、建边对象 → 决定图谱节点与联想质量 |
|
|
215
|
+
| `metadata.sources[].content` | 逐字原文 | `memos_get` 的完整上下文来源 |
|
|
216
|
+
|
|
217
|
+
**新输出结构**(有原文时):原文作为 `### Content` 正文;LLM 概括降级到
|
|
218
|
+
`### 抽取概括` 区块并保留 —— 它是向量化与建边的实际输入,看得见才能判断图谱节点切得对不对;
|
|
219
|
+
`### 同源碎片` 列出由同一次写入抽出的其他记忆(id + key),从而能看出 LLM 的切分方式。
|
|
220
|
+
无原文时(fast-path 写入)输出与此前完全一致。
|
|
221
|
+
|
|
222
|
+
同源判定用原文全文,不用 `session_id` —— 同一会话里的无关写入会被错误归组。
|
|
223
|
+
|
|
224
|
+
新增 `src/verbatim-source.ts`:`verbatimOf` / `sourceFingerprint` / `findSiblings` /
|
|
225
|
+
`renderVerbatimSections`,四个纯函数,handler 与测试调用同一份实现。
|
|
226
|
+
Full 与 Lite 两条路径都已接线(Lite 的 JSONL 无同源概念,列表恒空)。
|
|
227
|
+
同源查询失败时返回空列表,不影响原文返回。
|
|
228
|
+
|
|
229
|
+
#### 测试
|
|
230
|
+
|
|
231
|
+
新增 `src/verbatim-source.test.ts`(37 项)。**变异验证抓出三个断言缺乏判别力**,
|
|
232
|
+
每一个都是「测试写了但等于没写」的形态:
|
|
233
|
+
|
|
234
|
+
- 接线守卫数两条路径**合计**调用次数 `>= 2`,而 Lite 路径自身就有 2 次,
|
|
235
|
+
砍掉 Full 路径那次仍然通过。改为按路径分别断言。
|
|
236
|
+
- 「原文不截断」用 30 字测试原文,而变异截断到 200 字 —— 改不动它。
|
|
237
|
+
测试原文改为 600+ 字并加尾部标记。**这条正是本次要修的缺陷,却最后才抓住。**
|
|
238
|
+
- `"not-an-array"[0]` 是字符 `"n"`、无 `.content`,宽松实现也返回 null。
|
|
239
|
+
改用伪装成数组的对象才有判别力。
|
|
240
|
+
|
|
241
|
+
顺带删除 `sourceFingerprint` 的长度前缀:变异验证时构造不出它能防住的碰撞,
|
|
242
|
+
无测试支撑的防御代码比没有更糟。
|
|
243
|
+
|
|
244
|
+
12 个变异被捕获(W1「Full 路径不读原文」即回到本次修复前的行为)。
|
|
245
|
+
门禁:vitest 436/436、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
246
|
+
protocol v2、lite smoke。
|
|
247
|
+
|
|
248
|
+
## [3.1.1] - 2026-08-22
|
|
249
|
+
|
|
250
|
+
### 🐛 修复:`memos_context_resume` 每条记忆成对出现
|
|
251
|
+
|
|
252
|
+
3.1.0 装好后 `memos_search` 与 `memos_list_v2` 都已正确隐藏 `WorkingMemory` 短期副本,
|
|
253
|
+
但 `memos_context_resume` 仍成对显示(10 条 = 5 对,内容逐字相同、UUID 不同)。
|
|
254
|
+
|
|
255
|
+
**根因不止是漏接一条路径。** temporal 查询的 Cypher 不返回 `memory_type`,
|
|
256
|
+
构造出的 metadata 只有 `relativity`/`temporal_rank`/`source` —— 于是分层判定永远
|
|
257
|
+
读到 `undefined` 并判为可见,**下游任何过滤对 temporal 记忆都是空转**。
|
|
258
|
+
受影响的是四个调用点,不只是 `memos_context_resume`:
|
|
259
|
+
|
|
260
|
+
| 路径 | 3.1.0 的状态 |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `memos_context_resume` | 成对显示(用户实测发现) |
|
|
263
|
+
| `memos_search` temporal intent(两处) | 有过滤代码,但读不到字段 |
|
|
264
|
+
| `memos_think` | 同上 |
|
|
265
|
+
|
|
266
|
+
后三处一直漏着且不易察觉 —— 过滤代码存在且看起来合理,只是作用对象缺字段。
|
|
267
|
+
|
|
268
|
+
修法:在 Cypher 里排除 `WorkingMemory` 并返回 `memory_type`。这是唯一对四处都成立的做法,
|
|
269
|
+
且 `LIMIT` 在过滤之后 —— 要 N 条就得 N 条真记忆,无需超额取数。
|
|
270
|
+
`memos_context_resume` 的 API 回退路径由服务端施加 limit,无法先过滤,故超额取 3 倍。
|
|
271
|
+
|
|
272
|
+
逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 仍然有效:为真时不加排除条件,
|
|
273
|
+
但仍返回 `memory_type` —— 开关只关过滤,不关可观测性。
|
|
274
|
+
|
|
275
|
+
#### 测试
|
|
276
|
+
|
|
277
|
+
新增 `src/context-resume.test.ts`(22 项),**9 个变异全部被捕获**,
|
|
278
|
+
其中 W1「API 路径不过滤」即回到本次报告的原缺陷。
|
|
279
|
+
|
|
280
|
+
新增 `npm run test:host-env-smoke`:用 MCP host 配置里的**原样环境**驱动 server,
|
|
281
|
+
且不继承外层环境变量。已有的 spread smoke 自带硬编码 Neo4j 凭据兜底,
|
|
282
|
+
因此「host 读不到凭据」这一失败形态它测不出来 —— 实测该形态下检索照常返回记忆、
|
|
283
|
+
但联想标注为 0,属静默降级。
|
|
284
|
+
|
|
285
|
+
门禁:vitest 416/416、tsc、pack 契约、schema budget +0.0%、semantic 快照 17 工具、
|
|
286
|
+
protocol v2、lite smoke、spread smoke、host-env smoke。
|
|
287
|
+
|
|
288
|
+
## [3.1.0] - 2026-08-22
|
|
289
|
+
|
|
290
|
+
### 🧠 检索排序:衰减、强化、分档去重与图扩散联想
|
|
291
|
+
|
|
292
|
+
本版把检索侧的排序从「纯相似度」改为「相似度 + 时间衰减 + 访问强化」,并加入按类型分档的
|
|
293
|
+
近重复折叠与一跳图扩散联想。
|
|
294
|
+
|
|
295
|
+
#### 记忆层级:`WorkingMemory` 默认隐藏
|
|
296
|
+
|
|
297
|
+
后端 scheduler 会为每条持久记忆写一份 `WorkingMemory` 短期副本,该层按用户仅保留最近 20 条、
|
|
298
|
+
每次检索都会重写淘汰。把这些 id 交给 agent 意味着它拿到的引用几分钟后就失效。
|
|
299
|
+
新增 `src/memory-tier.ts`,`memos_search` 与 `memos_list_v2` 默认滤掉该层;
|
|
300
|
+
逃生开关 `MEMOS_SHOW_WORKING_MEMORY=true` 可关闭过滤。缺 `memory_type` 视为可见
|
|
301
|
+
(Lite 的 JSONL 不写该字段)。
|
|
302
|
+
|
|
303
|
+
#### 衰减与强化
|
|
304
|
+
|
|
305
|
+
排序分数加入按 `created_at` 的时间衰减与按访问次数的强化项,两者都可用环境变量关掉,
|
|
306
|
+
默认开启。访问计数由 `src/access-tracker.ts` 本地维护,不写回后端。
|
|
307
|
+
|
|
308
|
+
#### 近重复折叠(按类型分档)
|
|
309
|
+
|
|
310
|
+
同类型内容高度重合的记忆折叠为一条并标注折叠数。分档按 `memory_type` ——
|
|
311
|
+
不同类型的相似文本(一条 `DECISION` 与一条 `BUGFIX`)不应互相折叠。
|
|
312
|
+
|
|
313
|
+
#### 一跳图扩散联想(`MEMOS_SPREAD_ACTIVATION`,默认关闭)
|
|
314
|
+
|
|
315
|
+
命中记忆沿知识图谱做一跳扩散,把强关联但关键词不匹配的记忆带进结果并标注来源。
|
|
316
|
+
默认关闭,因为它需要可用的 Neo4j 凭据;读不到凭据时静默返回 0 条联想。
|
|
317
|
+
|
|
318
|
+
#### 检索期注解
|
|
319
|
+
|
|
320
|
+
结果里标注衰减后分数、访问次数与折叠数,使排序结果可解释而非黑箱。
|
|
321
|
+
|
|
322
|
+
#### graph schema 四处缺陷修复
|
|
323
|
+
|
|
324
|
+
`memos_graph` 的 schema 模式修正四处:节点计数口径、边类型去重、孤立节点统计与采样上限。
|
|
325
|
+
|
|
9
326
|
## [3.0.1] - 2026-08-19
|
|
10
327
|
|
|
11
328
|
### 🩹 修复 3.0.0 打包出的多余依赖与失效仓库链接
|
package/README.md
CHANGED
|
@@ -327,7 +327,7 @@ only when `MEMOS_ENABLE_DELETE=true`; the other 16 are available by default.
|
|
|
327
327
|
| `memos_admin` | Maintenance — `action`: `list_cubes` / `register_cube` / `create_user` / `validate_cubes` / `stats` / `calendar` |
|
|
328
328
|
| `memos_export_wiki` | Export a cube as an interlinked markdown wiki (page per memory + index + mermaid graph) |
|
|
329
329
|
| `memos_import_wiki` | Import an exported wiki back into a cube; supports dry-run and versioning edited pages, never deletes memories |
|
|
330
|
-
| `memos_canvas` | Symbolic task canvas — short-term task state that survives context compaction. `action`: `open` / `update` / `show` / `list
|
|
330
|
+
| `memos_canvas` | Symbolic task canvas — short-term task state that survives context compaction. `action`: `open` / `update` / `show` / `list` / `delete` (a canvas with unfinished nodes needs `confirm=true`). Nodes carry greppable ids (`000-N1`) and a `ref` anchoring them to evidence: `mem:<memory_id>` / `file:<path>` / `note:<text>` |
|
|
331
331
|
| `memos_distill_skill` | Create an inert, reviewable Skill candidate under `<project_path>/skill-candidates`; it never installs the candidate automatically |
|
|
332
332
|
| `memos_list_skill_candidates` | List generated Skill candidates without modifying or installing them |
|
|
333
333
|
| `memos_review_skill_candidate` | Approve or reject a candidate with reviewer audit metadata; approval does not install |
|
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
|
}
|
package/dist/canvas-format.js
CHANGED
|
@@ -123,9 +123,19 @@ export function allocateNodeId(canvas) {
|
|
|
123
123
|
* Windows paths are the reason: `C:\Users\x` contains backslashes, which JSON
|
|
124
124
|
* escapes and which must come back byte-identical or the anchor stops resolving.
|
|
125
125
|
* Label escaping is for prose; refs get JSON, which is lossless.
|
|
126
|
+
*
|
|
127
|
+
* One transform sits on top of JSON: `JSON.stringify` renders a quote as `\"`,
|
|
128
|
+
* and that bare quote would still close the Mermaid label. Promoting the whole
|
|
129
|
+
* two-character escape to `\u0022` keeps the label intact while staying valid
|
|
130
|
+
* JSON, so `parseRef` decodes it back to `"`.
|
|
131
|
+
*
|
|
132
|
+
* The `\\` in the pattern is load-bearing. Replacing only the quote leaves
|
|
133
|
+
* JSON's backslash in front of the substitution, producing `\\u0022` — which
|
|
134
|
+
* JSON.parse reads as a literal backslash followed by `u0022`, so a ref
|
|
135
|
+
* containing a quote comes back as the text `\u0022` instead of the quote.
|
|
126
136
|
*/
|
|
127
137
|
function renderRef(ref) {
|
|
128
|
-
return JSON.stringify(ref).slice(1, -1).replace(
|
|
138
|
+
return JSON.stringify(ref).slice(1, -1).replace(/\\"/g, "\\u0022");
|
|
129
139
|
}
|
|
130
140
|
function parseRef(raw) {
|
|
131
141
|
try {
|
|
@@ -142,10 +152,7 @@ export function renderCanvas(canvas) {
|
|
|
142
152
|
updatedTime: canvas.updatedTime,
|
|
143
153
|
prefix: canvas.prefix,
|
|
144
154
|
};
|
|
145
|
-
const lines = [
|
|
146
|
-
`%%${JSON.stringify(meta)}%%`,
|
|
147
|
-
"graph LR",
|
|
148
|
-
];
|
|
155
|
+
const lines = [`%%${JSON.stringify(meta)}%%`, "graph LR"];
|
|
149
156
|
for (const node of canvas.nodes) {
|
|
150
157
|
const parts = [
|
|
151
158
|
`status: ${node.status}`,
|
|
@@ -192,9 +199,12 @@ export function parseCanvas(content) {
|
|
|
192
199
|
try {
|
|
193
200
|
const raw = metaMatch[0].slice(2, -2);
|
|
194
201
|
const parsed = JSON.parse(raw);
|
|
195
|
-
canvas.taskGoal =
|
|
196
|
-
|
|
197
|
-
canvas.
|
|
202
|
+
canvas.taskGoal =
|
|
203
|
+
typeof parsed.taskGoal === "string" ? parsed.taskGoal : "";
|
|
204
|
+
canvas.createdTime =
|
|
205
|
+
typeof parsed.createdTime === "string" ? parsed.createdTime : null;
|
|
206
|
+
canvas.updatedTime =
|
|
207
|
+
typeof parsed.updatedTime === "string" ? parsed.updatedTime : null;
|
|
198
208
|
if (typeof parsed.prefix === "string" && /^\d{3}$/.test(parsed.prefix)) {
|
|
199
209
|
canvas.prefix = parsed.prefix;
|
|
200
210
|
prefixFromHeader = true;
|
package/dist/canvas-store.d.ts
CHANGED
|
@@ -35,6 +35,24 @@ export declare function canvasPath(cubesDir: string, cubeId: string, name: strin
|
|
|
35
35
|
export declare function loadCanvas(cubesDir: string, cubeId: string, name: string): Canvas | null;
|
|
36
36
|
/** Writes atomically and returns the path written. */
|
|
37
37
|
export declare function saveCanvas(cubesDir: string, cubeId: string, name: string, canvas: Canvas): string;
|
|
38
|
+
/**
|
|
39
|
+
* Remove a canvas file. Returns false when it was not there to begin with.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately not atomic and deliberately not a soft delete. A canvas is
|
|
42
|
+
* short-term state whose whole purpose is to be discarded once the task closes;
|
|
43
|
+
* a trash directory would just accumulate the files this exists to remove. The
|
|
44
|
+
* decision about whether losing this particular canvas is acceptable belongs to
|
|
45
|
+
* the caller, which is why the handler asks before reaching this function.
|
|
46
|
+
*
|
|
47
|
+
* `canvasPath` runs first, so a traversal-shaped name throws rather than
|
|
48
|
+
* unlinking something outside the canvas directory.
|
|
49
|
+
*
|
|
50
|
+
* The watermark is raised before the unlink, because the file is the only other
|
|
51
|
+
* record of the prefix and it is about to stop existing. A cube written before
|
|
52
|
+
* the watermark existed has none, so without this the delete would leave no
|
|
53
|
+
* trace of the prefix at all and `nextPrefix` would reissue it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function deleteCanvas(cubesDir: string, cubeId: string, name: string): boolean;
|
|
38
56
|
export interface CanvasEntry {
|
|
39
57
|
/** Filename without the extension — the handle callers pass back in. */
|
|
40
58
|
name: string;
|
|
@@ -57,6 +75,12 @@ export declare function listCanvases(cubesDir: string, cubeId: string): CanvasEn
|
|
|
57
75
|
* two canvases' ids ambiguous. Returning null instead of wrapping keeps the
|
|
58
76
|
* failure visible — a caller that has 1000 canvases in one cube has a different
|
|
59
77
|
* problem than a naming collision.
|
|
78
|
+
*
|
|
79
|
+
* The watermark is consulted alongside the files on disk because `deleteCanvas`
|
|
80
|
+
* exists: deriving the maximum from surviving files alone would hand a deleted
|
|
81
|
+
* canvas's prefix to the next one, which is the ambiguity this function is
|
|
82
|
+
* written to avoid. Files are still scanned so that a cube predating the
|
|
83
|
+
* watermark, or one whose watermark was lost, allocates correctly.
|
|
60
84
|
*/
|
|
61
85
|
export declare function nextPrefix(cubesDir: string, cubeId: string): string | null;
|
|
62
86
|
//# sourceMappingURL=canvas-store.d.ts.map
|
package/dist/canvas-store.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
21
21
|
import { basename, join, resolve, sep } from "node:path";
|
|
22
|
-
import { parseCanvas, renderCanvas, slugify } from "./canvas-format.js";
|
|
22
|
+
import { parseCanvas, renderCanvas, slugify, } from "./canvas-format.js";
|
|
23
23
|
export class CanvasPathError extends Error {
|
|
24
24
|
constructor(message) {
|
|
25
25
|
super(message);
|
|
@@ -28,6 +28,18 @@ export class CanvasPathError extends Error {
|
|
|
28
28
|
}
|
|
29
29
|
const CANVAS_DIR = "canvas";
|
|
30
30
|
const CANVAS_EXT = ".mmd";
|
|
31
|
+
/**
|
|
32
|
+
* Highest prefix ever issued in this cube, kept outside the canvas files.
|
|
33
|
+
*
|
|
34
|
+
* Without it, `nextPrefix` derives the watermark from the files that happen to
|
|
35
|
+
* exist, and deleting the newest canvas hands its prefix to the next one — which
|
|
36
|
+
* is exactly the id ambiguity `nextPrefix` refuses to create. The name starts
|
|
37
|
+
* with a dot and does not end in `.mmd`, so `slugify` can never mint it and
|
|
38
|
+
* `listCanvases` never reads it as a canvas.
|
|
39
|
+
*/
|
|
40
|
+
const PREFIX_HWM_FILE = ".prefix-hwm";
|
|
41
|
+
/** Leading three digits of a canvas name — the prefix it minted its ids from. */
|
|
42
|
+
const PREFIX_RE = /^(\d{3})/;
|
|
31
43
|
/** `cube_id` is machine-derived (`detectCubeFromPath`), so this is a shape check
|
|
32
44
|
* rather than a sanitiser — a value that does not look like a cube id is a bug
|
|
33
45
|
* upstream, and guessing at what it meant would hide it. */
|
|
@@ -71,7 +83,8 @@ export function canvasPath(cubesDir, cubeId, name) {
|
|
|
71
83
|
const dir = resolve(cubesDir, rawCube, CANVAS_DIR);
|
|
72
84
|
const full = resolve(dir, `${slug}${CANVAS_EXT}`);
|
|
73
85
|
const dirWithSep = dir.endsWith(sep) ? dir : dir + sep;
|
|
74
|
-
if (!full.startsWith(dirWithSep) ||
|
|
86
|
+
if (!full.startsWith(dirWithSep) ||
|
|
87
|
+
basename(full) !== `${slug}${CANVAS_EXT}`) {
|
|
75
88
|
throw new CanvasPathError(`resolved path escapes the canvas directory: ${full}`);
|
|
76
89
|
}
|
|
77
90
|
return full;
|
|
@@ -120,8 +133,41 @@ export function saveCanvas(cubesDir, cubeId, name, canvas) {
|
|
|
120
133
|
}
|
|
121
134
|
throw err;
|
|
122
135
|
}
|
|
136
|
+
// After the rename, so a failed write never advances the watermark. Recorded
|
|
137
|
+
// on every save rather than only on create: it is idempotent, and a cube whose
|
|
138
|
+
// watermark was lost recovers it on the next update to any canvas.
|
|
139
|
+
const m = PREFIX_RE.exec(name);
|
|
140
|
+
if (m)
|
|
141
|
+
bumpPrefixHwm(cubesDir, cubeId, m[1]);
|
|
123
142
|
return path;
|
|
124
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Remove a canvas file. Returns false when it was not there to begin with.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately not atomic and deliberately not a soft delete. A canvas is
|
|
148
|
+
* short-term state whose whole purpose is to be discarded once the task closes;
|
|
149
|
+
* a trash directory would just accumulate the files this exists to remove. The
|
|
150
|
+
* decision about whether losing this particular canvas is acceptable belongs to
|
|
151
|
+
* the caller, which is why the handler asks before reaching this function.
|
|
152
|
+
*
|
|
153
|
+
* `canvasPath` runs first, so a traversal-shaped name throws rather than
|
|
154
|
+
* unlinking something outside the canvas directory.
|
|
155
|
+
*
|
|
156
|
+
* The watermark is raised before the unlink, because the file is the only other
|
|
157
|
+
* record of the prefix and it is about to stop existing. A cube written before
|
|
158
|
+
* the watermark existed has none, so without this the delete would leave no
|
|
159
|
+
* trace of the prefix at all and `nextPrefix` would reissue it.
|
|
160
|
+
*/
|
|
161
|
+
export function deleteCanvas(cubesDir, cubeId, name) {
|
|
162
|
+
const path = canvasPath(cubesDir, cubeId, name);
|
|
163
|
+
if (!existsSync(path))
|
|
164
|
+
return false;
|
|
165
|
+
const m = PREFIX_RE.exec(name);
|
|
166
|
+
if (m)
|
|
167
|
+
bumpPrefixHwm(cubesDir, cubeId, m[1]);
|
|
168
|
+
unlinkSync(path);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
125
171
|
/**
|
|
126
172
|
* Every canvas in a cube, sorted by name.
|
|
127
173
|
*
|
|
@@ -147,7 +193,11 @@ export function listCanvases(cubesDir, cubeId) {
|
|
|
147
193
|
const name = file.slice(0, -CANVAS_EXT.length);
|
|
148
194
|
const path = join(dir, file);
|
|
149
195
|
try {
|
|
150
|
-
entries.push({
|
|
196
|
+
entries.push({
|
|
197
|
+
name,
|
|
198
|
+
path,
|
|
199
|
+
canvas: parseCanvas(readFileSync(path, "utf8")),
|
|
200
|
+
});
|
|
151
201
|
}
|
|
152
202
|
catch {
|
|
153
203
|
continue; // unreadable file should not hide the readable ones
|
|
@@ -159,7 +209,47 @@ export function listCanvases(cubesDir, cubeId) {
|
|
|
159
209
|
// ============================================================================
|
|
160
210
|
// Prefix allocation
|
|
161
211
|
// ============================================================================
|
|
162
|
-
|
|
212
|
+
function prefixHwmPath(cubesDir, cubeId) {
|
|
213
|
+
const dir = canvasDirFor(cubesDir, cubeId);
|
|
214
|
+
return dir ? join(dir, PREFIX_HWM_FILE) : null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Highest prefix ever issued, or -1 when there is no usable record.
|
|
218
|
+
*
|
|
219
|
+
* Any unreadable or out-of-range value reads as -1 rather than throwing: losing
|
|
220
|
+
* the watermark degrades allocation back to filesystem-derived, which is a
|
|
221
|
+
* weaker guarantee but not a broken one.
|
|
222
|
+
*/
|
|
223
|
+
function readPrefixHwm(cubesDir, cubeId) {
|
|
224
|
+
const path = prefixHwmPath(cubesDir, cubeId);
|
|
225
|
+
if (!path || !existsSync(path))
|
|
226
|
+
return -1;
|
|
227
|
+
try {
|
|
228
|
+
const n = Number(readFileSync(path, "utf8").trim());
|
|
229
|
+
return Number.isInteger(n) && n >= 0 && n <= 999 ? n : -1;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return -1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Raise the watermark to `prefix` if that is higher. Never lowers it. */
|
|
236
|
+
function bumpPrefixHwm(cubesDir, cubeId, prefix) {
|
|
237
|
+
const path = prefixHwmPath(cubesDir, cubeId);
|
|
238
|
+
if (!path)
|
|
239
|
+
return;
|
|
240
|
+
const n = Number(prefix);
|
|
241
|
+
if (!Number.isInteger(n) || n < 0 || n > 999)
|
|
242
|
+
return;
|
|
243
|
+
if (n <= readPrefixHwm(cubesDir, cubeId))
|
|
244
|
+
return;
|
|
245
|
+
try {
|
|
246
|
+
writeFileSync(path, String(n), "utf8");
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Best effort. A canvas that saved but failed to record its prefix is
|
|
250
|
+
// still a usable canvas; refusing the save would be the worse trade.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
163
253
|
/**
|
|
164
254
|
* Next unused three-digit canvas prefix, or null once 999 is taken.
|
|
165
255
|
*
|
|
@@ -168,9 +258,15 @@ const PREFIX_RE = /^(\d{3})/;
|
|
|
168
258
|
* two canvases' ids ambiguous. Returning null instead of wrapping keeps the
|
|
169
259
|
* failure visible — a caller that has 1000 canvases in one cube has a different
|
|
170
260
|
* problem than a naming collision.
|
|
261
|
+
*
|
|
262
|
+
* The watermark is consulted alongside the files on disk because `deleteCanvas`
|
|
263
|
+
* exists: deriving the maximum from surviving files alone would hand a deleted
|
|
264
|
+
* canvas's prefix to the next one, which is the ambiguity this function is
|
|
265
|
+
* written to avoid. Files are still scanned so that a cube predating the
|
|
266
|
+
* watermark, or one whose watermark was lost, allocates correctly.
|
|
171
267
|
*/
|
|
172
268
|
export function nextPrefix(cubesDir, cubeId) {
|
|
173
|
-
let max =
|
|
269
|
+
let max = readPrefixHwm(cubesDir, cubeId);
|
|
174
270
|
for (const entry of listCanvases(cubesDir, cubeId)) {
|
|
175
271
|
const m = PREFIX_RE.exec(entry.name);
|
|
176
272
|
if (!m)
|