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 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`. Nodes carry greppable ids (`000-N1`) and a `ref` anchoring them to evidence: `mem:<memory_id>` / `file:<path>` / `note:<text>` |
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 |
@@ -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 slugify(input: string): string;
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
  *
@@ -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, SLUG_MAX).replace(/-+$/g, "");
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(/"/g, "\\u0022");
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 = typeof parsed.taskGoal === "string" ? parsed.taskGoal : "";
196
- canvas.createdTime = typeof parsed.createdTime === "string" ? parsed.createdTime : null;
197
- canvas.updatedTime = typeof parsed.updatedTime === "string" ? parsed.updatedTime : null;
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;
@@ -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
@@ -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) || basename(full) !== `${slug}${CANVAS_EXT}`) {
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({ name, path, canvas: parseCanvas(readFileSync(path, "utf8")) });
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
- const PREFIX_RE = /^(\d{3})/;
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 = -1;
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)
@@ -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, slugify, truncateSummary, } from "../canvas-format.js";
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": return "✓";
40
- case "doing": return "▶";
41
- case "blocked": return "✗";
42
- default: return "○";
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 ?? "").trim().toLowerCase();
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 goal supplies the slug; a goal with no ASCII (a Chinese title, say)
116
- // yields none, so fall back to the prefix rather than writing a nameless file.
117
- const goalSlug = slugify(goal);
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, ['memos_canvas(action="update", name="...", summary="<step>", status="doing")']);
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 [{ type: "text", text: renderCanvasView(name, entry?.path ?? name, canvas) }];
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 ?? "").trim().toLowerCase();
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
  /**
@@ -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: {
@@ -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.string().optional()
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.string().optional()
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", "DECISION", "MILESTONE", "BUGFIX",
35
- "FEATURE", "CONFIG", "CODE_PATTERN", "GOTCHA", "PROGRESS", "SYNTHESIS",
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.string().describe("Search query. Can be natural language or prefixed with memory type (e.g., 'ERROR_PATTERN ModuleNotFoundError')"),
55
- context: z.array(z.object({
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
- })).optional().describe("Recent conversation turns (last 5-10); triggers context-aware search"),
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.number().int().optional().default(10).describe("Max results (default 10)"),
62
- compact: z.boolean().optional().default(true).describe("Compact large results (default true)"),
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.number().int().optional().default(15).describe("Evidence pool size (3-30)"),
73
- fresh_days: z.number().int().optional().default(30).describe("Days before time-sensitive memories count as stale"),
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.string().optional().describe("Target directory (default: <project_path>/docs/memory-wiki)"),
83
- include_archived: z.boolean().optional().default(false).describe("Also export archived memories"),
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.string().optional().describe("Wiki directory (default: <project_path>/docs/memory-wiki)"),
93
- dry_run: z.boolean().optional().default(false).describe("Preview the diff without writing (default false)"),
94
- on_edit: z.enum(["skip", "version"]).optional().default("skip").describe("Edited pages: skip (default) or save as new version (old memory kept)"),
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.string().describe("Memory content. Be detailed — include context, rationale, relevant code/commands."),
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.number().int().optional().default(20).describe("Max memories returned"),
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.boolean().optional().default(true).describe("Compact large results"),
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.string().describe("Current context (error message, code, user question)"),
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.array(z.string()).min(1).describe("Evidence memory IDs supporting this workflow"),
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({ project_path: projectPathBrief, candidate_id: z.string() }),
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.enum(["related", "path", "impact", "schema", "import"]).describe("Query kind"),
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.number().int().optional().default(3).describe("path/impact: max hops"),
172
- sample_size: z.number().int().optional().default(100).describe("schema: nodes sampled"),
173
- graph_json: z.string().optional().describe("import: Graphify graph.json contents; validation is dry-run and never writes data"),
174
- project_key: z.string().optional().describe("import: stable project namespace label"),
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.enum(["list_cubes", "register_cube", "create_user", "validate_cubes", "stats", "calendar", "capabilities"]).describe("Operation"),
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.string().optional().describe("register_cube: cube directory (auto-detected if omitted)"),
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.boolean().optional().default(true).describe("validate_cubes: auto-fix"),
190
- include_status: z.boolean().optional().default(false).describe("list_cubes: check registration"),
191
- mode: z.enum(["project", "student"]).optional().describe("calendar: view mode"),
192
- semester: z.string().optional().describe("calendar student: 'YYYY-Season' or 'current'"),
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.enum(["list", "week", "month"]).optional().describe("calendar student: format"),
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.string().optional().describe("update/show: canvas name (from list)"),
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.string().optional().describe("update: edit this node, else append"),
209
- summary: z.string().optional().describe("update: step text (required to append)"),
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.string().optional().describe("ID of the specific memory to delete. Get this from memos_search or memos_list."),
234
- memory_ids: z.array(z.string()).optional().describe("List of memory IDs to delete in batch."),
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.boolean().optional().default(false).describe("Set to true to delete ALL memories in the cube. DANGEROUS! Requires explicit user confirmation."),
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-memos-mcp",
3
- "version": "3.1.5",
3
+ "version": "3.1.7",
4
4
  "description": "MCP Server for oh-memos — Intelligent Memory Management (Node.js, no Python required)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",