remnote-bridge 0.1.6 → 0.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/dist/cli/handlers/edit-handler.js +89 -3
- package/dist/cli/handlers/read-handler.js +16 -0
- package/dist/cli/handlers/tree-edit-handler.js +59 -28
- package/dist/cli/handlers/tree-parser.js +110 -3
- package/dist/mcp/instructions.js +47 -9
- package/dist/mcp/resources/edit-rem-guide.js +53 -0
- package/dist/mcp/resources/edit-tree-guide.js +60 -0
- package/dist/mcp/resources/error-reference.js +8 -1
- package/dist/mcp/resources/outline-format.js +29 -1
- package/dist/mcp/resources/rem-object-fields.js +6 -4
- package/dist/mcp/resources/separator-flashcard.js +5 -5
- package/package.json +1 -1
- package/remnote-plugin/src/bridge/message-router.ts +11 -0
- package/remnote-plugin/src/services/add-to-portal.ts +40 -0
- package/remnote-plugin/src/services/create-portal.ts +47 -0
- package/remnote-plugin/src/services/remove-from-portal.ts +40 -0
- package/remnote-plugin/src/services/write-rem-fields.ts +39 -0
- package/remnote-plugin/src/types.ts +7 -4
- package/skills/remnote-bridge/SKILL.md +42 -4
- package/skills/remnote-bridge/instructions/connect.md +11 -3
- package/skills/remnote-bridge/instructions/edit-rem.md +67 -4
- package/skills/remnote-bridge/instructions/edit-tree.md +100 -10
- package/skills/remnote-bridge/instructions/overall.md +18 -3
- package/skills/remnote-bridge/instructions/read-rem.md +5 -2
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
* 防线 1:缓存存在性检查(必须先 read 再 edit)
|
|
8
8
|
* 防线 2:乐观并发检测(当前 JSON 与缓存 JSON 比较)
|
|
9
9
|
* 防线 3:str_replace 精确匹配(old_str 必须唯一匹配)
|
|
10
|
+
*
|
|
11
|
+
* Portal 专用路径:type === 'portal' 时,在简化 JSON(9 字段)上执行 str_replace,
|
|
12
|
+
* 推导变更后调用专用写入逻辑。
|
|
10
13
|
*/
|
|
14
|
+
import { PORTAL_FIELDS } from './read-handler.js';
|
|
11
15
|
/** 只读字段集合 — 变更这些字段只产生警告,不执行写入 */
|
|
12
16
|
const READ_ONLY_FIELDS = new Set([
|
|
13
17
|
'id',
|
|
14
18
|
'children',
|
|
15
19
|
'isTable',
|
|
16
|
-
'portalType',
|
|
20
|
+
'portalType',
|
|
17
21
|
'propertyType',
|
|
18
22
|
'aliases',
|
|
19
23
|
'remsBeingReferenced', 'deepRemsBeingReferenced', 'remsReferencingThis',
|
|
@@ -26,6 +30,10 @@ const READ_ONLY_FIELDS = new Set([
|
|
|
26
30
|
'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
|
|
27
31
|
'isPowerupPropertyListItem', 'isPowerupSlot',
|
|
28
32
|
]);
|
|
33
|
+
/** Portal 简化 JSON 中的只读字段 */
|
|
34
|
+
const PORTAL_READONLY_FIELDS = new Set([
|
|
35
|
+
'id', 'type', 'portalType', 'children', 'createdAt', 'updatedAt',
|
|
36
|
+
]);
|
|
29
37
|
export class EditHandler {
|
|
30
38
|
cache;
|
|
31
39
|
forwardToPlugin;
|
|
@@ -47,6 +55,86 @@ export class EditHandler {
|
|
|
47
55
|
// 不更新缓存 — 迫使 AI re-read
|
|
48
56
|
throw new Error(`Rem ${remId} has been modified since last read. Please read it again before editing.`);
|
|
49
57
|
}
|
|
58
|
+
// ── 检测 Portal 类型,分流到专用路径 ──
|
|
59
|
+
const cachedObj = JSON.parse(cachedJson);
|
|
60
|
+
if (cachedObj.type === 'portal') {
|
|
61
|
+
return this.handlePortalEdit(remId, cachedJson, cachedObj, oldStr, newStr);
|
|
62
|
+
}
|
|
63
|
+
// ── 普通 Rem 路径 ──
|
|
64
|
+
return this.handleNormalEdit(remId, cachedJson, cachedObj, oldStr, newStr);
|
|
65
|
+
}
|
|
66
|
+
/** Portal 专用编辑路径:在简化 JSON 上执行 str_replace */
|
|
67
|
+
async handlePortalEdit(remId, cachedJson, fullObj, oldStr, newStr) {
|
|
68
|
+
// 从完整对象提取简化 JSON
|
|
69
|
+
const simplified = {};
|
|
70
|
+
for (const field of PORTAL_FIELDS) {
|
|
71
|
+
if (field in fullObj) {
|
|
72
|
+
simplified[field] = fullObj[field];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const simplifiedJson = JSON.stringify(simplified, null, 2);
|
|
76
|
+
// ── 防线 3: str_replace 在简化 JSON 上精确匹配 ──
|
|
77
|
+
const matchCount = countOccurrences(simplifiedJson, oldStr);
|
|
78
|
+
if (matchCount === 0) {
|
|
79
|
+
throw new Error(`old_str not found in the simplified Portal JSON of rem ${remId}`);
|
|
80
|
+
}
|
|
81
|
+
if (matchCount > 1) {
|
|
82
|
+
throw new Error(`old_str matches ${matchCount} locations in Portal rem ${remId}. ` +
|
|
83
|
+
`Make old_str more specific to match exactly once.`);
|
|
84
|
+
}
|
|
85
|
+
const modifiedSimplifiedJson = simplifiedJson.replace(oldStr, newStr);
|
|
86
|
+
// JSON 解析
|
|
87
|
+
let modified;
|
|
88
|
+
try {
|
|
89
|
+
modified = JSON.parse(modifiedSimplifiedJson);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
|
|
93
|
+
}
|
|
94
|
+
// 推导变更字段
|
|
95
|
+
const changes = {};
|
|
96
|
+
const warnings = [];
|
|
97
|
+
for (const key of Object.keys(modified)) {
|
|
98
|
+
if (JSON.stringify(modified[key]) !== JSON.stringify(simplified[key])) {
|
|
99
|
+
if (PORTAL_READONLY_FIELDS.has(key)) {
|
|
100
|
+
warnings.push(`Field '${key}' is read-only and was ignored`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
changes[key] = modified[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 空变更检查
|
|
108
|
+
if (Object.keys(changes).length === 0) {
|
|
109
|
+
return { ok: true, changes: [], warnings };
|
|
110
|
+
}
|
|
111
|
+
// ── 发送变更到 Plugin ──
|
|
112
|
+
const writeResult = (await this.forwardToPlugin('write_rem_fields', {
|
|
113
|
+
remId,
|
|
114
|
+
changes,
|
|
115
|
+
}));
|
|
116
|
+
if (writeResult.failed) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
changes: [],
|
|
120
|
+
warnings,
|
|
121
|
+
error: `Failed to update field '${writeResult.failed.field}': ${writeResult.failed.error}`,
|
|
122
|
+
appliedChanges: writeResult.applied,
|
|
123
|
+
failedField: writeResult.failed.field,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存(D5)──
|
|
127
|
+
const freshRemObject = await this.forwardToPlugin('read_rem', { remId });
|
|
128
|
+
const freshJson = JSON.stringify(freshRemObject, null, 2);
|
|
129
|
+
this.cache.set('rem:' + remId, freshJson);
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
changes: Object.keys(changes),
|
|
133
|
+
warnings,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** 普通 Rem 编辑路径:在完整 JSON 上执行 str_replace */
|
|
137
|
+
async handleNormalEdit(remId, cachedJson, original, oldStr, newStr) {
|
|
50
138
|
// ── 防线 3: str_replace 精确匹配 ──
|
|
51
139
|
const matchCount = countOccurrences(cachedJson, oldStr);
|
|
52
140
|
if (matchCount === 0) {
|
|
@@ -57,7 +145,6 @@ export class EditHandler {
|
|
|
57
145
|
`Make old_str more specific to match exactly once.`);
|
|
58
146
|
}
|
|
59
147
|
const modifiedJson = cachedJson.replace(oldStr, newStr);
|
|
60
|
-
// ── 后处理校验 ──
|
|
61
148
|
// 1. JSON 解析
|
|
62
149
|
let modified;
|
|
63
150
|
try {
|
|
@@ -66,7 +153,6 @@ export class EditHandler {
|
|
|
66
153
|
catch {
|
|
67
154
|
throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
|
|
68
155
|
}
|
|
69
|
-
const original = JSON.parse(cachedJson);
|
|
70
156
|
// 2. 推导变更字段
|
|
71
157
|
const changes = {};
|
|
72
158
|
const warnings = [];
|
|
@@ -17,6 +17,12 @@ const RF_FIELDS = new Set([
|
|
|
17
17
|
'embeddedQueueViewMode',
|
|
18
18
|
'localUpdatedAt', 'lastPracticed',
|
|
19
19
|
]);
|
|
20
|
+
/** Portal 简化输出字段(type === 'portal' 时默认输出这 9 个字段) */
|
|
21
|
+
export const PORTAL_FIELDS = [
|
|
22
|
+
'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
|
|
23
|
+
'parent', 'positionAmongstSiblings', 'children',
|
|
24
|
+
'createdAt', 'updatedAt',
|
|
25
|
+
];
|
|
20
26
|
export class ReadHandler {
|
|
21
27
|
cache;
|
|
22
28
|
forwardToPlugin;
|
|
@@ -59,6 +65,16 @@ export class ReadHandler {
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
}
|
|
68
|
+
else if (remObject.type === 'portal') {
|
|
69
|
+
// Portal 简化模式:只输出 9 个关键字段
|
|
70
|
+
const obj = remObject;
|
|
71
|
+
result = {};
|
|
72
|
+
for (const field of PORTAL_FIELDS) {
|
|
73
|
+
if (field in obj) {
|
|
74
|
+
result[field] = obj[field];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
62
78
|
else {
|
|
63
79
|
// 默认模式:排除 R-F 字段
|
|
64
80
|
const obj = remObject;
|
|
@@ -90,37 +90,68 @@ export class TreeEditHandler {
|
|
|
90
90
|
}
|
|
91
91
|
parentId = actualId;
|
|
92
92
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (op.parentIsMultiline) {
|
|
112
|
-
changes.isCardItem = true;
|
|
113
|
-
if (!changes.practiceDirection)
|
|
114
|
-
changes.practiceDirection = 'none';
|
|
93
|
+
if (op.isPortal) {
|
|
94
|
+
// ── Portal 创建路径 ──
|
|
95
|
+
// 1. 创建空 Portal 并设置父节点
|
|
96
|
+
const portalResult = await this.forwardToPlugin('create_portal', {
|
|
97
|
+
parentId,
|
|
98
|
+
position: op.position,
|
|
99
|
+
});
|
|
100
|
+
// 2. 逐个添加引用
|
|
101
|
+
if (op.portalRefs?.length) {
|
|
102
|
+
for (const refId of op.portalRefs) {
|
|
103
|
+
await this.forwardToPlugin('add_to_portal', {
|
|
104
|
+
portalId: portalResult.remId,
|
|
105
|
+
remId: refId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 记录新创建的 remId,供后续嵌套引用
|
|
110
|
+
newRemIdMap.set(i, portalResult.remId);
|
|
115
111
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
else {
|
|
113
|
+
// ── 普通 Rem 创建路径 ──
|
|
114
|
+
// 解析 Markdown 前缀 + 箭头分隔符 → 属性
|
|
115
|
+
const { cleanContent, powerups, backText, practiceDirection } = parsePowerupPrefix(op.content);
|
|
116
|
+
const createResult = await this.forwardToPlugin('create_rem', {
|
|
117
|
+
content: cleanContent,
|
|
118
|
+
parentId,
|
|
119
|
+
position: op.position,
|
|
120
120
|
});
|
|
121
|
+
// 合并所有需要写入的属性(Powerup + 箭头分隔符推导的字段)
|
|
122
|
+
const changes = { ...powerups };
|
|
123
|
+
if (backText !== undefined)
|
|
124
|
+
changes.backText = backText;
|
|
125
|
+
if (practiceDirection !== undefined)
|
|
126
|
+
changes.practiceDirection = practiceDirection;
|
|
127
|
+
// 父节点为 multiline 时,子行标记 isCardItem
|
|
128
|
+
// ⚠ SDK bug: setIsCardItem(true) 会偷偷设 practiceDirection: "forward"
|
|
129
|
+
// 但 practiceDirection 应该只存在于父行(问题行),card-item(答案行)上不应该有。
|
|
130
|
+
// 如果 card-item 带着 practiceDirection: "forward" 且有子行,会被 RemNote 错误渲染成 multiline 卡片。
|
|
131
|
+
// 对策:setIsCardItem(true) 后立即用 practiceDirection: 'none' 覆盖掉副作用。
|
|
132
|
+
// 合并 HTML 注释中的元数据(type、doc、tag)
|
|
133
|
+
if (op.metadata) {
|
|
134
|
+
if (op.metadata.type)
|
|
135
|
+
changes.type = op.metadata.type;
|
|
136
|
+
if (op.metadata.isDocument)
|
|
137
|
+
changes.isDocument = op.metadata.isDocument;
|
|
138
|
+
if (op.metadata.tags)
|
|
139
|
+
changes.tags = op.metadata.tags;
|
|
140
|
+
}
|
|
141
|
+
if (op.parentIsMultiline) {
|
|
142
|
+
changes.isCardItem = true;
|
|
143
|
+
if (!changes.practiceDirection)
|
|
144
|
+
changes.practiceDirection = 'none';
|
|
145
|
+
}
|
|
146
|
+
if (Object.keys(changes).length > 0) {
|
|
147
|
+
await this.forwardToPlugin('write_rem_fields', {
|
|
148
|
+
remId: createResult.remId,
|
|
149
|
+
changes,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// 记录新创建的 remId,供后续嵌套引用
|
|
153
|
+
newRemIdMap.set(i, createResult.remId);
|
|
121
154
|
}
|
|
122
|
-
// 记录新创建的 remId,供后续嵌套引用
|
|
123
|
-
newRemIdMap.set(i, createResult.remId);
|
|
124
155
|
break;
|
|
125
156
|
}
|
|
126
157
|
case 'delete': {
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
const LINE_MARKER_RE = /<!--(\S+)((?:\s+\S+)*)-->$/;
|
|
15
15
|
/** 省略占位符正则 */
|
|
16
16
|
const ELIDED_LINE_RE = /^<!--\.\.\.elided\s/;
|
|
17
|
-
/**
|
|
17
|
+
/** 纯元数据注释正则(不含 remId,仅匹配 type:xxx、doc、tag:xxx) */
|
|
18
|
+
const METADATA_ONLY_RE = /<!--((?:type:\S+|doc|tag:\S+)(?:\s+(?:type:\S+|doc|tag:\S+))*)-->$/;
|
|
19
|
+
/** Portal 新增行正则:<!--portal refs:id1,id2--> 或 <!--portal--> */
|
|
20
|
+
const NEW_PORTAL_RE = /^<!--portal(?:\s+refs:(\S+))?-->$/;
|
|
21
|
+
/** 解析单行,提取缩进、内容、remId,以及 Portal 新增行标记 */
|
|
18
22
|
function parseLine(line) {
|
|
19
23
|
// 计算缩进(每 2 空格一级)
|
|
20
24
|
const stripped = line.replace(/^ */, '');
|
|
@@ -24,7 +28,21 @@ function parseLine(line) {
|
|
|
24
28
|
if (ELIDED_LINE_RE.test(stripped)) {
|
|
25
29
|
return { depth, rawContent: stripped, remId: null, metadata: '', isElided: true };
|
|
26
30
|
}
|
|
27
|
-
//
|
|
31
|
+
// 检测 Portal 新增行:<!--portal refs:id1,id2--> 或 <!--portal-->
|
|
32
|
+
const portalMatch = stripped.match(NEW_PORTAL_RE);
|
|
33
|
+
if (portalMatch) {
|
|
34
|
+
const refs = portalMatch[1] ? portalMatch[1].split(',') : [];
|
|
35
|
+
return { depth, rawContent: '', remId: null, metadata: '', isElided: false, isPortal: true, portalRefs: refs };
|
|
36
|
+
}
|
|
37
|
+
// 先检测纯元数据注释(不含 remId,新增行专用)
|
|
38
|
+
// 必须在 LINE_MARKER_RE 之前,否则 type:concept 等会被误认为 remId
|
|
39
|
+
const metaOnlyMatch = stripped.match(METADATA_ONLY_RE);
|
|
40
|
+
if (metaOnlyMatch) {
|
|
41
|
+
const metadata = metaOnlyMatch[1].trim();
|
|
42
|
+
const contentPart = stripped.slice(0, metaOnlyMatch.index).trimEnd();
|
|
43
|
+
return { depth, rawContent: contentPart, remId: null, metadata, isElided: false };
|
|
44
|
+
}
|
|
45
|
+
// 匹配行尾标记(含 remId)
|
|
28
46
|
const match = stripped.match(LINE_MARKER_RE);
|
|
29
47
|
if (match) {
|
|
30
48
|
const remId = match[1];
|
|
@@ -50,7 +68,7 @@ export function parseOutline(text) {
|
|
|
50
68
|
// stack[i] = depth 为 i 的最近节点
|
|
51
69
|
const stack = [];
|
|
52
70
|
for (const line of lines) {
|
|
53
|
-
const { depth, rawContent, remId, isElided } = parseLine(line);
|
|
71
|
+
const { depth, rawContent, remId, metadata, isElided, isPortal, portalRefs } = parseLine(line);
|
|
54
72
|
const node = {
|
|
55
73
|
remId,
|
|
56
74
|
depth,
|
|
@@ -58,6 +76,9 @@ export function parseOutline(text) {
|
|
|
58
76
|
rawLine: line,
|
|
59
77
|
children: [],
|
|
60
78
|
isElided,
|
|
79
|
+
metadata: metadata || undefined,
|
|
80
|
+
isPortal: isPortal || undefined,
|
|
81
|
+
portalRefs: portalRefs?.length ? portalRefs : undefined,
|
|
61
82
|
};
|
|
62
83
|
if (depth === 0) {
|
|
63
84
|
roots.push(node);
|
|
@@ -78,6 +99,33 @@ export function parseOutline(text) {
|
|
|
78
99
|
}
|
|
79
100
|
return roots;
|
|
80
101
|
}
|
|
102
|
+
// ────────────────────────── 元数据解析 ──────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* 解析 HTML 注释中的元数据字符串为结构化属性。
|
|
105
|
+
*
|
|
106
|
+
* 支持的标记:type:concept、type:descriptor、doc、tag:Name(id)
|
|
107
|
+
*/
|
|
108
|
+
export function parseMetadata(metadataStr) {
|
|
109
|
+
const result = {};
|
|
110
|
+
const tokens = metadataStr.split(/\s+/);
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
if (token === 'type:concept')
|
|
113
|
+
result.type = 'concept';
|
|
114
|
+
else if (token === 'type:descriptor')
|
|
115
|
+
result.type = 'descriptor';
|
|
116
|
+
else if (token === 'doc')
|
|
117
|
+
result.isDocument = true;
|
|
118
|
+
else if (token.startsWith('tag:')) {
|
|
119
|
+
const idMatch = token.match(/\(([^)]+)\)$/);
|
|
120
|
+
if (idMatch) {
|
|
121
|
+
if (!result.tags)
|
|
122
|
+
result.tags = [];
|
|
123
|
+
result.tags.push(idMatch[1]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
81
129
|
/**
|
|
82
130
|
* 从新增行的 rawContent 中解析 Markdown 前缀和箭头分隔符,提取属性。
|
|
83
131
|
*
|
|
@@ -249,6 +297,9 @@ function collectNewLines(roots) {
|
|
|
249
297
|
parentId,
|
|
250
298
|
position: childPos,
|
|
251
299
|
parentIsMultiline: isContentMultiline(node.rawContent),
|
|
300
|
+
metadata: child.metadata ? parseMetadata(child.metadata) : undefined,
|
|
301
|
+
isPortal: child.isPortal,
|
|
302
|
+
portalRefs: child.portalRefs,
|
|
252
303
|
});
|
|
253
304
|
// 新增行也可能有子节点(嵌套新增)
|
|
254
305
|
collectNewLinesUnderNew(child, result, myIndex);
|
|
@@ -278,6 +329,9 @@ function collectNewLinesUnderNew(parent, result, parentIndex) {
|
|
|
278
329
|
parentId: `__new_${parentIndex}__`,
|
|
279
330
|
position: childPos,
|
|
280
331
|
parentIsMultiline: isContentMultiline(parent.rawContent),
|
|
332
|
+
metadata: child.metadata ? parseMetadata(child.metadata) : undefined,
|
|
333
|
+
isPortal: child.isPortal,
|
|
334
|
+
portalRefs: child.portalRefs,
|
|
281
335
|
});
|
|
282
336
|
if (child.children.length > 0) {
|
|
283
337
|
collectNewLinesUnderNew(child, result, myIndex);
|
|
@@ -285,6 +339,38 @@ function collectNewLinesUnderNew(parent, result, parentIndex) {
|
|
|
285
339
|
childPos++;
|
|
286
340
|
}
|
|
287
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* 检测新增行(无 remId)是否劫持了已有节点的 children。
|
|
344
|
+
* 遍历新树,找到无 remId 的节点,检查其 children 中是否包含旧树中已存在的 remId。
|
|
345
|
+
*/
|
|
346
|
+
function findCapturedChildren(roots, oldMap) {
|
|
347
|
+
function walk(node) {
|
|
348
|
+
if (node.remId === null && !node.isElided && !node.isPortal) {
|
|
349
|
+
// 新增行:检查它的 children 中是否有已存在的 remId
|
|
350
|
+
const captured = [];
|
|
351
|
+
for (const child of node.children) {
|
|
352
|
+
if (child.remId && oldMap.has(child.remId)) {
|
|
353
|
+
captured.push(child.remId);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (captured.length > 0) {
|
|
357
|
+
return { newNodeContent: node.rawContent || '(empty)', capturedIds: captured };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const child of node.children) {
|
|
361
|
+
const result = walk(child);
|
|
362
|
+
if (result)
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
for (const root of roots) {
|
|
368
|
+
const result = walk(root);
|
|
369
|
+
if (result)
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
288
374
|
/** 递归收集所有省略占位符行的 rawContent */
|
|
289
375
|
function collectElidedLines(roots) {
|
|
290
376
|
const result = new Set();
|
|
@@ -335,6 +421,24 @@ export function diffTrees(oldRoots, newRoots) {
|
|
|
335
421
|
};
|
|
336
422
|
}
|
|
337
423
|
}
|
|
424
|
+
// ── 子节点劫持检测:新增行不应"抢走"已有节点的 children ──
|
|
425
|
+
// 典型场景:在有子节点的 Rem 和它的 children 之间插入同级新行,
|
|
426
|
+
// 导致 children 被解析为新行的子节点而非原父节点的子节点。
|
|
427
|
+
{
|
|
428
|
+
const captured = findCapturedChildren(newRoots, oldMap);
|
|
429
|
+
if (captured) {
|
|
430
|
+
return {
|
|
431
|
+
type: 'children_captured',
|
|
432
|
+
message: `New line "${captured.newNodeContent}" accidentally captured existing children (${captured.capturedIds.join(', ')}). ` +
|
|
433
|
+
`Insert the new line after the last child, not between a parent Rem and its children.`,
|
|
434
|
+
details: {
|
|
435
|
+
newNodeContent: captured.newNodeContent,
|
|
436
|
+
capturedIds: captured.capturedIds,
|
|
437
|
+
hint: 'Place the new line after all siblings of the target level, not right after a Rem that has children below it.',
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
338
442
|
// ── 内容变更检测 ──
|
|
339
443
|
const modifiedRems = [];
|
|
340
444
|
for (const [remId, newInfo] of newMap) {
|
|
@@ -400,6 +504,9 @@ export function diffTrees(oldRoots, newRoots) {
|
|
|
400
504
|
parentId: nl.parentId,
|
|
401
505
|
position: nl.position,
|
|
402
506
|
parentIsMultiline: nl.parentIsMultiline,
|
|
507
|
+
metadata: nl.metadata,
|
|
508
|
+
isPortal: nl.isPortal,
|
|
509
|
+
portalRefs: nl.portalRefs,
|
|
403
510
|
});
|
|
404
511
|
}
|
|
405
512
|
// ── D4 步骤 2: 移动(父节点变化) ──
|
package/dist/mcp/instructions.js
CHANGED
|
@@ -3,6 +3,8 @@ export const SERVER_INSTRUCTIONS = `
|
|
|
3
3
|
|
|
4
4
|
RemNote 知识库操作工具集。你可以通过这些工具读取、搜索、编辑用户的 RemNote 笔记和知识结构。不能操控闪卡(Card/Flashcard)本身——闪卡由 RemNote 根据 Rem 属性自动生成;也不能管理 Plugin 或系统配置。
|
|
5
5
|
|
|
6
|
+
> **架构备注**:本 MCP Server 是 \\\`remnote-bridge\\\` CLI 的包装层,每个工具调用在底层都会转化为一次 CLI 子进程调用(\\\`--json\\\` 模式)。因此工具的参数、返回值、错误信息与 CLI 完全一致。如果你在错误消息中看到"守护进程"、"daemon"等字样,指的就是 CLI 的后台守护进程。
|
|
7
|
+
|
|
6
8
|
---
|
|
7
9
|
|
|
8
10
|
## 1. Core Concepts
|
|
@@ -29,13 +31,13 @@ Rem 有两个独立维度:**type**(闪卡语义)和 **isDocument**(页
|
|
|
29
31
|
| \\\`default\\\` | 普通 Rem | 正常字重 |
|
|
30
32
|
| \\\`portal\\\` | 嵌入引用容器 | 紫色边框(**只读**,不可设置) |
|
|
31
33
|
|
|
32
|
-
###
|
|
34
|
+
### 闪卡的操作方式
|
|
33
35
|
|
|
34
|
-
闪卡由 Rem 的 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\`
|
|
36
|
+
闪卡由 Rem 的 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\` 三个字段控制。创建/修改闪卡,操作的是这些**字段**,而非分隔符。
|
|
35
37
|
|
|
36
|
-
**禁止**:在文本中插入分隔符(\\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等)来创建闪卡。分隔符是 RemNote
|
|
38
|
+
**禁止**:在文本中插入分隔符(\\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等)来创建闪卡。分隔符是 RemNote 编辑器的输入语法,工具端无法识别。
|
|
37
39
|
|
|
38
|
-
| 闪卡操作 |
|
|
40
|
+
| 闪卡操作 | 方法 |
|
|
39
41
|
|:---------|:---------|
|
|
40
42
|
| 创建概念定义 | \\\`edit_tree\\\` 新增行 \\\`概念 ↔ 定义\\\`,再 \\\`edit_rem\\\` 设 \\\`type: "concept"\\\` |
|
|
41
43
|
| 创建正向问答 | \\\`edit_tree\\\` 新增行 \\\`问题 → 答案\\\` |
|
|
@@ -44,7 +46,7 @@ Rem 有两个独立维度:**type**(闪卡语义)和 **isDocument**(页
|
|
|
44
46
|
|
|
45
47
|
### 理解用户意图:分隔符映射
|
|
46
48
|
|
|
47
|
-
用户在 RemNote
|
|
49
|
+
用户在 RemNote 编辑器中通过分隔符创建闪卡。当用户提到这些分隔符时,你需要理解其意图并映射到对应的工具操作:
|
|
48
50
|
|
|
49
51
|
| 用户说 / 编辑器分隔符 | 对应的 type | 对应的 practiceDirection |
|
|
50
52
|
|:----------------------|:-----------|:-----------------------|
|
|
@@ -63,6 +65,16 @@ Rem 有两个独立维度:**type**(闪卡语义)和 **isDocument**(页
|
|
|
63
65
|
| Tag \\\`##\\\` | 附加标签 | 分类标记 |
|
|
64
66
|
| Portal \\\`((\\\` | 嵌入实时视图 | 编辑同步,大纲中标为 \\\`type:portal\\\` |
|
|
65
67
|
|
|
68
|
+
### Portal 操作速查
|
|
69
|
+
|
|
70
|
+
| 操作 | 命令 | 方式 |
|
|
71
|
+
|:-----|:-----|:-----|
|
|
72
|
+
| 创建 Portal | \\\`edit_tree\\\` | 新增行 \\\`<!--portal refs:id1,id2-->\\\` |
|
|
73
|
+
| 删除 Portal | \\\`edit_tree\\\` | 从大纲中移除 Portal 行(与删除普通行相同) |
|
|
74
|
+
| 修改引用列表(增删引用的 Rem) | \\\`edit_rem\\\` | str_replace 简化 JSON 中的 \\\`portalDirectlyIncludedRem\\\` 数组 |
|
|
75
|
+
| 移动 Portal(换父节点/位置) | \\\`edit_tree\\\` | 与移动普通行相同 |
|
|
76
|
+
| 读取 Portal | \\\`read_rem\\\` | 自动输出 9 字段简化 JSON |
|
|
77
|
+
|
|
66
78
|
### Powerup 过滤
|
|
67
79
|
|
|
68
80
|
RemNote 的格式设置(标题、高亮、代码等)会注入隐藏的系统 Tag 和子 Rem。这些噪音数据**默认自动过滤**,你通常无需关心。
|
|
@@ -91,6 +103,12 @@ disconnect → 关闭 daemon,清空所有缓存
|
|
|
91
103
|
- \\\`disconnect\\\` 会销毁所有缓存,之前的 read 结果全部失效
|
|
92
104
|
- daemon 默认 30 分钟无活动自动关闭
|
|
93
105
|
|
|
106
|
+
### Windows 注意事项
|
|
107
|
+
|
|
108
|
+
- **首次 connect 较慢**:daemon 启动时会自动安装 remnote-plugin 的依赖(约 600+ 个包),在 Windows 上可能需要 30-60 秒,connect 超时设为 60 秒
|
|
109
|
+
- **依赖自动修复**:如果 webpack-dev-server 因依赖损坏而崩溃,daemon 会自动清洁重装依赖(删除 node_modules 后重新安装)并重试,最多重试 2 次
|
|
110
|
+
- **端口残留**:多次 connect 失败后可能出现端口被占用(EADDRINUSE),用 \\\`remnote-bridge disconnect\\\` 或手动终止占用端口的进程后重试
|
|
111
|
+
|
|
94
112
|
### ⚠️ connect 后需要用户配合(重要)
|
|
95
113
|
|
|
96
114
|
\\\`connect\\\` 成功只意味着 daemon 和 webpack-dev-server 已启动,**Plugin 并未自动连接**。用户必须在 RemNote 中完成以下操作,Plugin 才能连接到 daemon:
|
|
@@ -196,31 +214,50 @@ newStr: "text": [\\n "点击",\\n {\\n "i": "m",\\n "iUrl": "ht
|
|
|
196
214
|
|
|
197
215
|
1. \\\`read_tree\\\` 获取目标区域的 Markdown 大纲(建立缓存)
|
|
198
216
|
2. 在大纲中用 str_replace 进行结构修改:
|
|
199
|
-
- **新增**:插入无 remId
|
|
217
|
+
- **新增**:插入无 remId 注释的新行(通过缩进确定父子关系)。可在行尾加元数据注释指定属性:\\\`新行 <!--type:concept doc tag:Name(id)-->\\\`
|
|
200
218
|
- **删除**:移除带 remId 的行(必须同时删除所有子行)
|
|
201
219
|
- **移动**:改变行的缩进级别或位置
|
|
202
220
|
- **重排**:调换同级行的顺序
|
|
203
221
|
|
|
204
222
|
**红线**:edit_tree **禁止修改已有行的文字内容**——改内容必须用 edit_rem。edit_tree 只做结构操作。
|
|
205
223
|
|
|
224
|
+
**⚠️ 新增行的插入位置**:新行必须插在目标层级所有兄弟的**末尾**,不能插在一个有子节点的 Rem 和它的 children 之间。否则 children 会被新行"劫持",触发 \\\`children_captured\\\` 错误。
|
|
225
|
+
|
|
226
|
+
\\\`\\\`\\\`
|
|
227
|
+
❌ 错误:插在父 Rem 和 children 之间
|
|
228
|
+
oldStr: 水分子 ↓ <!--idA-->
|
|
229
|
+
newStr: 水分子 ↓ <!--idA-->
|
|
230
|
+
新行 ← children 会变成新行的子节点!
|
|
231
|
+
|
|
232
|
+
✅ 正确:插在末尾
|
|
233
|
+
oldStr: 最后一个兄弟 <!--idZ-->
|
|
234
|
+
newStr: 最后一个兄弟 <!--idZ-->
|
|
235
|
+
新行
|
|
236
|
+
\\\`\\\`\\\`
|
|
237
|
+
|
|
238
|
+
**需要"创建新节点并把已有 children 移过去"?** 分两步:
|
|
239
|
+
1. 第一次 \\\`edit_tree\\\`:在末尾创建新节点(获得 remId)
|
|
240
|
+
2. 第二次 \\\`edit_tree\\\`:把已有行移动到新节点下面(此时新节点已有 remId,走正常 move 逻辑)
|
|
241
|
+
|
|
206
242
|
### 场景 F:创建 / 修改闪卡
|
|
207
243
|
|
|
208
244
|
> 用户说:"创建一个概念定义"、"做个正向问答卡"、"把这个改成 concept"
|
|
209
245
|
|
|
210
246
|
闪卡由 \\\`type\\\`、\\\`backText\\\`、\\\`practiceDirection\\\` 三个字段控制。操作方式:
|
|
211
247
|
|
|
212
|
-
**创建新闪卡**(\\\`edit_tree\\\` 新增行 +
|
|
248
|
+
**创建新闪卡**(\\\`edit_tree\\\` 新增行 + 箭头 + 可选元数据注释):
|
|
213
249
|
- 正向问答:\\\`问题 → 答案\\\`
|
|
214
250
|
- 双向问答:\\\`问题 ↔ 答案\\\`
|
|
215
251
|
- 多行答案:\\\`问题 ↓\\\`(子行自动成为答案)
|
|
216
|
-
- 概念定义:\\\`概念 ↔
|
|
252
|
+
- 概念定义:\\\`概念 ↔ 定义 <!--type:concept-->\\\`(一步完成,无需再 edit_rem)
|
|
253
|
+
- 描述属性:\\\`属性 → 值 <!--type:descriptor-->\\\`
|
|
217
254
|
|
|
218
255
|
**修改现有 Rem 的闪卡行为**(\\\`read_rem\\\` → \\\`edit_rem\\\`):
|
|
219
256
|
- 改类型:修改 \\\`type\\\` 字段(\\\`"default"\\\` → \\\`"concept"\\\`)
|
|
220
257
|
- 改方向:修改 \\\`practiceDirection\\\`(\\\`"forward"\\\` / \\\`"backward"\\\` / \\\`"both"\\\` / \\\`"none"\\\`)
|
|
221
258
|
- 加/改背面:修改 \\\`backText\\\` 字段
|
|
222
259
|
|
|
223
|
-
**禁止**:在文本内容中插入 \\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等分隔符——这些是 RemNote
|
|
260
|
+
**禁止**:在文本内容中插入 \\\`::\\\`、\\\`;;\\\`、\\\`>>\\\` 等分隔符——这些是 RemNote 编辑器语法,工具端不识别。
|
|
224
261
|
|
|
225
262
|
### 场景 G:排查连接问题
|
|
226
263
|
|
|
@@ -317,6 +354,7 @@ newStr: "text": [\\n "点击",\\n {\\n "i": "m",\\n "iUrl": "ht
|
|
|
317
354
|
| Content modification not allowed | edit_tree 中修改了行内容 | 改用 \\\`edit_rem\\\` 修改内容 |
|
|
318
355
|
| orphan_detected | 删了父行但保留了子行 | 同时删除所有子行 |
|
|
319
356
|
| folded_delete | 删除有隐藏子节点的行 | 用更大 depth 重新 read_tree |
|
|
357
|
+
| children_captured | 新行插在父 Rem 和它的 children 之间,劫持了已有子节点 | 把新行插到所有兄弟的**末尾**而非紧跟父 Rem 之后(见下方说明) |
|
|
320
358
|
|
|
321
359
|
完整错误参考见 \\\`resource://error-reference\\\`。
|
|
322
360
|
`;
|
|
@@ -176,6 +176,59 @@ newStr: "practiceDirection": "both"
|
|
|
176
176
|
| 混淆 highlightColor 和 h | 前者字符串 \\\`"Red"\\\`,后者数字 \\\`1\\\` | 参考上方对比表 |
|
|
177
177
|
| 漏 onlyAudio | \\\`i:"a"\\\` 的 \\\`onlyAudio\\\` 是必填 | true=音频,false=视频 |
|
|
178
178
|
| JSON 语法错 | 引号、逗号、括号不完整 | 检查替换边界 |
|
|
179
|
+
| Portal oldStr 不匹配 | Portal 编辑在简化 JSON 上匹配,不是完整 JSON | 检查 oldStr 是否匹配 9 字段简化 JSON |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Portal 编辑
|
|
184
|
+
|
|
185
|
+
当被编辑的 Rem 是 Portal(type === 'portal')时,edit_rem 自动切换到 Portal 专用路径。
|
|
186
|
+
|
|
187
|
+
**edit_rem 只能修改 Portal 的引用列表和位置属性。创建 Portal 和删除 Portal 请使用 \\\`edit_tree\\\`。**
|
|
188
|
+
|
|
189
|
+
### 操作目标:简化 JSON
|
|
190
|
+
|
|
191
|
+
Portal 的 str_replace 在 **9 字段简化 JSON** 上执行(而非完整 51 字段):
|
|
192
|
+
|
|
193
|
+
\\\`\\\`\\\`json
|
|
194
|
+
{
|
|
195
|
+
"id": "abc123",
|
|
196
|
+
"type": "portal",
|
|
197
|
+
"portalType": "portal",
|
|
198
|
+
"portalDirectlyIncludedRem": ["remId1", "remId2"],
|
|
199
|
+
"parent": "parentId",
|
|
200
|
+
"positionAmongstSiblings": 3,
|
|
201
|
+
"children": ["remId1", "remId2"],
|
|
202
|
+
"createdAt": 1709000000000,
|
|
203
|
+
"updatedAt": 1709000000000
|
|
204
|
+
}
|
|
205
|
+
\\\`\\\`\\\`
|
|
206
|
+
|
|
207
|
+
### 可写字段
|
|
208
|
+
|
|
209
|
+
| 字段 | 写入方式 |
|
|
210
|
+
|:-----|:---------|
|
|
211
|
+
| \\\`portalDirectlyIncludedRem\\\` | diff 数组 → addToPortal / removeFromPortal |
|
|
212
|
+
| \\\`parent\\\` | setParent() |
|
|
213
|
+
| \\\`positionAmongstSiblings\\\` | setParent(parent, position) |
|
|
214
|
+
|
|
215
|
+
其余字段(id、type、portalType、children、createdAt、updatedAt)为只读,修改只产生警告。
|
|
216
|
+
|
|
217
|
+
### 示例
|
|
218
|
+
|
|
219
|
+
添加引用:
|
|
220
|
+
|
|
221
|
+
\\\`\\\`\\\`
|
|
222
|
+
oldStr: "portalDirectlyIncludedRem": ["remId1", "remId2"]
|
|
223
|
+
newStr: "portalDirectlyIncludedRem": ["remId1", "remId2", "remId3"]
|
|
224
|
+
\\\`\\\`\\\`
|
|
225
|
+
|
|
226
|
+
移除引用:
|
|
227
|
+
|
|
228
|
+
\\\`\\\`\\\`
|
|
229
|
+
oldStr: "portalDirectlyIncludedRem": ["remId1", "remId2"]
|
|
230
|
+
newStr: "portalDirectlyIncludedRem": ["remId1"]
|
|
231
|
+
\\\`\\\`\\\`
|
|
179
232
|
|
|
180
233
|
---
|
|
181
234
|
|