oh-memos-mcp 3.1.5 → 3.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,89 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [3.1.6] - 2026-08-26
10
+
11
+ 仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
12
+
13
+ ### 🔤 `memos_canvas` 的 ref 里,引号读不回来
14
+
15
+ `ref` 走 JSON 转义而非标签转义,因为 Windows 路径的反斜杠必须逐字返回,
16
+ 否则锚点失效。但 `renderRef` 在 `JSON.stringify` 之后又替换了一次裸引号:
17
+
18
+ ```js
19
+ JSON.stringify(ref).slice(1, -1).replace(/"/g, "\\u0022") // 旧
20
+ ```
21
+
22
+ `JSON.stringify` 已经把 `"` 写成 `\"`。那个 `replace` 只匹配裸引号,
23
+ **JSON 留下的反斜杠还在前面**,于是落盘成 `\\u0022`;`parseRef` 走 `JSON.parse`,
24
+ `\\` 解成一个字面反斜杠,取回 `"` 而不是 `"`。少匹配了一个反斜杠。
25
+
26
+ 现在匹配 `\"` 整体:`.replace(/\\"/g, "\\u0022")`。引号仍须离开标签 ——
27
+ `"` 会提前终止它所在的 Mermaid `["..."]` —— 但这次是**替换**已转义的序列,
28
+ 不是在它上面再叠一层。
29
+
30
+ 反斜杠一侧一直是对的:`\\` 就是 JSON 在磁盘上的正确表示,`parseRef` 会解回单反斜杠。
31
+ 只有引号这一条路径坏了。
32
+
33
+ ### 🗑️ `memos_canvas` 新增 `delete` 动作
34
+
35
+ 此前只有 open/update/show/list,清理必须手动删 `.mmd`。而画布的定位是**短期**状态,
36
+ 注定会产生废弃文件 —— 加之全局规则禁止手工改动记忆文件,等于没有合法的清理路径。
37
+
38
+ `action="delete"` 在有未完成节点(doing/todo/blocked)时**拒绝**并给出逃生口,
39
+ 全 done 或空画布直接删:
40
+
41
+ ```
42
+ ❌ Canvas '000-x' still has 1 unfinished node (0 doing · 0 todo · 1 blocked)
43
+ Review it first: memos_canvas(action="show", name="000-x")
44
+ Delete anyway: memos_canvas(action="delete", name="000-x", confirm=true)
45
+ ```
46
+
47
+ **没有套用 `MEMOS_ENABLE_DELETE`**。那道门是保护长期记忆的:走 API、有 embedding 成本、
48
+ 且 Lite 模式下整个禁用。画布是本地文件、不走 API,而 Lite 模式下它恰恰是少数可用功能之一 ——
49
+ 套上那道门等于在最需要清理的模式里砍掉清理能力。`confirm` 这种「拒绝并给出下一步」
50
+ 与 `MAX_NODES`、非法 status 是同一个既有模式。
51
+
52
+ 删除是硬删,不做回收站:画布存在的意义就是任务收尾后被丢弃,
53
+ 回收站只会攒下它本该清掉的文件。`deleteCanvas` 复用 `canvasPath`,遍历防护自动继承。
54
+
55
+ ### 🔢 前缀水位线:`delete` 破坏了「前缀永不复用」
56
+
57
+ 加上 delete 之后有两个 e2e 断言失败。**不是断言写错** —— `nextPrefix` 用现存文件算
58
+ `max+1`,在只增不减的世界里「前缀永不复用」自动成立,delete 打破了这个不变量:
59
+ 删掉最高前缀的画布后,那个前缀会被重新发放。
60
+
61
+ 后果正是 `nextPrefix` 自己注释里警告过的:前缀嵌在它铸出的每个节点 id 里,
62
+ commit message 或记忆正文引用的 `001-N3` 会变得指向两个不同画布的节点。
63
+
64
+ 修法是 `{cube}/canvas/.prefix-hwm` 水位线文件,`saveCanvas` 与 `deleteCanvas` 都抬升它,
65
+ 只增不减。delete 侧必须抬升:文件是该前缀唯一的记录,删掉后若水位线仍低于它,
66
+ 就会被重新发放。旧 cube 没有这个文件时(`readPrefixHwm` 返回 -1),
67
+ 从现存文件恢复下限,所以升级不会因为水位线缺失而复用前缀。
68
+
69
+ 水位线损坏或不可读时退回扫描文件,与 `parseCanvas` 一致 ——
70
+ 画布是恢复态,读取时机正是刚丢失上下文的那一刻,抛异常是最坏的选择。
71
+
72
+ ### 🧪 测试
73
+
74
+ canvas 单测 41 → 45(format)、28 → 44(store),canvas e2e 15 → 31 项。
75
+ 两个修复都做了反向验证,还原实现后对应用例确实变红:转义 3 项、水位线 1 项(含 delete 侧那次抬升)。
76
+
77
+ e2e 补了「旧 cube(无水位线)删除后不复用前缀」这条真实升级路径 ——
78
+ 它跨 store 与 handler,单测碰不到。
79
+
80
+ 顺带改掉一个自己写坏的用例:名字叫 "does not free the prefix it used",
81
+ 断言却在验证前缀被释放了。名字与断言矛盾的测试比没有测试更坏。
82
+
83
+ `memos_canvas` 的 schema 改了(action enum 加 `delete`,新增 `confirm`),
84
+ 两处冻结的哈希同步重算:`schema-semantic-baseline.json` 与
85
+ `protocol-contract.test.ts` 的 `EXPECTED_SCHEMA_HASHES`。
86
+
87
+ 门禁:vitest 511 passed、tsc、canvas e2e 31 项、pack 契约 104 文件、
88
+ schema budget +0.8%(限 5%)、semantic 快照 17 工具、protocol v2、lite smoke。
89
+
90
+ ---
91
+
9
92
  ## [3.1.5] - 2026-08-26
10
93
 
11
94
  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 |
@@ -123,9 +123,19 @@ export function allocateNodeId(canvas) {
123
123
  * Windows paths are the reason: `C:\Users\x` contains backslashes, which JSON
124
124
  * escapes and which must come back byte-identical or the anchor stops resolving.
125
125
  * Label escaping is for prose; refs get JSON, which is lossless.
126
+ *
127
+ * One transform sits on top of JSON: `JSON.stringify` renders a quote as `\"`,
128
+ * and that bare quote would still close the Mermaid label. Promoting the whole
129
+ * two-character escape to `\u0022` keeps the label intact while staying valid
130
+ * JSON, so `parseRef` decodes it back to `"`.
131
+ *
132
+ * The `\\` in the pattern is load-bearing. Replacing only the quote leaves
133
+ * JSON's backslash in front of the substitution, producing `\\u0022` — which
134
+ * JSON.parse reads as a literal backslash followed by `u0022`, so a ref
135
+ * containing a quote comes back as the text `\u0022` instead of the quote.
126
136
  */
127
137
  function renderRef(ref) {
128
- return JSON.stringify(ref).slice(1, -1).replace(/"/g, "\\u0022");
138
+ return JSON.stringify(ref).slice(1, -1).replace(/\\"/g, "\\u0022");
129
139
  }
130
140
  function parseRef(raw) {
131
141
  try {
@@ -142,10 +152,7 @@ export function renderCanvas(canvas) {
142
152
  updatedTime: canvas.updatedTime,
143
153
  prefix: canvas.prefix,
144
154
  };
145
- const lines = [
146
- `%%${JSON.stringify(meta)}%%`,
147
- "graph LR",
148
- ];
155
+ const lines = [`%%${JSON.stringify(meta)}%%`, "graph LR"];
149
156
  for (const node of canvas.nodes) {
150
157
  const parts = [
151
158
  `status: ${node.status}`,
@@ -192,9 +199,12 @@ export function parseCanvas(content) {
192
199
  try {
193
200
  const raw = metaMatch[0].slice(2, -2);
194
201
  const parsed = JSON.parse(raw);
195
- canvas.taskGoal = 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;
202
+ canvas.taskGoal =
203
+ typeof parsed.taskGoal === "string" ? parsed.taskGoal : "";
204
+ canvas.createdTime =
205
+ typeof parsed.createdTime === "string" ? parsed.createdTime : null;
206
+ canvas.updatedTime =
207
+ typeof parsed.updatedTime === "string" ? parsed.updatedTime : null;
198
208
  if (typeof parsed.prefix === "string" && /^\d{3}$/.test(parsed.prefix)) {
199
209
  canvas.prefix = parsed.prefix;
200
210
  prefixFromHeader = true;
@@ -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)
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { MEMOS_CUBES_DIR } from "../config.js";
30
30
  import { NODE_STATUSES, allocateNodeId, countByStatus, formatCanvasHeadline, slugify, truncateSummary, } from "../canvas-format.js";
31
- import { CanvasPathError, listCanvases, loadCanvas, nextPrefix, saveCanvas, } from "../canvas-store.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;
@@ -126,14 +132,16 @@ function actionOpen(cubeId, args) {
126
132
  };
127
133
  try {
128
134
  const path = saveCanvas(MEMOS_CUBES_DIR, cubeId, name, canvas);
129
- return [{
135
+ return [
136
+ {
130
137
  type: "text",
131
138
  text: [
132
139
  renderCanvasView(name, path, canvas),
133
140
  "",
134
141
  `Canvas \`${name}\` created. Node ids will be \`${prefix}-N1\`, \`${prefix}-N2\`, …`,
135
142
  ].join("\n"),
136
- }];
143
+ },
144
+ ];
137
145
  }
138
146
  catch (err) {
139
147
  return pathErrorResponse(err);
@@ -189,7 +197,9 @@ function actionUpdate(cubeId, args) {
189
197
  else {
190
198
  // Append a new node.
191
199
  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")']);
200
+ return errorResponse("`summary` is required when appending a node", ERR_PARAM_MISSING, [
201
+ 'memos_canvas(action="update", name="...", summary="<step>", status="doing")',
202
+ ]);
193
203
  }
194
204
  if (canvas.nodes.length >= MAX_NODES) {
195
205
  return errorResponse(`Canvas '${name}' already has ${MAX_NODES} nodes`, ERR_OPERATION_FAILED, [
@@ -212,7 +222,8 @@ function actionUpdate(cubeId, args) {
212
222
  canvas.updatedTime = new Date().toISOString();
213
223
  try {
214
224
  const path = saveCanvas(MEMOS_CUBES_DIR, cubeId, name, canvas);
215
- return [{
225
+ return [
226
+ {
216
227
  type: "text",
217
228
  text: [
218
229
  `${statusIcon(touched.status)} \`${touched.id}\` ${touched.summary}` +
@@ -220,7 +231,8 @@ function actionUpdate(cubeId, args) {
220
231
  "",
221
232
  renderCanvasView(name, path, canvas),
222
233
  ].join("\n"),
223
- }];
234
+ },
235
+ ];
224
236
  }
225
237
  catch (err) {
226
238
  return pathErrorResponse(err);
@@ -241,18 +253,84 @@ function actionShow(cubeId, args) {
241
253
  return pathErrorResponse(err);
242
254
  }
243
255
  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
- ]);
256
+ return errorResponse(`Canvas '${name}' not found in cube '${cubeId}'`, ERR_PARAM_INVALID, ['List canvases: `memos_canvas(action="list", project_path="<cwd>")`']);
247
257
  }
248
258
  // Recompute the path for display only; load already proved it resolves.
249
259
  const entry = listCanvases(MEMOS_CUBES_DIR, cubeId).find((e) => e.name === name);
250
- return [{ type: "text", text: renderCanvasView(name, entry?.path ?? name, canvas) }];
260
+ return [
261
+ { type: "text", text: renderCanvasView(name, entry?.path ?? name, canvas) },
262
+ ];
263
+ }
264
+ /**
265
+ * Delete a canvas file.
266
+ *
267
+ * A canvas with open work is refused unless `confirm=true`. The asymmetry is the
268
+ * point: a canvas whose nodes are all `done` is finished bookkeeping and deleting
269
+ * it needs no ceremony, while one still holding `doing`/`todo`/`blocked` nodes is
270
+ * the only record of where a task stopped — and that record is most valuable at
271
+ * exactly the moment the model has lost its context and might delete it by
272
+ * mistake. The refusal names what would be lost so the decision is informed.
273
+ *
274
+ * This is not gated behind MEMOS_ENABLE_DELETE. That gate protects long-term
275
+ * memories behind the HTTP provider; a canvas is a local file with an
276
+ * hours-long lifetime, and in Lite mode the canvas is one of the few things that
277
+ * works — gating it there would remove cleanup precisely where it is needed.
278
+ */
279
+ function actionDelete(cubeId, args) {
280
+ const name = String(args.name ?? "").trim();
281
+ if (!name) {
282
+ return errorResponse("`name` is required to delete a canvas", ERR_PARAM_MISSING, ['List canvases: `memos_canvas(action="list", project_path="<cwd>")`']);
283
+ }
284
+ let canvas;
285
+ try {
286
+ canvas = loadCanvas(MEMOS_CUBES_DIR, cubeId, name);
287
+ }
288
+ catch (err) {
289
+ return pathErrorResponse(err);
290
+ }
291
+ if (canvas === null) {
292
+ return errorResponse(`Canvas '${name}' not found in cube '${cubeId}'`, ERR_PARAM_INVALID, ['List canvases: `memos_canvas(action="list", project_path="<cwd>")`']);
293
+ }
294
+ const counts = countByStatus(canvas);
295
+ const open = counts.doing + counts.todo + counts.blocked;
296
+ if (open > 0 && args.confirm !== true) {
297
+ return errorResponse(`Canvas '${name}' still has ${open} unfinished node${open === 1 ? "" : "s"} ` +
298
+ `(${counts.doing} doing · ${counts.todo} todo · ${counts.blocked} blocked)`, ERR_PARAM_INVALID, [
299
+ `Review it first: \`memos_canvas(action="show", name="${name}")\``,
300
+ `Delete anyway: \`memos_canvas(action="delete", name="${name}", confirm=true)\``,
301
+ ]);
302
+ }
303
+ try {
304
+ const removed = deleteCanvas(MEMOS_CUBES_DIR, cubeId, name);
305
+ if (!removed) {
306
+ // loadCanvas found it a moment ago, so this is a concurrent delete rather
307
+ // than a caller mistake. Same end state either way.
308
+ return [{ type: "text", text: `Canvas \`${name}\` was already gone.` }];
309
+ }
310
+ const tally = `${counts.done} done · ${counts.doing} doing · ${counts.todo} todo` +
311
+ (counts.blocked > 0 ? ` · ${counts.blocked} blocked` : "");
312
+ return [
313
+ {
314
+ type: "text",
315
+ text: [
316
+ `🗑️ Deleted canvas \`${name}\` from \`${cubeId}\` (${canvas.nodes.length} node${canvas.nodes.length === 1 ? "" : "s"}: ${tally})`,
317
+ "",
318
+ `**Goal was**: ${canvas.taskGoal || "(none recorded)"}`,
319
+ "",
320
+ "The file is gone — `mem:` refs it cited still exist in the graph.",
321
+ ].join("\n"),
322
+ },
323
+ ];
324
+ }
325
+ catch (err) {
326
+ return pathErrorResponse(err);
327
+ }
251
328
  }
252
329
  function actionList(cubeId) {
253
330
  const entries = listCanvases(MEMOS_CUBES_DIR, cubeId);
254
331
  if (entries.length === 0) {
255
- return [{
332
+ return [
333
+ {
256
334
  type: "text",
257
335
  text: [
258
336
  `## 🗺️ Canvases: ${cubeId}`,
@@ -261,7 +339,8 @@ function actionList(cubeId) {
261
339
  "",
262
340
  'Open one: `memos_canvas(action="open", goal="<task>", project_path="<cwd>")`',
263
341
  ].join("\n"),
264
- }];
342
+ },
343
+ ];
265
344
  }
266
345
  // Unfinished first: after a compaction the open work is the only part that
267
346
  // still matters.
@@ -284,7 +363,9 @@ function actionList(cubeId) {
284
363
  // ============================================================================
285
364
  export async function handleMemosCanvas(arguments_) {
286
365
  const cubeId = getCubeIdFromArgs(arguments_);
287
- const action = String(arguments_.action ?? "").trim().toLowerCase();
366
+ const action = String(arguments_.action ?? "")
367
+ .trim()
368
+ .toLowerCase();
288
369
  switch (action) {
289
370
  case "open":
290
371
  return actionOpen(cubeId, arguments_);
@@ -294,8 +375,10 @@ export async function handleMemosCanvas(arguments_) {
294
375
  return actionShow(cubeId, arguments_);
295
376
  case "list":
296
377
  return actionList(cubeId);
378
+ case "delete":
379
+ return actionDelete(cubeId, arguments_);
297
380
  default:
298
- return errorResponse(`Unknown canvas action: ${String(arguments_.action ?? "(none)")}`, ERR_PARAM_INVALID, ["Valid actions: open, update, show, list"]);
381
+ return errorResponse(`Unknown canvas action: ${String(arguments_.action ?? "(none)")}`, ERR_PARAM_INVALID, ["Valid actions: open, update, show, list, delete"]);
299
382
  }
300
383
  }
301
384
  /**
@@ -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.6",
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",