oh-memos-mcp 3.1.5 → 3.1.7
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 +149 -0
- package/README.md +1 -1
- package/dist/canvas-format.d.ts +24 -1
- package/dist/canvas-format.js +48 -10
- package/dist/canvas-store.d.ts +24 -0
- package/dist/canvas-store.js +101 -5
- package/dist/handlers/canvas.js +106 -24
- 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,155 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [3.1.7] - 2026-08-27
|
|
10
|
+
|
|
11
|
+
仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
|
|
12
|
+
|
|
13
|
+
### 🏷️ `open` 报出的画布名,`list` 找不到
|
|
14
|
+
|
|
15
|
+
goal 足够长时,`actionOpen` 报出的名字比磁盘上真实存在的多一个字符:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
open reports: 000-verify-3-1-6-canvas-delete-and-ref-escaping-over-live-mcp (61)
|
|
19
|
+
list shows : 000-verify-3-1-6-canvas-delete-and-ref-escaping-over-live-mc (60)
|
|
20
|
+
on disk : ...-live-mc.mmd
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`actionOpen` 先 `slugify(goal)` 拿到最多 `SLUG_MAX`(60)字符,再拼 `${prefix}-`,
|
|
24
|
+
总长可达 64。`saveCanvas` → `canvasPath` 在落盘路上又 slugify 一次,砍回 60。
|
|
25
|
+
**前缀那 4 个字符的预算从来没被算进去。**
|
|
26
|
+
|
|
27
|
+
`canvasPath` 里那处「slug 与原名不同就拒绝」的检查本该拦住它,但它只在名字
|
|
28
|
+
「像路径」(含 `/`、`\` 或 `..`)时抛错 —— 截断不像路径,于是静默放过。
|
|
29
|
+
|
|
30
|
+
影响是**报错的句柄,不是损坏的数据**:前缀仍让每个画布互不相同,
|
|
31
|
+
两个前 60 字符相同的长 goal 得到 `000-`/`001-` 两个独立文件,各自节点都在;
|
|
32
|
+
`update`/`delete` 传 `open` 报的长名字也照样能用,因为 `canvasPath` 的截断是幂等的。
|
|
33
|
+
坏的只有一件事:调用方拿到的名字,`list` 永远不会显示,磁盘上也不存在。
|
|
34
|
+
|
|
35
|
+
修法是把名字组装收进 `canvas-format.ts` 的新函数 `canvasName(prefix, goal)` ——
|
|
36
|
+
让它紧挨着自己必须遵守的那个上限,而不是让 handler 去做预算算术:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export function canvasName(prefix: string, goal: string): string {
|
|
40
|
+
const head = `${prefix}-`;
|
|
41
|
+
const slug = slugify(goal, SLUG_MAX - head.length);
|
|
42
|
+
return slug ? `${head}${slug}` : `${head}task`;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`slugify` 因此多一个可选的 `maxLength`,**向内钳制到 `SLUG_MAX`** ——
|
|
47
|
+
调用方能收紧上限,不能放宽它,否则「每个画布文件名都装得进 `SLUG_MAX`」
|
|
48
|
+
这个不变量就成了各调用点的自选项。截断后仍走一次尾部连字符清理,
|
|
49
|
+
所以 `000-abc-.mmd` 这种名字不会出现。
|
|
50
|
+
|
|
51
|
+
### 🧪 测试
|
|
52
|
+
|
|
53
|
+
canvas-format 单测 41 → 50,canvas e2e 31 → 36 项。
|
|
54
|
+
|
|
55
|
+
核心断言是**不动点**而不是长度:`slugify(canvasName(p, g)) === canvasName(p, g)`,
|
|
56
|
+
跨 3 个前缀 × 6 种 goal(超长、纯 CJK、尾部连字符诱饵、前 60 字符相同的两个变体)。
|
|
57
|
+
长度只是症状 —— `canvasPath` 会再 slugify 一次,任何不是不动点的名字都是
|
|
58
|
+
「告诉了调用方、但文件系统从没见过」的名字。
|
|
59
|
+
|
|
60
|
+
e2e 补的 5 项跨 open/list/show/delete 与磁盘:`open` 报的名字必须等于
|
|
61
|
+
`list` 显示的名字、等于磁盘上存在的文件。反向验证还原实现后 3 项变红
|
|
62
|
+
(长度 64、磁盘上无此名、`list` 显示另一个名字)。
|
|
63
|
+
|
|
64
|
+
另两项(`reported name still routes to its canvas` / `... to delete`)
|
|
65
|
+
在修复前后都通过 —— `canvasPath` 截断幂等使然。**保留但改名如实**:
|
|
66
|
+
它们守的是「如果有人改用『让 `canvasPath` 对有损名字抛错』来修这个缺陷,
|
|
67
|
+
这条回归会被抓到」,不是这次的不变量。
|
|
68
|
+
|
|
69
|
+
门禁:vitest 520 passed、tsc、canvas e2e 36 项、pack 契约 104 文件、
|
|
70
|
+
schema budget +0.8%、semantic 快照 17 工具、protocol legacy + v2、
|
|
71
|
+
lite smoke、host-env smoke、spread smoke。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## [3.1.6] - 2026-08-26
|
|
76
|
+
|
|
77
|
+
仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
|
|
78
|
+
|
|
79
|
+
### 🔤 `memos_canvas` 的 ref 里,引号读不回来
|
|
80
|
+
|
|
81
|
+
`ref` 走 JSON 转义而非标签转义,因为 Windows 路径的反斜杠必须逐字返回,
|
|
82
|
+
否则锚点失效。但 `renderRef` 在 `JSON.stringify` 之后又替换了一次裸引号:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
JSON.stringify(ref).slice(1, -1).replace(/"/g, "\\u0022") // 旧
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`JSON.stringify` 已经把 `"` 写成 `\"`。那个 `replace` 只匹配裸引号,
|
|
89
|
+
**JSON 留下的反斜杠还在前面**,于是落盘成 `\\u0022`;`parseRef` 走 `JSON.parse`,
|
|
90
|
+
`\\` 解成一个字面反斜杠,取回 `"` 而不是 `"`。少匹配了一个反斜杠。
|
|
91
|
+
|
|
92
|
+
现在匹配 `\"` 整体:`.replace(/\\"/g, "\\u0022")`。引号仍须离开标签 ——
|
|
93
|
+
`"` 会提前终止它所在的 Mermaid `["..."]` —— 但这次是**替换**已转义的序列,
|
|
94
|
+
不是在它上面再叠一层。
|
|
95
|
+
|
|
96
|
+
反斜杠一侧一直是对的:`\\` 就是 JSON 在磁盘上的正确表示,`parseRef` 会解回单反斜杠。
|
|
97
|
+
只有引号这一条路径坏了。
|
|
98
|
+
|
|
99
|
+
### 🗑️ `memos_canvas` 新增 `delete` 动作
|
|
100
|
+
|
|
101
|
+
此前只有 open/update/show/list,清理必须手动删 `.mmd`。而画布的定位是**短期**状态,
|
|
102
|
+
注定会产生废弃文件 —— 加之全局规则禁止手工改动记忆文件,等于没有合法的清理路径。
|
|
103
|
+
|
|
104
|
+
`action="delete"` 在有未完成节点(doing/todo/blocked)时**拒绝**并给出逃生口,
|
|
105
|
+
全 done 或空画布直接删:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
❌ Canvas '000-x' still has 1 unfinished node (0 doing · 0 todo · 1 blocked)
|
|
109
|
+
Review it first: memos_canvas(action="show", name="000-x")
|
|
110
|
+
Delete anyway: memos_canvas(action="delete", name="000-x", confirm=true)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**没有套用 `MEMOS_ENABLE_DELETE`**。那道门是保护长期记忆的:走 API、有 embedding 成本、
|
|
114
|
+
且 Lite 模式下整个禁用。画布是本地文件、不走 API,而 Lite 模式下它恰恰是少数可用功能之一 ——
|
|
115
|
+
套上那道门等于在最需要清理的模式里砍掉清理能力。`confirm` 这种「拒绝并给出下一步」
|
|
116
|
+
与 `MAX_NODES`、非法 status 是同一个既有模式。
|
|
117
|
+
|
|
118
|
+
删除是硬删,不做回收站:画布存在的意义就是任务收尾后被丢弃,
|
|
119
|
+
回收站只会攒下它本该清掉的文件。`deleteCanvas` 复用 `canvasPath`,遍历防护自动继承。
|
|
120
|
+
|
|
121
|
+
### 🔢 前缀水位线:`delete` 破坏了「前缀永不复用」
|
|
122
|
+
|
|
123
|
+
加上 delete 之后有两个 e2e 断言失败。**不是断言写错** —— `nextPrefix` 用现存文件算
|
|
124
|
+
`max+1`,在只增不减的世界里「前缀永不复用」自动成立,delete 打破了这个不变量:
|
|
125
|
+
删掉最高前缀的画布后,那个前缀会被重新发放。
|
|
126
|
+
|
|
127
|
+
后果正是 `nextPrefix` 自己注释里警告过的:前缀嵌在它铸出的每个节点 id 里,
|
|
128
|
+
commit message 或记忆正文引用的 `001-N3` 会变得指向两个不同画布的节点。
|
|
129
|
+
|
|
130
|
+
修法是 `{cube}/canvas/.prefix-hwm` 水位线文件,`saveCanvas` 与 `deleteCanvas` 都抬升它,
|
|
131
|
+
只增不减。delete 侧必须抬升:文件是该前缀唯一的记录,删掉后若水位线仍低于它,
|
|
132
|
+
就会被重新发放。旧 cube 没有这个文件时(`readPrefixHwm` 返回 -1),
|
|
133
|
+
从现存文件恢复下限,所以升级不会因为水位线缺失而复用前缀。
|
|
134
|
+
|
|
135
|
+
水位线损坏或不可读时退回扫描文件,与 `parseCanvas` 一致 ——
|
|
136
|
+
画布是恢复态,读取时机正是刚丢失上下文的那一刻,抛异常是最坏的选择。
|
|
137
|
+
|
|
138
|
+
### 🧪 测试
|
|
139
|
+
|
|
140
|
+
canvas 单测 41 → 45(format)、28 → 44(store),canvas e2e 15 → 31 项。
|
|
141
|
+
两个修复都做了反向验证,还原实现后对应用例确实变红:转义 3 项、水位线 1 项(含 delete 侧那次抬升)。
|
|
142
|
+
|
|
143
|
+
e2e 补了「旧 cube(无水位线)删除后不复用前缀」这条真实升级路径 ——
|
|
144
|
+
它跨 store 与 handler,单测碰不到。
|
|
145
|
+
|
|
146
|
+
顺带改掉一个自己写坏的用例:名字叫 "does not free the prefix it used",
|
|
147
|
+
断言却在验证前缀被释放了。名字与断言矛盾的测试比没有测试更坏。
|
|
148
|
+
|
|
149
|
+
`memos_canvas` 的 schema 改了(action enum 加 `delete`,新增 `confirm`),
|
|
150
|
+
两处冻结的哈希同步重算:`schema-semantic-baseline.json` 与
|
|
151
|
+
`protocol-contract.test.ts` 的 `EXPECTED_SCHEMA_HASHES`。
|
|
152
|
+
|
|
153
|
+
门禁:vitest 511 passed、tsc、canvas e2e 31 项、pack 契约 104 文件、
|
|
154
|
+
schema budget +0.8%(限 5%)、semantic 快照 17 工具、protocol v2、lite smoke。
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
9
158
|
## [3.1.5] - 2026-08-26
|
|
10
159
|
|
|
11
160
|
3.1.1~3.1.4 都是「仅 MCP server」的版本,Python 包与容器镜像停在 3.1.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/canvas-format.d.ts
CHANGED
|
@@ -45,8 +45,31 @@ export declare const SUMMARY_MAX = 120;
|
|
|
45
45
|
*
|
|
46
46
|
* Returns "" when nothing usable remains (a purely CJK title, for instance);
|
|
47
47
|
* callers must have a fallback and must not write a file named "".
|
|
48
|
+
*
|
|
49
|
+
* `maxLength` narrows the cap for a caller that must leave room for a prefix.
|
|
50
|
+
* It is clamped to `SLUG_MAX`, so no caller can widen the invariant that every
|
|
51
|
+
* canvas filename fits in `SLUG_MAX` characters.
|
|
52
|
+
*/
|
|
53
|
+
export declare function slugify(input: string, maxLength?: number): string;
|
|
54
|
+
/**
|
|
55
|
+
* Compose a canvas filename from its prefix and the task goal.
|
|
56
|
+
*
|
|
57
|
+
* The prefix has to be part of the length budget, and it was not: `actionOpen`
|
|
58
|
+
* used to slug the goal to `SLUG_MAX` and then prepend `${prefix}-`, producing a
|
|
59
|
+
* name up to `SLUG_MAX + 4` characters. `canvasPath` slugs again on the way to
|
|
60
|
+
* disk and cut it back to `SLUG_MAX`, so the name reported by `open` was not the
|
|
61
|
+
* name `list` would show, and not the file that existed. Nothing was overwritten
|
|
62
|
+
* — the numeric prefix still made every canvas distinct — but the handle handed
|
|
63
|
+
* back to the caller was wrong.
|
|
64
|
+
*
|
|
65
|
+
* So the composition lives here, next to the cap it has to respect: slug the
|
|
66
|
+
* goal into what is left after the prefix, and the result is already a fixed
|
|
67
|
+
* point of `slugify`.
|
|
68
|
+
*
|
|
69
|
+
* A goal with no ASCII (a purely CJK title) slugs to "", so fall back to
|
|
70
|
+
* `<prefix>-task` rather than writing a file named after the prefix alone.
|
|
48
71
|
*/
|
|
49
|
-
export declare function
|
|
72
|
+
export declare function canvasName(prefix: string, goal: string): string;
|
|
50
73
|
/**
|
|
51
74
|
* Make text safe to sit inside a Mermaid `["..."]` label.
|
|
52
75
|
*
|
package/dist/canvas-format.js
CHANGED
|
@@ -36,12 +36,40 @@ const SLUG_MAX = 60;
|
|
|
36
36
|
*
|
|
37
37
|
* Returns "" when nothing usable remains (a purely CJK title, for instance);
|
|
38
38
|
* callers must have a fallback and must not write a file named "".
|
|
39
|
+
*
|
|
40
|
+
* `maxLength` narrows the cap for a caller that must leave room for a prefix.
|
|
41
|
+
* It is clamped to `SLUG_MAX`, so no caller can widen the invariant that every
|
|
42
|
+
* canvas filename fits in `SLUG_MAX` characters.
|
|
39
43
|
*/
|
|
40
|
-
export function slugify(input) {
|
|
44
|
+
export function slugify(input, maxLength = SLUG_MAX) {
|
|
45
|
+
const cap = Math.max(0, Math.min(maxLength, SLUG_MAX));
|
|
41
46
|
const lowered = String(input ?? "").toLowerCase();
|
|
42
47
|
const kept = lowered.replace(/[^a-z0-9]+/g, "-");
|
|
43
48
|
const collapsed = kept.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
|
44
|
-
return collapsed.slice(0,
|
|
49
|
+
return collapsed.slice(0, cap).replace(/-+$/g, "");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Compose a canvas filename from its prefix and the task goal.
|
|
53
|
+
*
|
|
54
|
+
* The prefix has to be part of the length budget, and it was not: `actionOpen`
|
|
55
|
+
* used to slug the goal to `SLUG_MAX` and then prepend `${prefix}-`, producing a
|
|
56
|
+
* name up to `SLUG_MAX + 4` characters. `canvasPath` slugs again on the way to
|
|
57
|
+
* disk and cut it back to `SLUG_MAX`, so the name reported by `open` was not the
|
|
58
|
+
* name `list` would show, and not the file that existed. Nothing was overwritten
|
|
59
|
+
* — the numeric prefix still made every canvas distinct — but the handle handed
|
|
60
|
+
* back to the caller was wrong.
|
|
61
|
+
*
|
|
62
|
+
* So the composition lives here, next to the cap it has to respect: slug the
|
|
63
|
+
* goal into what is left after the prefix, and the result is already a fixed
|
|
64
|
+
* point of `slugify`.
|
|
65
|
+
*
|
|
66
|
+
* A goal with no ASCII (a purely CJK title) slugs to "", so fall back to
|
|
67
|
+
* `<prefix>-task` rather than writing a file named after the prefix alone.
|
|
68
|
+
*/
|
|
69
|
+
export function canvasName(prefix, goal) {
|
|
70
|
+
const head = `${prefix}-`;
|
|
71
|
+
const slug = slugify(goal, SLUG_MAX - head.length);
|
|
72
|
+
return slug ? `${head}${slug}` : `${head}task`;
|
|
45
73
|
}
|
|
46
74
|
// ============================================================================
|
|
47
75
|
// escapeLabel
|
|
@@ -123,9 +151,19 @@ export function allocateNodeId(canvas) {
|
|
|
123
151
|
* Windows paths are the reason: `C:\Users\x` contains backslashes, which JSON
|
|
124
152
|
* escapes and which must come back byte-identical or the anchor stops resolving.
|
|
125
153
|
* Label escaping is for prose; refs get JSON, which is lossless.
|
|
154
|
+
*
|
|
155
|
+
* One transform sits on top of JSON: `JSON.stringify` renders a quote as `\"`,
|
|
156
|
+
* and that bare quote would still close the Mermaid label. Promoting the whole
|
|
157
|
+
* two-character escape to `\u0022` keeps the label intact while staying valid
|
|
158
|
+
* JSON, so `parseRef` decodes it back to `"`.
|
|
159
|
+
*
|
|
160
|
+
* The `\\` in the pattern is load-bearing. Replacing only the quote leaves
|
|
161
|
+
* JSON's backslash in front of the substitution, producing `\\u0022` — which
|
|
162
|
+
* JSON.parse reads as a literal backslash followed by `u0022`, so a ref
|
|
163
|
+
* containing a quote comes back as the text `\u0022` instead of the quote.
|
|
126
164
|
*/
|
|
127
165
|
function renderRef(ref) {
|
|
128
|
-
return JSON.stringify(ref).slice(1, -1).replace(
|
|
166
|
+
return JSON.stringify(ref).slice(1, -1).replace(/\\"/g, "\\u0022");
|
|
129
167
|
}
|
|
130
168
|
function parseRef(raw) {
|
|
131
169
|
try {
|
|
@@ -142,10 +180,7 @@ export function renderCanvas(canvas) {
|
|
|
142
180
|
updatedTime: canvas.updatedTime,
|
|
143
181
|
prefix: canvas.prefix,
|
|
144
182
|
};
|
|
145
|
-
const lines = [
|
|
146
|
-
`%%${JSON.stringify(meta)}%%`,
|
|
147
|
-
"graph LR",
|
|
148
|
-
];
|
|
183
|
+
const lines = [`%%${JSON.stringify(meta)}%%`, "graph LR"];
|
|
149
184
|
for (const node of canvas.nodes) {
|
|
150
185
|
const parts = [
|
|
151
186
|
`status: ${node.status}`,
|
|
@@ -192,9 +227,12 @@ export function parseCanvas(content) {
|
|
|
192
227
|
try {
|
|
193
228
|
const raw = metaMatch[0].slice(2, -2);
|
|
194
229
|
const parsed = JSON.parse(raw);
|
|
195
|
-
canvas.taskGoal =
|
|
196
|
-
|
|
197
|
-
canvas.
|
|
230
|
+
canvas.taskGoal =
|
|
231
|
+
typeof parsed.taskGoal === "string" ? parsed.taskGoal : "";
|
|
232
|
+
canvas.createdTime =
|
|
233
|
+
typeof parsed.createdTime === "string" ? parsed.createdTime : null;
|
|
234
|
+
canvas.updatedTime =
|
|
235
|
+
typeof parsed.updatedTime === "string" ? parsed.updatedTime : null;
|
|
198
236
|
if (typeof parsed.prefix === "string" && /^\d{3}$/.test(parsed.prefix)) {
|
|
199
237
|
canvas.prefix = parsed.prefix;
|
|
200
238
|
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)
|
package/dist/handlers/canvas.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* compaction, and anchors that make a summary checkable.
|
|
28
28
|
*/
|
|
29
29
|
import { MEMOS_CUBES_DIR } from "../config.js";
|
|
30
|
-
import { NODE_STATUSES, allocateNodeId, countByStatus, formatCanvasHeadline,
|
|
31
|
-
import { CanvasPathError, listCanvases, loadCanvas, nextPrefix, saveCanvas, } from "../canvas-store.js";
|
|
30
|
+
import { NODE_STATUSES, allocateNodeId, canvasName, countByStatus, formatCanvasHeadline, truncateSummary, } from "../canvas-format.js";
|
|
31
|
+
import { CanvasPathError, deleteCanvas, listCanvases, loadCanvas, nextPrefix, saveCanvas, } from "../canvas-store.js";
|
|
32
32
|
import { ERR_PARAM_INVALID, ERR_PARAM_MISSING, ERR_OPERATION_FAILED, errorResponse, getCubeIdFromArgs, } from "./utils.js";
|
|
33
33
|
const STATUS_LIST = NODE_STATUSES.join(", ");
|
|
34
34
|
/** Ceiling on nodes in one canvas. Past this the canvas has stopped being a
|
|
@@ -36,14 +36,20 @@ const STATUS_LIST = NODE_STATUSES.join(", ");
|
|
|
36
36
|
const MAX_NODES = 40;
|
|
37
37
|
function statusIcon(status) {
|
|
38
38
|
switch (status) {
|
|
39
|
-
case "done":
|
|
40
|
-
|
|
41
|
-
case "
|
|
42
|
-
|
|
39
|
+
case "done":
|
|
40
|
+
return "✓";
|
|
41
|
+
case "doing":
|
|
42
|
+
return "▶";
|
|
43
|
+
case "blocked":
|
|
44
|
+
return "✗";
|
|
45
|
+
default:
|
|
46
|
+
return "○";
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
function parseStatus(raw) {
|
|
46
|
-
const value = String(raw ?? "")
|
|
50
|
+
const value = String(raw ?? "")
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase();
|
|
47
53
|
return NODE_STATUSES.includes(value)
|
|
48
54
|
? value
|
|
49
55
|
: null;
|
|
@@ -112,10 +118,9 @@ function actionOpen(cubeId, args) {
|
|
|
112
118
|
if (prefix === null) {
|
|
113
119
|
return errorResponse(`Cube '${cubeId}' already holds 1000 canvases (000-999)`, ERR_OPERATION_FAILED, ["Remove finished canvas files from the cube's canvas/ directory"]);
|
|
114
120
|
}
|
|
115
|
-
// The
|
|
116
|
-
//
|
|
117
|
-
const
|
|
118
|
-
const name = goalSlug ? `${prefix}-${goalSlug}` : `${prefix}-task`;
|
|
121
|
+
// The prefix is part of the filename budget, so composing the name is
|
|
122
|
+
// `canvasName`'s job — see the note there on why doing it inline was wrong.
|
|
123
|
+
const name = canvasName(prefix, goal);
|
|
119
124
|
const now = new Date().toISOString();
|
|
120
125
|
const canvas = {
|
|
121
126
|
prefix,
|
|
@@ -126,14 +131,16 @@ function actionOpen(cubeId, args) {
|
|
|
126
131
|
};
|
|
127
132
|
try {
|
|
128
133
|
const path = saveCanvas(MEMOS_CUBES_DIR, cubeId, name, canvas);
|
|
129
|
-
return [
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
130
136
|
type: "text",
|
|
131
137
|
text: [
|
|
132
138
|
renderCanvasView(name, path, canvas),
|
|
133
139
|
"",
|
|
134
140
|
`Canvas \`${name}\` created. Node ids will be \`${prefix}-N1\`, \`${prefix}-N2\`, …`,
|
|
135
141
|
].join("\n"),
|
|
136
|
-
}
|
|
142
|
+
},
|
|
143
|
+
];
|
|
137
144
|
}
|
|
138
145
|
catch (err) {
|
|
139
146
|
return pathErrorResponse(err);
|
|
@@ -189,7 +196,9 @@ function actionUpdate(cubeId, args) {
|
|
|
189
196
|
else {
|
|
190
197
|
// Append a new node.
|
|
191
198
|
if (summaryRaw === undefined || String(summaryRaw).trim() === "") {
|
|
192
|
-
return errorResponse("`summary` is required when appending a node", ERR_PARAM_MISSING, [
|
|
199
|
+
return errorResponse("`summary` is required when appending a node", ERR_PARAM_MISSING, [
|
|
200
|
+
'memos_canvas(action="update", name="...", summary="<step>", status="doing")',
|
|
201
|
+
]);
|
|
193
202
|
}
|
|
194
203
|
if (canvas.nodes.length >= MAX_NODES) {
|
|
195
204
|
return errorResponse(`Canvas '${name}' already has ${MAX_NODES} nodes`, ERR_OPERATION_FAILED, [
|
|
@@ -212,7 +221,8 @@ function actionUpdate(cubeId, args) {
|
|
|
212
221
|
canvas.updatedTime = new Date().toISOString();
|
|
213
222
|
try {
|
|
214
223
|
const path = saveCanvas(MEMOS_CUBES_DIR, cubeId, name, canvas);
|
|
215
|
-
return [
|
|
224
|
+
return [
|
|
225
|
+
{
|
|
216
226
|
type: "text",
|
|
217
227
|
text: [
|
|
218
228
|
`${statusIcon(touched.status)} \`${touched.id}\` ${touched.summary}` +
|
|
@@ -220,7 +230,8 @@ function actionUpdate(cubeId, args) {
|
|
|
220
230
|
"",
|
|
221
231
|
renderCanvasView(name, path, canvas),
|
|
222
232
|
].join("\n"),
|
|
223
|
-
}
|
|
233
|
+
},
|
|
234
|
+
];
|
|
224
235
|
}
|
|
225
236
|
catch (err) {
|
|
226
237
|
return pathErrorResponse(err);
|
|
@@ -241,18 +252,84 @@ function actionShow(cubeId, args) {
|
|
|
241
252
|
return pathErrorResponse(err);
|
|
242
253
|
}
|
|
243
254
|
if (canvas === null) {
|
|
244
|
-
return errorResponse(`Canvas '${name}' not found in cube '${cubeId}'`, ERR_PARAM_INVALID, [
|
|
245
|
-
'List canvases: `memos_canvas(action="list", project_path="<cwd>")`',
|
|
246
|
-
]);
|
|
255
|
+
return errorResponse(`Canvas '${name}' not found in cube '${cubeId}'`, ERR_PARAM_INVALID, ['List canvases: `memos_canvas(action="list", project_path="<cwd>")`']);
|
|
247
256
|
}
|
|
248
257
|
// Recompute the path for display only; load already proved it resolves.
|
|
249
258
|
const entry = listCanvases(MEMOS_CUBES_DIR, cubeId).find((e) => e.name === name);
|
|
250
|
-
return [
|
|
259
|
+
return [
|
|
260
|
+
{ type: "text", text: renderCanvasView(name, entry?.path ?? name, canvas) },
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Delete a canvas file.
|
|
265
|
+
*
|
|
266
|
+
* A canvas with open work is refused unless `confirm=true`. The asymmetry is the
|
|
267
|
+
* point: a canvas whose nodes are all `done` is finished bookkeeping and deleting
|
|
268
|
+
* it needs no ceremony, while one still holding `doing`/`todo`/`blocked` nodes is
|
|
269
|
+
* the only record of where a task stopped — and that record is most valuable at
|
|
270
|
+
* exactly the moment the model has lost its context and might delete it by
|
|
271
|
+
* mistake. The refusal names what would be lost so the decision is informed.
|
|
272
|
+
*
|
|
273
|
+
* This is not gated behind MEMOS_ENABLE_DELETE. That gate protects long-term
|
|
274
|
+
* memories behind the HTTP provider; a canvas is a local file with an
|
|
275
|
+
* hours-long lifetime, and in Lite mode the canvas is one of the few things that
|
|
276
|
+
* works — gating it there would remove cleanup precisely where it is needed.
|
|
277
|
+
*/
|
|
278
|
+
function actionDelete(cubeId, args) {
|
|
279
|
+
const name = String(args.name ?? "").trim();
|
|
280
|
+
if (!name) {
|
|
281
|
+
return errorResponse("`name` is required to delete a canvas", ERR_PARAM_MISSING, ['List canvases: `memos_canvas(action="list", project_path="<cwd>")`']);
|
|
282
|
+
}
|
|
283
|
+
let canvas;
|
|
284
|
+
try {
|
|
285
|
+
canvas = loadCanvas(MEMOS_CUBES_DIR, cubeId, name);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
return pathErrorResponse(err);
|
|
289
|
+
}
|
|
290
|
+
if (canvas === null) {
|
|
291
|
+
return errorResponse(`Canvas '${name}' not found in cube '${cubeId}'`, ERR_PARAM_INVALID, ['List canvases: `memos_canvas(action="list", project_path="<cwd>")`']);
|
|
292
|
+
}
|
|
293
|
+
const counts = countByStatus(canvas);
|
|
294
|
+
const open = counts.doing + counts.todo + counts.blocked;
|
|
295
|
+
if (open > 0 && args.confirm !== true) {
|
|
296
|
+
return errorResponse(`Canvas '${name}' still has ${open} unfinished node${open === 1 ? "" : "s"} ` +
|
|
297
|
+
`(${counts.doing} doing · ${counts.todo} todo · ${counts.blocked} blocked)`, ERR_PARAM_INVALID, [
|
|
298
|
+
`Review it first: \`memos_canvas(action="show", name="${name}")\``,
|
|
299
|
+
`Delete anyway: \`memos_canvas(action="delete", name="${name}", confirm=true)\``,
|
|
300
|
+
]);
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const removed = deleteCanvas(MEMOS_CUBES_DIR, cubeId, name);
|
|
304
|
+
if (!removed) {
|
|
305
|
+
// loadCanvas found it a moment ago, so this is a concurrent delete rather
|
|
306
|
+
// than a caller mistake. Same end state either way.
|
|
307
|
+
return [{ type: "text", text: `Canvas \`${name}\` was already gone.` }];
|
|
308
|
+
}
|
|
309
|
+
const tally = `${counts.done} done · ${counts.doing} doing · ${counts.todo} todo` +
|
|
310
|
+
(counts.blocked > 0 ? ` · ${counts.blocked} blocked` : "");
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
type: "text",
|
|
314
|
+
text: [
|
|
315
|
+
`🗑️ Deleted canvas \`${name}\` from \`${cubeId}\` (${canvas.nodes.length} node${canvas.nodes.length === 1 ? "" : "s"}: ${tally})`,
|
|
316
|
+
"",
|
|
317
|
+
`**Goal was**: ${canvas.taskGoal || "(none recorded)"}`,
|
|
318
|
+
"",
|
|
319
|
+
"The file is gone — `mem:` refs it cited still exist in the graph.",
|
|
320
|
+
].join("\n"),
|
|
321
|
+
},
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return pathErrorResponse(err);
|
|
326
|
+
}
|
|
251
327
|
}
|
|
252
328
|
function actionList(cubeId) {
|
|
253
329
|
const entries = listCanvases(MEMOS_CUBES_DIR, cubeId);
|
|
254
330
|
if (entries.length === 0) {
|
|
255
|
-
return [
|
|
331
|
+
return [
|
|
332
|
+
{
|
|
256
333
|
type: "text",
|
|
257
334
|
text: [
|
|
258
335
|
`## 🗺️ Canvases: ${cubeId}`,
|
|
@@ -261,7 +338,8 @@ function actionList(cubeId) {
|
|
|
261
338
|
"",
|
|
262
339
|
'Open one: `memos_canvas(action="open", goal="<task>", project_path="<cwd>")`',
|
|
263
340
|
].join("\n"),
|
|
264
|
-
}
|
|
341
|
+
},
|
|
342
|
+
];
|
|
265
343
|
}
|
|
266
344
|
// Unfinished first: after a compaction the open work is the only part that
|
|
267
345
|
// still matters.
|
|
@@ -284,7 +362,9 @@ function actionList(cubeId) {
|
|
|
284
362
|
// ============================================================================
|
|
285
363
|
export async function handleMemosCanvas(arguments_) {
|
|
286
364
|
const cubeId = getCubeIdFromArgs(arguments_);
|
|
287
|
-
const action = String(arguments_.action ?? "")
|
|
365
|
+
const action = String(arguments_.action ?? "")
|
|
366
|
+
.trim()
|
|
367
|
+
.toLowerCase();
|
|
288
368
|
switch (action) {
|
|
289
369
|
case "open":
|
|
290
370
|
return actionOpen(cubeId, arguments_);
|
|
@@ -294,8 +374,10 @@ export async function handleMemosCanvas(arguments_) {
|
|
|
294
374
|
return actionShow(cubeId, arguments_);
|
|
295
375
|
case "list":
|
|
296
376
|
return actionList(cubeId);
|
|
377
|
+
case "delete":
|
|
378
|
+
return actionDelete(cubeId, arguments_);
|
|
297
379
|
default:
|
|
298
|
-
return errorResponse(`Unknown canvas action: ${String(arguments_.action ?? "(none)")}`, ERR_PARAM_INVALID, ["Valid actions: open, update, show, list"]);
|
|
380
|
+
return errorResponse(`Unknown canvas action: ${String(arguments_.action ?? "(none)")}`, ERR_PARAM_INVALID, ["Valid actions: open, update, show, list, delete"]);
|
|
299
381
|
}
|
|
300
382
|
}
|
|
301
383
|
/**
|
package/dist/tools-registry.d.ts
CHANGED
|
@@ -212,6 +212,7 @@ export declare const toolSchemas: {
|
|
|
212
212
|
inputSchema: z.ZodObject<{
|
|
213
213
|
action: z.ZodEnum<{
|
|
214
214
|
show: "show";
|
|
215
|
+
delete: "delete";
|
|
215
216
|
list: "list";
|
|
216
217
|
update: "update";
|
|
217
218
|
open: "open";
|
|
@@ -229,6 +230,7 @@ export declare const toolSchemas: {
|
|
|
229
230
|
blocked: "blocked";
|
|
230
231
|
}>>;
|
|
231
232
|
ref: z.ZodOptional<z.ZodString>;
|
|
233
|
+
confirm: z.ZodOptional<z.ZodBoolean>;
|
|
232
234
|
}, z.core.$strip>;
|
|
233
235
|
};
|
|
234
236
|
memos_delete: {
|
package/dist/tools-registry.js
CHANGED
|
@@ -25,14 +25,26 @@ const cubeIdParam = z
|
|
|
25
25
|
// Brief variants for every tool except the hot paths (save/search): the full
|
|
26
26
|
// routing story is told once there; repeating it on every tool cost ~1.4k
|
|
27
27
|
// tokens of pure duplication on the tool surface.
|
|
28
|
-
const projectPathBrief = z
|
|
28
|
+
const projectPathBrief = z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
29
31
|
.describe("Project root; cube_id is derived from it (details: memos_search)");
|
|
30
|
-
const cubeIdBrief = z
|
|
32
|
+
const cubeIdBrief = z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
31
35
|
.describe("Explicit cube id; prefer project_path");
|
|
32
36
|
const cubeIdBriefDefault = cubeIdBrief.default(MEMOS_DEFAULT_CUBE);
|
|
33
37
|
const memoryTypeEnum = z.enum([
|
|
34
|
-
"ERROR_PATTERN",
|
|
35
|
-
"
|
|
38
|
+
"ERROR_PATTERN",
|
|
39
|
+
"DECISION",
|
|
40
|
+
"MILESTONE",
|
|
41
|
+
"BUGFIX",
|
|
42
|
+
"FEATURE",
|
|
43
|
+
"CONFIG",
|
|
44
|
+
"CODE_PATTERN",
|
|
45
|
+
"GOTCHA",
|
|
46
|
+
"PROGRESS",
|
|
47
|
+
"SYNTHESIS",
|
|
36
48
|
]);
|
|
37
49
|
// ============================================================================
|
|
38
50
|
// Tool Schema Definitions
|
|
@@ -51,15 +63,29 @@ Afterwards use memos tools for ALL memory operations — never mkdir/Write memor
|
|
|
51
63
|
Query may carry a type prefix ("DECISION auth"). Pass context (recent turns) when the query is ambiguous or refers to earlier conversation — enables LLM intent analysis + query expansion.
|
|
52
64
|
Large results are compacted; use memos_get(memory_id) for full details. NEVER create memory files via mkdir/Write — all memories live here.`,
|
|
53
65
|
inputSchema: z.object({
|
|
54
|
-
query: z
|
|
55
|
-
|
|
66
|
+
query: z
|
|
67
|
+
.string()
|
|
68
|
+
.describe("Search query. Can be natural language or prefixed with memory type (e.g., 'ERROR_PATTERN ModuleNotFoundError')"),
|
|
69
|
+
context: z
|
|
70
|
+
.array(z.object({
|
|
56
71
|
role: z.enum(["user", "assistant"]),
|
|
57
72
|
content: z.string(),
|
|
58
|
-
}))
|
|
73
|
+
}))
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Recent conversation turns (last 5-10); triggers context-aware search"),
|
|
59
76
|
project_path: projectPathParam,
|
|
60
77
|
cube_id: cubeIdParam,
|
|
61
|
-
top_k: z
|
|
62
|
-
|
|
78
|
+
top_k: z
|
|
79
|
+
.number()
|
|
80
|
+
.int()
|
|
81
|
+
.optional()
|
|
82
|
+
.default(10)
|
|
83
|
+
.describe("Max results (default 10)"),
|
|
84
|
+
compact: z
|
|
85
|
+
.boolean()
|
|
86
|
+
.optional()
|
|
87
|
+
.default(true)
|
|
88
|
+
.describe("Compact large results (default true)"),
|
|
63
89
|
}),
|
|
64
90
|
},
|
|
65
91
|
memos_think: {
|
|
@@ -69,8 +95,18 @@ YOU synthesize the answer from it, citing [n]; persist valuable syntheses via me
|
|
|
69
95
|
query: z.string().describe("The question to gather evidence for"),
|
|
70
96
|
project_path: projectPathBrief,
|
|
71
97
|
cube_id: cubeIdBriefDefault,
|
|
72
|
-
top_k: z
|
|
73
|
-
|
|
98
|
+
top_k: z
|
|
99
|
+
.number()
|
|
100
|
+
.int()
|
|
101
|
+
.optional()
|
|
102
|
+
.default(15)
|
|
103
|
+
.describe("Evidence pool size (3-30)"),
|
|
104
|
+
fresh_days: z
|
|
105
|
+
.number()
|
|
106
|
+
.int()
|
|
107
|
+
.optional()
|
|
108
|
+
.default(30)
|
|
109
|
+
.describe("Days before time-sensitive memories count as stale"),
|
|
74
110
|
}),
|
|
75
111
|
},
|
|
76
112
|
memos_export_wiki: {
|
|
@@ -79,8 +115,15 @@ Default output <project_path>/docs/memory-wiki; re-export replaces only files it
|
|
|
79
115
|
inputSchema: z.object({
|
|
80
116
|
project_path: projectPathBrief,
|
|
81
117
|
cube_id: cubeIdBriefDefault,
|
|
82
|
-
output_dir: z
|
|
83
|
-
|
|
118
|
+
output_dir: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("Target directory (default: <project_path>/docs/memory-wiki)"),
|
|
122
|
+
include_archived: z
|
|
123
|
+
.boolean()
|
|
124
|
+
.optional()
|
|
125
|
+
.default(false)
|
|
126
|
+
.describe("Also export archived memories"),
|
|
84
127
|
}),
|
|
85
128
|
},
|
|
86
129
|
memos_import_wiki: {
|
|
@@ -89,9 +132,20 @@ Use after editing exported pages (fixes, cleanup, migration to a fresh cube). Re
|
|
|
89
132
|
inputSchema: z.object({
|
|
90
133
|
project_path: projectPathBrief,
|
|
91
134
|
cube_id: cubeIdBriefDefault,
|
|
92
|
-
wiki_dir: z
|
|
93
|
-
|
|
94
|
-
|
|
135
|
+
wiki_dir: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Wiki directory (default: <project_path>/docs/memory-wiki)"),
|
|
139
|
+
dry_run: z
|
|
140
|
+
.boolean()
|
|
141
|
+
.optional()
|
|
142
|
+
.default(false)
|
|
143
|
+
.describe("Preview the diff without writing (default false)"),
|
|
144
|
+
on_edit: z
|
|
145
|
+
.enum(["skip", "version"])
|
|
146
|
+
.optional()
|
|
147
|
+
.default("skip")
|
|
148
|
+
.describe("Edited pages: skip (default) or save as new version (old memory kept)"),
|
|
95
149
|
}),
|
|
96
150
|
},
|
|
97
151
|
memos_save: {
|
|
@@ -99,7 +153,9 @@ Use after editing exported pages (fixes, cleanup, migration to a fresh cube). Re
|
|
|
99
153
|
🚨 MUST pass memory_type explicitly (decision tree: memos_suggest). In Lite mode this remains the primary durable write path.
|
|
100
154
|
This is the ONLY way to persist memories — never mkdir/Write memory files.`,
|
|
101
155
|
inputSchema: z.object({
|
|
102
|
-
content: z
|
|
156
|
+
content: z
|
|
157
|
+
.string()
|
|
158
|
+
.describe("Memory content. Be detailed — include context, rationale, relevant code/commands."),
|
|
103
159
|
memory_type: memoryTypeEnum.describe("**REQUIRED**. bug fix → BUGFIX/ERROR_PATTERN · decision → DECISION · gotcha → GOTCHA · " +
|
|
104
160
|
"template → CODE_PATTERN · config → CONFIG · feature → FEATURE · achievement → MILESTONE · " +
|
|
105
161
|
"status update → PROGRESS · synthesized answer → SYNTHESIS. Full rules: memos_suggest"),
|
|
@@ -112,9 +168,18 @@ This is the ONLY way to persist memories — never mkdir/Write memory files.`,
|
|
|
112
168
|
inputSchema: z.object({
|
|
113
169
|
project_path: projectPathBrief,
|
|
114
170
|
cube_id: cubeIdBriefDefault,
|
|
115
|
-
limit: z
|
|
171
|
+
limit: z
|
|
172
|
+
.number()
|
|
173
|
+
.int()
|
|
174
|
+
.optional()
|
|
175
|
+
.default(20)
|
|
176
|
+
.describe("Max memories returned"),
|
|
116
177
|
memory_type: memoryTypeEnum.optional().describe("Filter by type"),
|
|
117
|
-
compact: z
|
|
178
|
+
compact: z
|
|
179
|
+
.boolean()
|
|
180
|
+
.optional()
|
|
181
|
+
.default(true)
|
|
182
|
+
.describe("Compact large results"),
|
|
118
183
|
}),
|
|
119
184
|
},
|
|
120
185
|
memos_get: {
|
|
@@ -130,7 +195,9 @@ This is the ONLY way to persist memories — never mkdir/Write memory files.`,
|
|
|
130
195
|
|
|
131
196
|
Use when: unsure what to search for, or unsure which memory_type \`memos_save\` should use.`,
|
|
132
197
|
inputSchema: z.object({
|
|
133
|
-
context: z
|
|
198
|
+
context: z
|
|
199
|
+
.string()
|
|
200
|
+
.describe("Current context (error message, code, user question)"),
|
|
134
201
|
}),
|
|
135
202
|
},
|
|
136
203
|
memos_distill_skill: {
|
|
@@ -139,7 +206,10 @@ Use when: unsure what to search for, or unsure which memory_type \`memos_save\`
|
|
|
139
206
|
project_path: projectPathBrief,
|
|
140
207
|
title: z.string().describe("Candidate skill title"),
|
|
141
208
|
summary: z.string().describe("Reviewed workflow summary"),
|
|
142
|
-
memory_ids: z
|
|
209
|
+
memory_ids: z
|
|
210
|
+
.array(z.string())
|
|
211
|
+
.min(1)
|
|
212
|
+
.describe("Evidence memory IDs supporting this workflow"),
|
|
143
213
|
}),
|
|
144
214
|
},
|
|
145
215
|
memos_list_skill_candidates: {
|
|
@@ -157,21 +227,42 @@ Use when: unsure what to search for, or unsure which memory_type \`memos_save\`
|
|
|
157
227
|
},
|
|
158
228
|
memos_install_skill_candidate: {
|
|
159
229
|
description: `Install an approved candidate into <project_path>/.claude/skills/<slug>/SKILL.md. Requires explicit action; refuses overwrite, symlinks, malformed candidates, and unapproved status.`,
|
|
160
|
-
inputSchema: z.object({
|
|
230
|
+
inputSchema: z.object({
|
|
231
|
+
project_path: projectPathBrief,
|
|
232
|
+
candidate_id: z.string(),
|
|
233
|
+
}),
|
|
161
234
|
},
|
|
162
235
|
memos_graph: {
|
|
163
236
|
description: `Knowledge-graph queries; mode selects:
|
|
164
237
|
related = nodes+edges around a query · path = trace between two node ids · impact = forward blast radius of one memory · schema = graph structure & statistics · import = validate Graphify node-link JSON (dry-run only).`,
|
|
165
238
|
inputSchema: z.object({
|
|
166
|
-
mode: z
|
|
239
|
+
mode: z
|
|
240
|
+
.enum(["related", "path", "impact", "schema", "import"])
|
|
241
|
+
.describe("Query kind"),
|
|
167
242
|
query: z.string().optional().describe("related: search query"),
|
|
168
243
|
source_id: z.string().optional().describe("path: start node id"),
|
|
169
244
|
target_id: z.string().optional().describe("path: end node id"),
|
|
170
245
|
memory_id: z.string().optional().describe("impact: source memory id"),
|
|
171
|
-
max_depth: z
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
246
|
+
max_depth: z
|
|
247
|
+
.number()
|
|
248
|
+
.int()
|
|
249
|
+
.optional()
|
|
250
|
+
.default(3)
|
|
251
|
+
.describe("path/impact: max hops"),
|
|
252
|
+
sample_size: z
|
|
253
|
+
.number()
|
|
254
|
+
.int()
|
|
255
|
+
.optional()
|
|
256
|
+
.default(100)
|
|
257
|
+
.describe("schema: nodes sampled"),
|
|
258
|
+
graph_json: z
|
|
259
|
+
.string()
|
|
260
|
+
.optional()
|
|
261
|
+
.describe("import: Graphify graph.json contents; validation is dry-run and never writes data"),
|
|
262
|
+
project_key: z
|
|
263
|
+
.string()
|
|
264
|
+
.optional()
|
|
265
|
+
.describe("import: stable project namespace label"),
|
|
175
266
|
project_path: projectPathBrief,
|
|
176
267
|
cube_id: cubeIdBriefDefault,
|
|
177
268
|
}),
|
|
@@ -180,35 +271,78 @@ related = nodes+edges around a query · path = trace between two node ids · imp
|
|
|
180
271
|
description: `Maintenance & diagnostics; action selects:
|
|
181
272
|
list_cubes · register_cube (fix "not loaded") · create_user (fix "user does not exist") · validate_cubes (repair config mismatches) · stats (per-type counts) · calendar (milestone timeline) · capabilities (Full/Lite behavior and unsupported operations).`,
|
|
182
273
|
inputSchema: z.object({
|
|
183
|
-
action: z
|
|
274
|
+
action: z
|
|
275
|
+
.enum([
|
|
276
|
+
"list_cubes",
|
|
277
|
+
"register_cube",
|
|
278
|
+
"create_user",
|
|
279
|
+
"validate_cubes",
|
|
280
|
+
"stats",
|
|
281
|
+
"calendar",
|
|
282
|
+
"capabilities",
|
|
283
|
+
])
|
|
284
|
+
.describe("Operation"),
|
|
184
285
|
cube_id: cubeIdBrief,
|
|
185
286
|
project_path: projectPathBrief,
|
|
186
|
-
cube_path: z
|
|
287
|
+
cube_path: z
|
|
288
|
+
.string()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("register_cube: cube directory (auto-detected if omitted)"),
|
|
187
291
|
user_id: z.string().optional().describe("create_user: user id"),
|
|
188
292
|
user_name: z.string().optional().describe("create_user: display name"),
|
|
189
|
-
fix: z
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
293
|
+
fix: z
|
|
294
|
+
.boolean()
|
|
295
|
+
.optional()
|
|
296
|
+
.default(true)
|
|
297
|
+
.describe("validate_cubes: auto-fix"),
|
|
298
|
+
include_status: z
|
|
299
|
+
.boolean()
|
|
300
|
+
.optional()
|
|
301
|
+
.default(false)
|
|
302
|
+
.describe("list_cubes: check registration"),
|
|
303
|
+
mode: z
|
|
304
|
+
.enum(["project", "student"])
|
|
305
|
+
.optional()
|
|
306
|
+
.describe("calendar: view mode"),
|
|
307
|
+
semester: z
|
|
308
|
+
.string()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe("calendar student: 'YYYY-Season' or 'current'"),
|
|
193
311
|
course: z.string().optional().describe("calendar student: course filter"),
|
|
194
312
|
week: z.number().int().optional().describe("calendar student: week 1-18"),
|
|
195
|
-
view: z
|
|
313
|
+
view: z
|
|
314
|
+
.enum(["list", "week", "month"])
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("calendar student: format"),
|
|
196
317
|
}),
|
|
197
318
|
},
|
|
198
319
|
memos_canvas: {
|
|
199
320
|
description: `Task canvas — short-term task state that survives context compaction.
|
|
200
|
-
action: open (needs goal) · update (append node; node_id edits one) · show · list.
|
|
321
|
+
action: open (needs goal) · update (append node; node_id edits one) · show · list · delete.
|
|
201
322
|
Node ids are greppable (000-N1); ref anchors evidence: mem:<id> / file:<path> / note:<text>.`,
|
|
202
323
|
inputSchema: z.object({
|
|
203
|
-
action: z.enum(["open", "update", "show", "list"]),
|
|
324
|
+
action: z.enum(["open", "update", "show", "list", "delete"]),
|
|
204
325
|
project_path: projectPathBrief,
|
|
205
326
|
cube_id: cubeIdBrief,
|
|
206
|
-
name: z
|
|
327
|
+
name: z
|
|
328
|
+
.string()
|
|
329
|
+
.optional()
|
|
330
|
+
.describe("update/show/delete: canvas name (from list)"),
|
|
207
331
|
goal: z.string().optional().describe("open: the task"),
|
|
208
|
-
node_id: z
|
|
209
|
-
|
|
332
|
+
node_id: z
|
|
333
|
+
.string()
|
|
334
|
+
.optional()
|
|
335
|
+
.describe("update: edit this node, else append"),
|
|
336
|
+
summary: z
|
|
337
|
+
.string()
|
|
338
|
+
.optional()
|
|
339
|
+
.describe("update: step text (required to append)"),
|
|
210
340
|
status: z.enum(["todo", "doing", "done", "blocked"]).optional(),
|
|
211
341
|
ref: z.string().optional().describe("update: evidence anchor"),
|
|
342
|
+
confirm: z
|
|
343
|
+
.boolean()
|
|
344
|
+
.optional()
|
|
345
|
+
.describe("delete: required only when the canvas still has unfinished nodes"),
|
|
212
346
|
}),
|
|
213
347
|
},
|
|
214
348
|
// Delete tool schema (registered conditionally)
|
|
@@ -230,11 +364,21 @@ Before deleting, always:
|
|
|
230
364
|
2. Show the memory content that will be deleted
|
|
231
365
|
3. Get explicit user approval`,
|
|
232
366
|
inputSchema: z.object({
|
|
233
|
-
memory_id: z
|
|
234
|
-
|
|
367
|
+
memory_id: z
|
|
368
|
+
.string()
|
|
369
|
+
.optional()
|
|
370
|
+
.describe("ID of the specific memory to delete. Get this from memos_search or memos_list."),
|
|
371
|
+
memory_ids: z
|
|
372
|
+
.array(z.string())
|
|
373
|
+
.optional()
|
|
374
|
+
.describe("List of memory IDs to delete in batch."),
|
|
235
375
|
project_path: projectPathBrief,
|
|
236
376
|
cube_id: cubeIdBriefDefault,
|
|
237
|
-
delete_all: z
|
|
377
|
+
delete_all: z
|
|
378
|
+
.boolean()
|
|
379
|
+
.optional()
|
|
380
|
+
.default(false)
|
|
381
|
+
.describe("Set to true to delete ALL memories in the cube. DANGEROUS! Requires explicit user confirmation."),
|
|
238
382
|
}),
|
|
239
383
|
},
|
|
240
384
|
};
|