oh-memos-mcp 3.1.6 → 3.1.8
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 +117 -2
- package/dist/canvas-format.d.ts +24 -1
- package/dist/canvas-format.js +30 -2
- package/dist/handlers/canvas.js +4 -5
- package/dist/handlers/memory.js +7 -1
- package/dist/handlers/utils.js +11 -2
- package/dist/handlers/wiki-import.js +44 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,121 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [3.1.8] - 2026-08-27
|
|
10
|
+
|
|
11
|
+
仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
|
|
12
|
+
|
|
13
|
+
### 🏷️ `memos_get` 报 root 无权限,而 root 从未被传入
|
|
14
|
+
|
|
15
|
+
`memos_get` 打自己项目的 cube 时回:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
❌ [API_ERROR] Get failed: User 'root' does not have access to cube
|
|
19
|
+
'jincaizhaopin_cube'. Please register the cube first or request access.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
但 `MEMOS_USER=dev_user`,root 不在任何调用参数里。
|
|
23
|
+
|
|
24
|
+
root 来自后端的默认回退:`start_api.py` 的 `get_memory(mem_cube_id, memory_id,
|
|
25
|
+
user_id=None)` 把 `user_id` 声明为可选,缺失时 `core.py` 的 `get()` 回退到
|
|
26
|
+
`self.user_id`(MOS 实例自己的用户,即 root)。而 `ensureCubeRegistered` 注册的是
|
|
27
|
+
`MEMOS_USER`,于是 root 对该 cube 无授权,`_validate_cube_access` 抛 ValueError,
|
|
28
|
+
被 ValueError handler 映射成 400。
|
|
29
|
+
|
|
30
|
+
**`handleMemosGet` 是唯一漏传 `user_id` 的调用点** —— save / list / search /
|
|
31
|
+
stats / calendar / siblings 全都传了,admin 的 delete 路径也拼了查询串。
|
|
32
|
+
|
|
33
|
+
`apiCallWithRetry` 的 400 分支救不回来:它把 400 当「cube 未加载」,重注册后重试
|
|
34
|
+
—— 但重注册仍按 `MEMOS_USER` 注册,重试请求仍然不带 `user_id`,第二次照样落到 root。
|
|
35
|
+
|
|
36
|
+
实测对照(同一个不存在的 id,`jincaizhaopin_cube`):
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
不带 user_id -> User 'root' does not have access to cube 'jincaizhaopin_cube'
|
|
40
|
+
带 user_id=dev_user -> Memory with ID ... not found ← 正常语义
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
为什么以前没人踩到:cube 的**归属**决定是否触发。root 名下的 cube(`dev_cube`、
|
|
44
|
+
`claude_cube`、`memos_cube` 等)不受影响;只有 owner 是 `MEMOS_USER` 的项目 cube
|
|
45
|
+
才会报错 —— 也就是每一个用 `project_path` 自动派生出来的 cube。
|
|
46
|
+
|
|
47
|
+
同源缺陷也修了 `wiki-import.ts` 的 `getStoredMemory`。那里更隐蔽:该消息既不含
|
|
48
|
+
`not found` 也不是 404,会被当成真实故障,把整页 import 判成失败而不是「记忆不存在」。
|
|
49
|
+
|
|
50
|
+
`apiErrorResponse` 另加了对 `does not have access` 的识别。该消息同时含 `user` 和
|
|
51
|
+
cube 字样,却不匹配任何既有分支,此前只能给通用的「查 health / 看日志」。新提示先
|
|
52
|
+
指向 `user_id`,因为最常见的成因是调用方漏传它,而不是用户或 cube 不存在。
|
|
53
|
+
|
|
54
|
+
回归测试 `get-user-id-param.test.ts` 的 fetch stub **按 `user_id` 是否存在分流**
|
|
55
|
+
应答,复刻上面的实测对照。无条件回 not-found 的 stub 守不住这个缺陷 —— 变异验证
|
|
56
|
+
(把 params 改回 `{}`)时三条断言必须全红,写测试时实际拿它验过。
|
|
57
|
+
|
|
58
|
+
## [3.1.7] - 2026-08-27
|
|
59
|
+
|
|
60
|
+
仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
|
|
61
|
+
|
|
62
|
+
### 🏷️ `open` 报出的画布名,`list` 找不到
|
|
63
|
+
|
|
64
|
+
goal 足够长时,`actionOpen` 报出的名字比磁盘上真实存在的多一个字符:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
open reports: 000-verify-3-1-6-canvas-delete-and-ref-escaping-over-live-mcp (61)
|
|
68
|
+
list shows : 000-verify-3-1-6-canvas-delete-and-ref-escaping-over-live-mc (60)
|
|
69
|
+
on disk : ...-live-mc.mmd
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`actionOpen` 先 `slugify(goal)` 拿到最多 `SLUG_MAX`(60)字符,再拼 `${prefix}-`,
|
|
73
|
+
总长可达 64。`saveCanvas` → `canvasPath` 在落盘路上又 slugify 一次,砍回 60。
|
|
74
|
+
**前缀那 4 个字符的预算从来没被算进去。**
|
|
75
|
+
|
|
76
|
+
`canvasPath` 里那处「slug 与原名不同就拒绝」的检查本该拦住它,但它只在名字
|
|
77
|
+
「像路径」(含 `/`、`\` 或 `..`)时抛错 —— 截断不像路径,于是静默放过。
|
|
78
|
+
|
|
79
|
+
影响是**报错的句柄,不是损坏的数据**:前缀仍让每个画布互不相同,
|
|
80
|
+
两个前 60 字符相同的长 goal 得到 `000-`/`001-` 两个独立文件,各自节点都在;
|
|
81
|
+
`update`/`delete` 传 `open` 报的长名字也照样能用,因为 `canvasPath` 的截断是幂等的。
|
|
82
|
+
坏的只有一件事:调用方拿到的名字,`list` 永远不会显示,磁盘上也不存在。
|
|
83
|
+
|
|
84
|
+
修法是把名字组装收进 `canvas-format.ts` 的新函数 `canvasName(prefix, goal)` ——
|
|
85
|
+
让它紧挨着自己必须遵守的那个上限,而不是让 handler 去做预算算术:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
export function canvasName(prefix: string, goal: string): string {
|
|
89
|
+
const head = `${prefix}-`;
|
|
90
|
+
const slug = slugify(goal, SLUG_MAX - head.length);
|
|
91
|
+
return slug ? `${head}${slug}` : `${head}task`;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`slugify` 因此多一个可选的 `maxLength`,**向内钳制到 `SLUG_MAX`** ——
|
|
96
|
+
调用方能收紧上限,不能放宽它,否则「每个画布文件名都装得进 `SLUG_MAX`」
|
|
97
|
+
这个不变量就成了各调用点的自选项。截断后仍走一次尾部连字符清理,
|
|
98
|
+
所以 `000-abc-.mmd` 这种名字不会出现。
|
|
99
|
+
|
|
100
|
+
### 🧪 测试
|
|
101
|
+
|
|
102
|
+
canvas-format 单测 41 → 50,canvas e2e 34 → 40 项。
|
|
103
|
+
|
|
104
|
+
核心断言是**不动点**而不是长度:`slugify(canvasName(p, g)) === canvasName(p, g)`,
|
|
105
|
+
跨 3 个前缀 × 6 种 goal(超长、纯 CJK、尾部连字符诱饵、前 60 字符相同的两个变体)。
|
|
106
|
+
长度只是症状 —— `canvasPath` 会再 slugify 一次,任何不是不动点的名字都是
|
|
107
|
+
「告诉了调用方、但文件系统从没见过」的名字。
|
|
108
|
+
|
|
109
|
+
e2e 补的 5 项跨 open/list/show/delete 与磁盘:`open` 报的名字必须等于
|
|
110
|
+
`list` 显示的名字、等于磁盘上存在的文件。反向验证还原实现后 3 项变红
|
|
111
|
+
(长度 64、磁盘上无此名、`list` 显示另一个名字)。
|
|
112
|
+
|
|
113
|
+
另两项(`reported name still routes to its canvas` / `... to delete`)
|
|
114
|
+
在修复前后都通过 —— `canvasPath` 截断幂等使然。**保留但改名如实**:
|
|
115
|
+
它们守的是「如果有人改用『让 `canvasPath` 对有损名字抛错』来修这个缺陷,
|
|
116
|
+
这条回归会被抓到」,不是这次的不变量。
|
|
117
|
+
|
|
118
|
+
门禁:vitest 520 passed、tsc、canvas e2e 40 项、pack 契约 104 文件、
|
|
119
|
+
schema budget +0.8%、semantic 快照 17 工具、protocol legacy + v2、
|
|
120
|
+
lite smoke、host-env smoke、spread smoke。
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
9
124
|
## [3.1.6] - 2026-08-26
|
|
10
125
|
|
|
11
126
|
仅 MCP server(npm `oh-memos-mcp`)。Python 包与容器镜像无改动,仍为 3.1.5。
|
|
@@ -71,7 +186,7 @@ commit message 或记忆正文引用的 `001-N3` 会变得指向两个不同画
|
|
|
71
186
|
|
|
72
187
|
### 🧪 测试
|
|
73
188
|
|
|
74
|
-
canvas 单测 41 → 45(format)、28 → 44(store),canvas e2e
|
|
189
|
+
canvas 单测 41 → 45(format)、28 → 44(store),canvas e2e 18 → 34 项。
|
|
75
190
|
两个修复都做了反向验证,还原实现后对应用例确实变红:转义 3 项、水位线 1 项(含 delete 侧那次抬升)。
|
|
76
191
|
|
|
77
192
|
e2e 补了「旧 cube(无水位线)删除后不复用前缀」这条真实升级路径 ——
|
|
@@ -84,7 +199,7 @@ e2e 补了「旧 cube(无水位线)删除后不复用前缀」这条真实
|
|
|
84
199
|
两处冻结的哈希同步重算:`schema-semantic-baseline.json` 与
|
|
85
200
|
`protocol-contract.test.ts` 的 `EXPECTED_SCHEMA_HASHES`。
|
|
86
201
|
|
|
87
|
-
门禁:vitest 511 passed、tsc、canvas e2e
|
|
202
|
+
门禁:vitest 511 passed、tsc、canvas e2e 34 项、pack 契约 104 文件、
|
|
88
203
|
schema budget +0.8%(限 5%)、semantic 快照 17 工具、protocol v2、lite smoke。
|
|
89
204
|
|
|
90
205
|
---
|
package/dist/canvas-format.d.ts
CHANGED
|
@@ -45,8 +45,31 @@ export declare const SUMMARY_MAX = 120;
|
|
|
45
45
|
*
|
|
46
46
|
* Returns "" when nothing usable remains (a purely CJK title, for instance);
|
|
47
47
|
* callers must have a fallback and must not write a file named "".
|
|
48
|
+
*
|
|
49
|
+
* `maxLength` narrows the cap for a caller that must leave room for a prefix.
|
|
50
|
+
* It is clamped to `SLUG_MAX`, so no caller can widen the invariant that every
|
|
51
|
+
* canvas filename fits in `SLUG_MAX` characters.
|
|
52
|
+
*/
|
|
53
|
+
export declare function slugify(input: string, maxLength?: number): string;
|
|
54
|
+
/**
|
|
55
|
+
* Compose a canvas filename from its prefix and the task goal.
|
|
56
|
+
*
|
|
57
|
+
* The prefix has to be part of the length budget, and it was not: `actionOpen`
|
|
58
|
+
* used to slug the goal to `SLUG_MAX` and then prepend `${prefix}-`, producing a
|
|
59
|
+
* name up to `SLUG_MAX + 4` characters. `canvasPath` slugs again on the way to
|
|
60
|
+
* disk and cut it back to `SLUG_MAX`, so the name reported by `open` was not the
|
|
61
|
+
* name `list` would show, and not the file that existed. Nothing was overwritten
|
|
62
|
+
* — the numeric prefix still made every canvas distinct — but the handle handed
|
|
63
|
+
* back to the caller was wrong.
|
|
64
|
+
*
|
|
65
|
+
* So the composition lives here, next to the cap it has to respect: slug the
|
|
66
|
+
* goal into what is left after the prefix, and the result is already a fixed
|
|
67
|
+
* point of `slugify`.
|
|
68
|
+
*
|
|
69
|
+
* A goal with no ASCII (a purely CJK title) slugs to "", so fall back to
|
|
70
|
+
* `<prefix>-task` rather than writing a file named after the prefix alone.
|
|
48
71
|
*/
|
|
49
|
-
export declare function
|
|
72
|
+
export declare function canvasName(prefix: string, goal: string): string;
|
|
50
73
|
/**
|
|
51
74
|
* Make text safe to sit inside a Mermaid `["..."]` label.
|
|
52
75
|
*
|
package/dist/canvas-format.js
CHANGED
|
@@ -36,12 +36,40 @@ const SLUG_MAX = 60;
|
|
|
36
36
|
*
|
|
37
37
|
* Returns "" when nothing usable remains (a purely CJK title, for instance);
|
|
38
38
|
* callers must have a fallback and must not write a file named "".
|
|
39
|
+
*
|
|
40
|
+
* `maxLength` narrows the cap for a caller that must leave room for a prefix.
|
|
41
|
+
* It is clamped to `SLUG_MAX`, so no caller can widen the invariant that every
|
|
42
|
+
* canvas filename fits in `SLUG_MAX` characters.
|
|
39
43
|
*/
|
|
40
|
-
export function slugify(input) {
|
|
44
|
+
export function slugify(input, maxLength = SLUG_MAX) {
|
|
45
|
+
const cap = Math.max(0, Math.min(maxLength, SLUG_MAX));
|
|
41
46
|
const lowered = String(input ?? "").toLowerCase();
|
|
42
47
|
const kept = lowered.replace(/[^a-z0-9]+/g, "-");
|
|
43
48
|
const collapsed = kept.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
|
44
|
-
return collapsed.slice(0,
|
|
49
|
+
return collapsed.slice(0, cap).replace(/-+$/g, "");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Compose a canvas filename from its prefix and the task goal.
|
|
53
|
+
*
|
|
54
|
+
* The prefix has to be part of the length budget, and it was not: `actionOpen`
|
|
55
|
+
* used to slug the goal to `SLUG_MAX` and then prepend `${prefix}-`, producing a
|
|
56
|
+
* name up to `SLUG_MAX + 4` characters. `canvasPath` slugs again on the way to
|
|
57
|
+
* disk and cut it back to `SLUG_MAX`, so the name reported by `open` was not the
|
|
58
|
+
* name `list` would show, and not the file that existed. Nothing was overwritten
|
|
59
|
+
* — the numeric prefix still made every canvas distinct — but the handle handed
|
|
60
|
+
* back to the caller was wrong.
|
|
61
|
+
*
|
|
62
|
+
* So the composition lives here, next to the cap it has to respect: slug the
|
|
63
|
+
* goal into what is left after the prefix, and the result is already a fixed
|
|
64
|
+
* point of `slugify`.
|
|
65
|
+
*
|
|
66
|
+
* A goal with no ASCII (a purely CJK title) slugs to "", so fall back to
|
|
67
|
+
* `<prefix>-task` rather than writing a file named after the prefix alone.
|
|
68
|
+
*/
|
|
69
|
+
export function canvasName(prefix, goal) {
|
|
70
|
+
const head = `${prefix}-`;
|
|
71
|
+
const slug = slugify(goal, SLUG_MAX - head.length);
|
|
72
|
+
return slug ? `${head}${slug}` : `${head}task`;
|
|
45
73
|
}
|
|
46
74
|
// ============================================================================
|
|
47
75
|
// escapeLabel
|
package/dist/handlers/canvas.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
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,
|
|
30
|
+
import { NODE_STATUSES, allocateNodeId, canvasName, countByStatus, formatCanvasHeadline, truncateSummary, } from "../canvas-format.js";
|
|
31
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(", ");
|
|
@@ -118,10 +118,9 @@ function actionOpen(cubeId, args) {
|
|
|
118
118
|
if (prefix === null) {
|
|
119
119
|
return errorResponse(`Cube '${cubeId}' already holds 1000 canvases (000-999)`, ERR_OPERATION_FAILED, ["Remove finished canvas files from the cube's canvas/ directory"]);
|
|
120
120
|
}
|
|
121
|
-
// The
|
|
122
|
-
//
|
|
123
|
-
const
|
|
124
|
-
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);
|
|
125
124
|
const now = new Date().toISOString();
|
|
126
125
|
const canvas = {
|
|
127
126
|
prefix,
|
package/dist/handlers/memory.js
CHANGED
|
@@ -286,7 +286,13 @@ export async function handleMemosGet(arguments_) {
|
|
|
286
286
|
const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
|
|
287
287
|
if (!regSuccess)
|
|
288
288
|
return cubeRegistrationError(cubeId, regError);
|
|
289
|
-
const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId,
|
|
289
|
+
const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId,
|
|
290
|
+
// user_id 必须显式传:后端 `get_memory` 的 user_id 是可选参数,缺失时回退到
|
|
291
|
+
// MOS 实例自己的 user_id(root)。而 ensureCubeRegistered 注册的是 MEMOS_USER
|
|
292
|
+
// (dev_user),于是 root 对该 cube 无授权,校验抛 ValueError → 400
|
|
293
|
+
// "User 'root' does not have access to cube '...'"。重注册重试也救不回来:
|
|
294
|
+
// 它仍然按 MEMOS_USER 注册,重试请求仍然不带 user_id,第二次照样落到 root。
|
|
295
|
+
{ params: { user_id: MEMOS_USER } }, ensureCubeRegistered);
|
|
290
296
|
if (result.success && result.data) {
|
|
291
297
|
const resultData = result.data
|
|
292
298
|
.data;
|
package/dist/handlers/utils.js
CHANGED
|
@@ -53,10 +53,19 @@ export function apiErrorResponse(operation, statusOrMsg) {
|
|
|
53
53
|
// Turn known API errors into actionable next steps on the hot paths
|
|
54
54
|
// (save/search/list hit these far more often than register_cube does).
|
|
55
55
|
const suggestions = [];
|
|
56
|
-
|
|
56
|
+
// 「does not have access to cube」要在 user-missing 分支之前判:它同时含 "user"
|
|
57
|
+
// 和 cube 字样,但成因完全不同 —— 用户和 cube 都存在,只是该用户没被授权。
|
|
58
|
+
// 实测最常见的触发方式是调用方漏传 user_id,后端回退到 root 去校验(root 往往
|
|
59
|
+
// 没有项目 cube 的授权),所以提示要先指向 user_id,而不是让人去建用户。
|
|
60
|
+
if (msg.includes("does not have access")) {
|
|
61
|
+
suggestions.push('Cube exists but this user is not authorized — most often the caller omitted `user_id` and the API fell back to its own default user. Verify `MEMOS_USER` matches the cube owner, then re-register: `memos_admin(action="register_cube", cube_id="...")`');
|
|
62
|
+
}
|
|
63
|
+
else if (msg.includes("user") &&
|
|
64
|
+
(msg.includes("not exist") || msg.includes("not found"))) {
|
|
57
65
|
suggestions.push('User missing — run `memos_admin(action="create_user", user_id="dev_user")`, then retry');
|
|
58
66
|
}
|
|
59
|
-
else if (msg.includes("not loaded") ||
|
|
67
|
+
else if (msg.includes("not loaded") ||
|
|
68
|
+
(msg.includes("cube") && msg.includes("not found"))) {
|
|
60
69
|
suggestions.push('Cube not registered — run `memos_admin(action="register_cube", cube_id="...")` (find ids via `memos_admin(action="list_cubes")`), then retry');
|
|
61
70
|
}
|
|
62
71
|
suggestions.push("Check API health: `curl http://localhost:18000/health/detail`", "Check API logs for details");
|
|
@@ -21,8 +21,8 @@ import { MEMOS_URL, MEMOS_USER, logger } from "../config.js";
|
|
|
21
21
|
import { apiCallWithRetry } from "../api-client.js";
|
|
22
22
|
import { ensureCubeRegistered } from "../cube-manager.js";
|
|
23
23
|
import { parseMemoryWriteResponse } from "../memory-write-response.js";
|
|
24
|
-
import { inspectWikiPages, normalizeLedger } from "../wiki-import-safety.js";
|
|
25
|
-
import { buildFileBaseIndex } from "../wiki-relations.js";
|
|
24
|
+
import { inspectWikiPages, normalizeLedger, } from "../wiki-import-safety.js";
|
|
25
|
+
import { buildFileBaseIndex, } from "../wiki-relations.js";
|
|
26
26
|
import { writeRelationsForPage } from "./wiki-import-relations.js";
|
|
27
27
|
import { buildMemoryContent, parseWikiPage, stripTypePrefix, } from "../wiki-import-format.js";
|
|
28
28
|
import { ERR_OPERATION_FAILED, ERR_PARAM_MISSING, cubeRegistrationError, errorResponse, getCubeIdFromArgs, } from "./utils.js";
|
|
@@ -55,7 +55,10 @@ function collectMarkdownFiles(pagesDir) {
|
|
|
55
55
|
return files;
|
|
56
56
|
}
|
|
57
57
|
function contentHash(page) {
|
|
58
|
-
return crypto
|
|
58
|
+
return crypto
|
|
59
|
+
.createHash("sha256")
|
|
60
|
+
.update(`${page.type}\n${page.content}`)
|
|
61
|
+
.digest("hex");
|
|
59
62
|
}
|
|
60
63
|
function loadLedger(wikiDir) {
|
|
61
64
|
const ledgerPath = path.join(wikiDir, LEDGER_FILE);
|
|
@@ -89,7 +92,9 @@ function saveLedger(wikiDir, ledger) {
|
|
|
89
92
|
try {
|
|
90
93
|
fs.unlinkSync(temp);
|
|
91
94
|
}
|
|
92
|
-
catch {
|
|
95
|
+
catch {
|
|
96
|
+
/* best effort */
|
|
97
|
+
}
|
|
93
98
|
return false;
|
|
94
99
|
}
|
|
95
100
|
}
|
|
@@ -109,7 +114,10 @@ function acquireImportLock(wikiDir) {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
const fd = fs.openSync(lockPath, "wx");
|
|
112
|
-
fs.writeFileSync(fd, JSON.stringify({
|
|
117
|
+
fs.writeFileSync(fd, JSON.stringify({
|
|
118
|
+
pid: process.pid,
|
|
119
|
+
started_at: new Date(now).toISOString(),
|
|
120
|
+
}));
|
|
113
121
|
fs.closeSync(fd);
|
|
114
122
|
return null;
|
|
115
123
|
}
|
|
@@ -121,13 +129,20 @@ function releaseImportLock(wikiDir) {
|
|
|
121
129
|
try {
|
|
122
130
|
fs.unlinkSync(path.join(wikiDir, LOCK_FILE));
|
|
123
131
|
}
|
|
124
|
-
catch {
|
|
132
|
+
catch {
|
|
133
|
+
/* another process may own it */
|
|
134
|
+
}
|
|
125
135
|
}
|
|
126
136
|
/** Returns the stored node, null when the memory does not exist, or an error string. */
|
|
127
137
|
async function getStoredMemory(cubeId, memoryId) {
|
|
128
|
-
const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId,
|
|
138
|
+
const result = await apiCallWithRetry("GET", `${MEMOS_URL}/memories/${cubeId}/${memoryId}`, cubeId,
|
|
139
|
+
// 同 handlers/memory.ts 的 handleMemosGet:缺 user_id 会让后端回退到 root,
|
|
140
|
+
// 对 MEMOS_USER 名下的 cube 报「does not have access」。这里更隐蔽 —— 该错误
|
|
141
|
+
// 既不含 "not found" 也不是 404,会被当成真实故障返回,把整页 import 判成失败。
|
|
142
|
+
{ params: { user_id: MEMOS_USER } }, ensureCubeRegistered);
|
|
129
143
|
if (result.success) {
|
|
130
|
-
return result.data
|
|
144
|
+
return (result.data
|
|
145
|
+
?.data ?? null);
|
|
131
146
|
}
|
|
132
147
|
const msg = String(result.data?.message ?? "");
|
|
133
148
|
if (msg.toLowerCase().includes("not found") || result.status === 404)
|
|
@@ -152,7 +167,8 @@ async function savePageAsMemory(cubeId, page) {
|
|
|
152
167
|
const result = await apiCallWithRetry("POST", `${MEMOS_URL}/memories`, cubeId, { body }, ensureCubeRegistered);
|
|
153
168
|
if (!result.success) {
|
|
154
169
|
return {
|
|
155
|
-
error: String(result.data?.message ??
|
|
170
|
+
error: String(result.data?.message ??
|
|
171
|
+
`HTTP ${result.status}`),
|
|
156
172
|
memoryIds: [],
|
|
157
173
|
warnings: [],
|
|
158
174
|
uncertain: false,
|
|
@@ -174,12 +190,12 @@ export async function handleMemosImportWiki(arguments_) {
|
|
|
174
190
|
const projectPath = arguments_.project_path;
|
|
175
191
|
const dryRun = arguments_.dry_run === true;
|
|
176
192
|
const onEdit = arguments_.on_edit === "version" ? "version" : "skip";
|
|
177
|
-
const wikiDir = arguments_.wiki_dir
|
|
178
|
-
|
|
193
|
+
const wikiDir = arguments_.wiki_dir ??
|
|
194
|
+
(projectPath ? path.join(projectPath, "docs", "memory-wiki") : undefined);
|
|
179
195
|
if (!wikiDir) {
|
|
180
196
|
return errorResponse("Need project_path (default wiki: <project_path>/docs/memory-wiki) or an explicit wiki_dir", ERR_PARAM_MISSING, [
|
|
181
197
|
'`memos_import_wiki(project_path="/mnt/g/my/project")`',
|
|
182
|
-
|
|
198
|
+
"Run `memos_export_wiki` first, edit pages, then import",
|
|
183
199
|
]);
|
|
184
200
|
}
|
|
185
201
|
const resolvedDir = path.resolve(wikiDir);
|
|
@@ -189,7 +205,9 @@ export async function handleMemosImportWiki(arguments_) {
|
|
|
189
205
|
return errorResponse(lockError, ERR_OPERATION_FAILED);
|
|
190
206
|
try {
|
|
191
207
|
if (!fs.existsSync(pagesDir)) {
|
|
192
|
-
return errorResponse(`No pages directory under '${resolvedDir}' — export a wiki first`, ERR_OPERATION_FAILED, [
|
|
208
|
+
return errorResponse(`No pages directory under '${resolvedDir}' — export a wiki first`, ERR_OPERATION_FAILED, [
|
|
209
|
+
`memos_export_wiki(project_path="...") writes <project_path>/docs/memory-wiki/pages/`,
|
|
210
|
+
]);
|
|
193
211
|
}
|
|
194
212
|
const [regSuccess, regError] = await ensureCubeRegistered(cubeId);
|
|
195
213
|
if (!regSuccess)
|
|
@@ -209,7 +227,10 @@ export async function handleMemosImportWiki(arguments_) {
|
|
|
209
227
|
}
|
|
210
228
|
const result = parseWikiPage(raw);
|
|
211
229
|
if (result.ok)
|
|
212
|
-
parsed.push({
|
|
230
|
+
parsed.push({
|
|
231
|
+
page: result.page,
|
|
232
|
+
relPath: path.relative(resolvedDir, file),
|
|
233
|
+
});
|
|
213
234
|
else if (result.foreign)
|
|
214
235
|
foreign += 1;
|
|
215
236
|
else
|
|
@@ -252,7 +273,9 @@ export async function handleMemosImportWiki(arguments_) {
|
|
|
252
273
|
archived += 1;
|
|
253
274
|
continue;
|
|
254
275
|
}
|
|
255
|
-
if (ledgerEntry &&
|
|
276
|
+
if (ledgerEntry &&
|
|
277
|
+
typeof ledgerEntry !== "string" &&
|
|
278
|
+
ledgerEntry.new_memory_ids.length > 0) {
|
|
256
279
|
// The old page has already produced a new version; compare the edited
|
|
257
280
|
// content hash before doing another API lookup/version.
|
|
258
281
|
const mappedHash = ledgerEntry.content_hash;
|
|
@@ -306,7 +329,9 @@ export async function handleMemosImportWiki(arguments_) {
|
|
|
306
329
|
unchanged += 1;
|
|
307
330
|
continue;
|
|
308
331
|
}
|
|
309
|
-
const ledgerHash = typeof ledgerEntry === "string"
|
|
332
|
+
const ledgerHash = typeof ledgerEntry === "string"
|
|
333
|
+
? ledgerEntry
|
|
334
|
+
: ledgerEntry?.content_hash;
|
|
310
335
|
if (ledgerHash === contentHash(page)) {
|
|
311
336
|
alreadyVersioned += 1;
|
|
312
337
|
continue;
|
|
@@ -370,7 +395,9 @@ export async function handleMemosImportWiki(arguments_) {
|
|
|
370
395
|
lines.push("", `❌ 失败明细(前 ${REPORT_LIST_LIMIT} 条):`, ...failures.slice(0, REPORT_LIST_LIMIT).map((s) => `- ${s}`));
|
|
371
396
|
}
|
|
372
397
|
if (apiWarnings.length > 0) {
|
|
373
|
-
lines.push("", "⚠️ API warnings:", ...apiWarnings
|
|
398
|
+
lines.push("", "⚠️ API warnings:", ...apiWarnings
|
|
399
|
+
.slice(0, REPORT_LIST_LIMIT)
|
|
400
|
+
.map((warning) => `- ${warning}`));
|
|
374
401
|
}
|
|
375
402
|
lines.push("", "ℹ️ 导入恢复 type/tags/confidence/status/时间字段;关联 wikilinks 写入 Neo4j 关系边;写入内容经过服务端凭据脱敏。");
|
|
376
403
|
return [{ type: "text", text: lines.join("\n") }];
|