remnote-bridge 0.1.13 → 0.1.15
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/README.md +147 -28
- package/README.zh-CN.md +374 -0
- package/dist/cli/commands/health.js +231 -112
- package/dist/cli/commands/read-rem-in-tree.js +84 -0
- package/dist/cli/config.js +2 -0
- package/dist/cli/daemon/registry.js +8 -0
- package/dist/cli/handlers/edit-handler.js +14 -0
- package/dist/cli/handlers/patch-engine.js +347 -0
- package/dist/cli/handlers/read-handler.js +2 -53
- package/dist/cli/handlers/rem-field-filter.js +102 -0
- package/dist/cli/handlers/tree-edit-handler.js +67 -7
- package/dist/cli/handlers/tree-read-handler.js +4 -1
- package/dist/cli/handlers/tree-rem-read-handler.js +73 -0
- package/dist/cli/main.js +53 -2
- package/dist/cli/server/ws-server.js +9 -1
- package/dist/mcp/daemon-client.js +22 -2
- package/dist/mcp/instructions.js +99 -58
- package/dist/mcp/tools/edit-tools.js +7 -2
- package/dist/mcp/tools/infra-tools.js +20 -11
- package/dist/mcp/tools/read-tools.js +88 -2
- package/package.json +1 -1
- package/remnote-plugin/dist/index-sandbox.js +24 -24
- package/remnote-plugin/dist/index.js +24 -24
- package/remnote-plugin/dist/manifest.json +1 -1
- package/remnote-plugin/package.json +1 -1
- package/remnote-plugin/public/manifest.json +1 -1
- package/remnote-plugin/src/bridge/message-router.ts +3 -0
- package/remnote-plugin/src/services/read-rem-in-tree.ts +43 -0
- package/remnote-plugin/src/services/read-rem.ts +31 -16
- package/remnote-plugin/src/services/read-tree.ts +5 -0
- package/remnote-plugin/src/settings.ts +1 -1
- package/skills/remnote-bridge/SKILL.md +50 -8
- package/skills/remnote-bridge/instructions/connect.md +31 -8
- package/skills/remnote-bridge/instructions/disconnect.md +5 -0
- package/skills/remnote-bridge/instructions/edit-tree.md +117 -51
- package/skills/remnote-bridge/instructions/health.md +81 -53
- package/skills/remnote-bridge/instructions/overall.md +39 -8
- package/skills/remnote-bridge/instructions/read-rem-in-tree.md +100 -0
- package/skills/remnote-bridge/instructions/read-rem.md +30 -11
- package/skills/remnote-bridge-test/SKILL.md +847 -0
- package/skills/remnote-bridge-test/references/regression-suite.md +960 -0
- package/skills/remnote-bridge-test/references/verification-guide.md +161 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patch-engine.ts -- patch 解析器 + 应用器
|
|
3
|
+
*
|
|
4
|
+
* CLI handlers 层的纯函数。解析自定义 patch DSL 文本为结构化 hunk 列表,
|
|
5
|
+
* 供后续应用器匹配到缓存大纲并生成新大纲。
|
|
6
|
+
*
|
|
7
|
+
* 不依赖 server / commands / daemon(强约束)。
|
|
8
|
+
*/
|
|
9
|
+
/** 解析错误 */
|
|
10
|
+
export class PatchParseError extends Error {
|
|
11
|
+
lineNumber;
|
|
12
|
+
rawLine;
|
|
13
|
+
constructor(message, lineNumber, rawLine) {
|
|
14
|
+
super(`Patch 解析错误 (行 ${lineNumber}): ${message}\n > ${rawLine}`);
|
|
15
|
+
this.lineNumber = lineNumber;
|
|
16
|
+
this.rawLine = rawLine;
|
|
17
|
+
this.name = 'PatchParseError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ────────────────────────── 应用器错误类型 ──────────────────────────
|
|
21
|
+
/** patch 应用错误(锚点不存在、缩进不匹配等) */
|
|
22
|
+
export class PatchApplyError extends Error {
|
|
23
|
+
lineNumber;
|
|
24
|
+
constructor(message, lineNumber) {
|
|
25
|
+
super(`Patch 应用错误 (行 ${lineNumber}): ${message}`);
|
|
26
|
+
this.lineNumber = lineNumber;
|
|
27
|
+
this.name = 'PatchApplyError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** hunk 间 ID 引用冲突错误 */
|
|
31
|
+
export class PatchConflictError extends Error {
|
|
32
|
+
conflictId;
|
|
33
|
+
constructor(message, conflictId) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.conflictId = conflictId;
|
|
36
|
+
this.name = 'PatchConflictError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ────────────────────────── 应用器内部函数 ──────────────────────────
|
|
40
|
+
/** 行尾标记正则(与 tree-parser.ts 的 LINE_MARKER_RE 一致) */
|
|
41
|
+
const OUTLINE_LINE_MARKER_RE = /<!--(\S+)(?:\s+\S+)*-->$/;
|
|
42
|
+
/**
|
|
43
|
+
* 预扫描缓存大纲,构建 Rem ID → 行索引的映射。
|
|
44
|
+
* 使用与 tree-parser.ts 一致的行尾标记正则提取 ID。
|
|
45
|
+
*/
|
|
46
|
+
function buildIdIndex(lines) {
|
|
47
|
+
const map = new Map();
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const trimmed = lines[i].replace(/^ */, '');
|
|
50
|
+
const match = trimmed.match(OUTLINE_LINE_MARKER_RE);
|
|
51
|
+
if (match) {
|
|
52
|
+
map.set(match[1], i);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return map;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 计算缓存大纲行的缩进深度。
|
|
59
|
+
* 与 tree-parser.ts 的 parseLine 和解析器的 calcDepth 保持一致。
|
|
60
|
+
*/
|
|
61
|
+
function outlineLineDepth(line) {
|
|
62
|
+
const stripped = line.replace(/^ */, '');
|
|
63
|
+
const indentChars = line.length - stripped.length;
|
|
64
|
+
return Math.floor(indentChars / 2);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 预扫描所有 hunk,检测跨 hunk 的 ID 引用冲突。
|
|
68
|
+
*
|
|
69
|
+
* 规则:如果一个 hunk 删除了某 ID,而另一个 hunk 将该 ID 用作上下文锚点,
|
|
70
|
+
* 则存在冲突(同一 hunk 内允许先锚点后删除或先删除后锚点)。
|
|
71
|
+
*/
|
|
72
|
+
function validateCrossHunkConflicts(hunks) {
|
|
73
|
+
// 收集每个 hunk 的删除 ID 集合
|
|
74
|
+
const deletionsByHunk = new Map();
|
|
75
|
+
const allDeletedIds = new Set();
|
|
76
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
77
|
+
const hunkDeleted = new Set();
|
|
78
|
+
for (const line of hunks[h].lines) {
|
|
79
|
+
if (line.type === 'deletion' && line.remId) {
|
|
80
|
+
hunkDeleted.add(line.remId);
|
|
81
|
+
allDeletedIds.add(line.remId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
deletionsByHunk.set(h, hunkDeleted);
|
|
85
|
+
}
|
|
86
|
+
// 检查每个 hunk 的锚点 ID 是否被其他 hunk 删除
|
|
87
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
88
|
+
for (const line of hunks[h].lines) {
|
|
89
|
+
if (line.type === 'context' && line.remId) {
|
|
90
|
+
if (allDeletedIds.has(line.remId)) {
|
|
91
|
+
// 检查删除和锚点是否在同一个 hunk
|
|
92
|
+
const isDeletedInSameHunk = deletionsByHunk.get(h).has(line.remId);
|
|
93
|
+
if (!isDeletedInSameHunk) {
|
|
94
|
+
throw new PatchConflictError(`Hunk 间冲突:Rem ID "${line.remId}" 被一个 hunk 删除,` +
|
|
95
|
+
`但在另一个 hunk(行 ${line.lineNumber})中被用作锚点`, line.remId);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 将单个 hunk 的操作标记到 deleted 和 insertions 中。
|
|
104
|
+
*
|
|
105
|
+
* 遍历 hunk 的每一行:
|
|
106
|
+
* - context:通过 ID 索引定位 + 缩进校验,更新 lastMatchedIndex
|
|
107
|
+
* - deletion:通过 ID 索引定位 + 缩进校验,标记删除,更新 lastMatchedIndex
|
|
108
|
+
* - addition:追加到 lastMatchedIndex 的插入列表
|
|
109
|
+
*/
|
|
110
|
+
function applyHunk(hunk, outlineLines, idIndex, deleted, insertions) {
|
|
111
|
+
let lastMatchedIndex = -1;
|
|
112
|
+
for (const patchLine of hunk.lines) {
|
|
113
|
+
if (patchLine.type === 'context') {
|
|
114
|
+
const idx = idIndex.get(patchLine.remId);
|
|
115
|
+
if (idx === undefined) {
|
|
116
|
+
throw new PatchApplyError(`锚点 <!--${patchLine.remId}--> 在缓存大纲中不存在`, patchLine.lineNumber);
|
|
117
|
+
}
|
|
118
|
+
// 校验缩进
|
|
119
|
+
const actualDepth = outlineLineDepth(outlineLines[idx]);
|
|
120
|
+
if (actualDepth !== patchLine.depth) {
|
|
121
|
+
throw new PatchApplyError(`锚点 <!--${patchLine.remId}--> 缩进不匹配:` +
|
|
122
|
+
`缓存大纲中 depth=${actualDepth},patch 中 depth=${patchLine.depth}`, patchLine.lineNumber);
|
|
123
|
+
}
|
|
124
|
+
lastMatchedIndex = idx;
|
|
125
|
+
}
|
|
126
|
+
else if (patchLine.type === 'deletion') {
|
|
127
|
+
const idx = idIndex.get(patchLine.remId);
|
|
128
|
+
if (idx === undefined) {
|
|
129
|
+
throw new PatchApplyError(`要删除的 <!--${patchLine.remId}--> 在缓存大纲中不存在`, patchLine.lineNumber);
|
|
130
|
+
}
|
|
131
|
+
// 校验缩进
|
|
132
|
+
const actualDepth = outlineLineDepth(outlineLines[idx]);
|
|
133
|
+
if (actualDepth !== patchLine.depth) {
|
|
134
|
+
throw new PatchApplyError(`要删除的 <!--${patchLine.remId}--> 缩进不匹配:` +
|
|
135
|
+
`缓存大纲中 depth=${actualDepth},patch 中 depth=${patchLine.depth}`, patchLine.lineNumber);
|
|
136
|
+
}
|
|
137
|
+
deleted.add(idx);
|
|
138
|
+
lastMatchedIndex = idx;
|
|
139
|
+
}
|
|
140
|
+
else if (patchLine.type === 'addition') {
|
|
141
|
+
// 新增行插入在 lastMatchedIndex 之后
|
|
142
|
+
if (!insertions.has(lastMatchedIndex)) {
|
|
143
|
+
insertions.set(lastMatchedIndex, []);
|
|
144
|
+
}
|
|
145
|
+
// patchLine.content 已是完整的大纲行(含缩进),直接使用
|
|
146
|
+
insertions.get(lastMatchedIndex).push(patchLine.content);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 一次性重建新大纲字符串。
|
|
152
|
+
*
|
|
153
|
+
* 遍历原始行,跳过被删除的行,在适当位置插入新行。
|
|
154
|
+
*/
|
|
155
|
+
function rebuild(lines, deleted, insertions) {
|
|
156
|
+
const result = [];
|
|
157
|
+
// 处理在第一行之前的插入(lastMatchedIndex = -1 时)
|
|
158
|
+
const prependLines = insertions.get(-1);
|
|
159
|
+
if (prependLines) {
|
|
160
|
+
result.push(...prependLines);
|
|
161
|
+
}
|
|
162
|
+
for (let i = 0; i < lines.length; i++) {
|
|
163
|
+
if (!deleted.has(i)) {
|
|
164
|
+
result.push(lines[i]);
|
|
165
|
+
}
|
|
166
|
+
// 在第 i 行之后插入新行(无论 i 是否被删除)
|
|
167
|
+
const toInsert = insertions.get(i);
|
|
168
|
+
if (toInsert) {
|
|
169
|
+
result.push(...toInsert);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result.join('\n');
|
|
173
|
+
}
|
|
174
|
+
// ────────────────────────── 应用器 ──────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* 将 patch 应用到缓存大纲,生成新大纲字符串。
|
|
177
|
+
*
|
|
178
|
+
* 策略:ID 索引 + 一次性标记-重建(mark-and-rebuild)。
|
|
179
|
+
* 1. 解析 patch 文本为结构化 hunk 列表
|
|
180
|
+
* 2. 预扫描缓存大纲构建 Rem ID → 行索引映射
|
|
181
|
+
* 3. 预检测 hunk 间 ID 引用冲突
|
|
182
|
+
* 4. 遍历 hunk,为每行标记操作(删除/插入)
|
|
183
|
+
* 5. 一次性重建新大纲字符串
|
|
184
|
+
*
|
|
185
|
+
* @param patchText patch 文本
|
|
186
|
+
* @param cachedOutline 缓存大纲字符串(read-tree 输出格式)
|
|
187
|
+
* @returns 新大纲字符串(可直接传入 parseOutline)
|
|
188
|
+
*/
|
|
189
|
+
export function applyPatch(patchText, cachedOutline) {
|
|
190
|
+
// 1. 解析 patch
|
|
191
|
+
const parsed = parsePatch(patchText);
|
|
192
|
+
// 2. 构建缓存大纲的行数组和 ID 索引
|
|
193
|
+
const lines = cachedOutline.split('\n');
|
|
194
|
+
const idIndex = buildIdIndex(lines);
|
|
195
|
+
// 3. 预检测 hunk 间冲突
|
|
196
|
+
validateCrossHunkConflicts(parsed.hunks);
|
|
197
|
+
// 4. 标记操作
|
|
198
|
+
const deleted = new Set();
|
|
199
|
+
const insertions = new Map();
|
|
200
|
+
for (const hunk of parsed.hunks) {
|
|
201
|
+
applyHunk(hunk, lines, idIndex, deleted, insertions);
|
|
202
|
+
}
|
|
203
|
+
// 5. 重建
|
|
204
|
+
return rebuild(lines, deleted, insertions);
|
|
205
|
+
}
|
|
206
|
+
// ────────────────────────── 正则常量 ──────────────────────────
|
|
207
|
+
/** 合法锚点行:只包含缩进 + <!--remId--> */
|
|
208
|
+
const ANCHOR_LINE_RE = /^( *)<!--(\S+)-->$/;
|
|
209
|
+
/** 非法锚点行:包含内容文本 + <!--remId-->(用于报错提示) */
|
|
210
|
+
const CONTENT_BEFORE_MARKER_RE = /^( *)\S.*<!--\S+-->$/;
|
|
211
|
+
/** 从行文本中提取 Rem ID */
|
|
212
|
+
const REM_ID_RE = /<!--(\S+)-->/;
|
|
213
|
+
// ────────────────────────── 内部函数 ──────────────────────────
|
|
214
|
+
/**
|
|
215
|
+
* 计算缩进深度:每 2 个空格一级(与 tree-parser.ts 的 parseLine 一致)。
|
|
216
|
+
*/
|
|
217
|
+
function calcDepth(text) {
|
|
218
|
+
const stripped = text.replace(/^ */, '');
|
|
219
|
+
const indentChars = text.length - stripped.length;
|
|
220
|
+
return Math.floor(indentChars / 2);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 解析 patch 文本中的单行。
|
|
224
|
+
*
|
|
225
|
+
* 行类型判定规则:
|
|
226
|
+
* - `+` 前缀 → addition(新增行)
|
|
227
|
+
* - `-` 前缀 → deletion(删除行,必须包含 Rem ID)
|
|
228
|
+
* - 无前缀 → context(锚点行,严格校验:只允许缩进 + Rem ID 注释)
|
|
229
|
+
*/
|
|
230
|
+
function parsePatchLine(rawLine, lineNumber) {
|
|
231
|
+
const trimmed = rawLine.trimEnd();
|
|
232
|
+
// +/- 前缀检测
|
|
233
|
+
if (trimmed.startsWith('+') || trimmed.startsWith('-')) {
|
|
234
|
+
const prefix = trimmed[0];
|
|
235
|
+
const rest = trimmed.slice(1); // 剥离前缀,保留完整缩进
|
|
236
|
+
const depth = calcDepth(rest);
|
|
237
|
+
if (prefix === '+') {
|
|
238
|
+
// 新增行:remId 为 null(不提取,交给下游 parseOutline 处理)
|
|
239
|
+
return {
|
|
240
|
+
type: 'addition',
|
|
241
|
+
depth,
|
|
242
|
+
remId: null,
|
|
243
|
+
content: rest,
|
|
244
|
+
rawLine,
|
|
245
|
+
lineNumber,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
// 删除行:必须包含 Rem ID
|
|
250
|
+
const stripped = rest.replace(/^ */, '');
|
|
251
|
+
const idMatch = stripped.match(REM_ID_RE);
|
|
252
|
+
if (!idMatch) {
|
|
253
|
+
throw new PatchParseError('删除行必须包含 Rem ID(格式:<!--remId-->),当前行缺少 ID', lineNumber, rawLine);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
type: 'deletion',
|
|
257
|
+
depth,
|
|
258
|
+
remId: idMatch[1],
|
|
259
|
+
content: '',
|
|
260
|
+
rawLine,
|
|
261
|
+
lineNumber,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// 上下文行(锚点行)
|
|
266
|
+
const anchorMatch = trimmed.match(ANCHOR_LINE_RE);
|
|
267
|
+
if (anchorMatch) {
|
|
268
|
+
const depth = Math.floor(anchorMatch[1].length / 2);
|
|
269
|
+
return {
|
|
270
|
+
type: 'context',
|
|
271
|
+
depth,
|
|
272
|
+
remId: anchorMatch[2],
|
|
273
|
+
content: '',
|
|
274
|
+
rawLine,
|
|
275
|
+
lineNumber,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
// 检测包含内容文本的非法锚点行
|
|
279
|
+
if (CONTENT_BEFORE_MARKER_RE.test(trimmed)) {
|
|
280
|
+
throw new PatchParseError('上下文行只允许缩进 + Rem ID(如 " <!--abc-->"),不得包含内容文本。' +
|
|
281
|
+
'请移除内容文本只保留 Rem ID。', lineNumber, rawLine);
|
|
282
|
+
}
|
|
283
|
+
// 无法识别的行格式
|
|
284
|
+
throw new PatchParseError('无法识别的行格式。期望格式:上下文行 " <!--remId-->"、' +
|
|
285
|
+
'新增行 "+ 内容" 或删除行 "- <!--remId-->"', lineNumber, rawLine);
|
|
286
|
+
}
|
|
287
|
+
// ────────────────────────── 解析器 ──────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* 解析 patch 文本为结构化的 ParsedPatch。
|
|
290
|
+
*
|
|
291
|
+
* - 按 `\n` 拆分行
|
|
292
|
+
* - 空行跳过但保留行号映射
|
|
293
|
+
* - `...` 行作为 hunk 分隔符
|
|
294
|
+
* - 每个非空行通过 parsePatchLine 解析为 PatchLine
|
|
295
|
+
*/
|
|
296
|
+
export function parsePatch(patchText) {
|
|
297
|
+
const rawLines = patchText.split('\n');
|
|
298
|
+
const hunks = [];
|
|
299
|
+
let currentLines = [];
|
|
300
|
+
let currentStartLine = -1;
|
|
301
|
+
let hasAnyContent = false;
|
|
302
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
303
|
+
const lineNumber = i + 1; // 1-based
|
|
304
|
+
const line = rawLines[i];
|
|
305
|
+
// 跳过空行(trim 后为空)
|
|
306
|
+
if (line.trim() === '') {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// hunk 分隔符
|
|
310
|
+
if (line.trim() === '...') {
|
|
311
|
+
// 关闭当前 hunk
|
|
312
|
+
if (currentLines.length > 0) {
|
|
313
|
+
hunks.push({ lines: currentLines, startLine: currentStartLine });
|
|
314
|
+
currentLines = [];
|
|
315
|
+
currentStartLine = -1;
|
|
316
|
+
}
|
|
317
|
+
else if (hasAnyContent) {
|
|
318
|
+
// 连续 ... 之间无内容 → 空 hunk
|
|
319
|
+
throw new PatchParseError('hunk 内容为空(连续的 ... 分隔符之间没有有效行)', lineNumber, line);
|
|
320
|
+
}
|
|
321
|
+
hasAnyContent = true;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// 解析行
|
|
325
|
+
const patchLine = parsePatchLine(line, lineNumber);
|
|
326
|
+
if (currentStartLine === -1) {
|
|
327
|
+
currentStartLine = lineNumber;
|
|
328
|
+
}
|
|
329
|
+
currentLines.push(patchLine);
|
|
330
|
+
hasAnyContent = true;
|
|
331
|
+
}
|
|
332
|
+
// 关闭最后一个 hunk
|
|
333
|
+
if (currentLines.length > 0) {
|
|
334
|
+
hunks.push({ lines: currentLines, startLine: currentStartLine });
|
|
335
|
+
}
|
|
336
|
+
// 校验:空 patch
|
|
337
|
+
if (hunks.length === 0) {
|
|
338
|
+
throw new PatchParseError('patch 为空,没有有效的 patch 行', 1, patchText.split('\n')[0] || '');
|
|
339
|
+
}
|
|
340
|
+
// 校验:任何 hunk 的 lines 为空不应发生(已在循环中处理),但做保底检查
|
|
341
|
+
for (const hunk of hunks) {
|
|
342
|
+
if (hunk.lines.length === 0) {
|
|
343
|
+
throw new PatchParseError('hunk 内容为空', hunk.startLine, '');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { hunks };
|
|
347
|
+
}
|
|
@@ -6,24 +6,7 @@
|
|
|
6
6
|
* 2. 序列化为 JSON 字符串并缓存(完整版本)
|
|
7
7
|
* 3. 根据 fields/full 参数过滤字段返回给 CLI
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
const RF_FIELDS = new Set([
|
|
11
|
-
'children',
|
|
12
|
-
'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
|
|
13
|
-
'isPowerupPropertyListItem', 'isPowerupSlot',
|
|
14
|
-
'deepRemsBeingReferenced',
|
|
15
|
-
'ancestorTagRem', 'descendantTagRem',
|
|
16
|
-
'portalsAndDocumentsIn', 'allRemInDocumentOrPortal', 'allRemInFolderQueue',
|
|
17
|
-
'timesSelectedInSearch', 'lastTimeMovedTo', 'schemaVersion',
|
|
18
|
-
'embeddedQueueViewMode',
|
|
19
|
-
'localUpdatedAt', 'lastPracticed',
|
|
20
|
-
]);
|
|
21
|
-
/** Portal 简化输出字段(type === 'portal' 时默认输出这 8 个字段) */
|
|
22
|
-
export const PORTAL_FIELDS = [
|
|
23
|
-
'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
|
|
24
|
-
'parent', 'positionAmongstSiblings',
|
|
25
|
-
'createdAt', 'updatedAt',
|
|
26
|
-
];
|
|
9
|
+
import { filterRemFields } from './rem-field-filter.js';
|
|
27
10
|
export class ReadHandler {
|
|
28
11
|
cache;
|
|
29
12
|
forwardToPlugin;
|
|
@@ -50,41 +33,7 @@ export class ReadHandler {
|
|
|
50
33
|
// 字段过滤
|
|
51
34
|
const fields = payload.fields;
|
|
52
35
|
const full = payload.full;
|
|
53
|
-
let result;
|
|
54
|
-
if (full) {
|
|
55
|
-
// --full → 返回完整对象(含 R-F 字段)。浅拷贝避免污染缓存对象。
|
|
56
|
-
result = { ...remObject };
|
|
57
|
-
}
|
|
58
|
-
else if (fields) {
|
|
59
|
-
// --fields 过滤:只返回指定字段 + id
|
|
60
|
-
const obj = remObject;
|
|
61
|
-
result = { id: obj.id };
|
|
62
|
-
for (const field of fields) {
|
|
63
|
-
if (field in obj) {
|
|
64
|
-
result[field] = obj[field];
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
else if (remObject.type === 'portal') {
|
|
69
|
-
// Portal 简化模式:只输出 8 个关键字段
|
|
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
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
// 默认模式:排除 R-F 字段
|
|
80
|
-
const obj = remObject;
|
|
81
|
-
result = {};
|
|
82
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
83
|
-
if (!RF_FIELDS.has(key)) {
|
|
84
|
-
result[key] = value;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
36
|
+
let result = filterRemFields(remObject, { full, fields });
|
|
88
37
|
// 附加缓存覆盖提示
|
|
89
38
|
if (previousCachedAt) {
|
|
90
39
|
result._cacheOverridden = { id: remId, previousCachedAt };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rem-field-filter — RemObject 字段过滤逻辑
|
|
3
|
+
*
|
|
4
|
+
* 从 ReadHandler 提取的共享模块,供 ReadHandler 和 TreeRemReadHandler 使用。
|
|
5
|
+
*/
|
|
6
|
+
/** R-F 字段(仅 --full 模式输出,默认不输出) */
|
|
7
|
+
export const RF_FIELDS = new Set([
|
|
8
|
+
'children',
|
|
9
|
+
'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
|
|
10
|
+
'isPowerupPropertyListItem', 'isPowerupSlot',
|
|
11
|
+
'deepRemsBeingReferenced',
|
|
12
|
+
'ancestorTagRem', 'descendantTagRem',
|
|
13
|
+
'portalsAndDocumentsIn', 'allRemInDocumentOrPortal', 'allRemInFolderQueue',
|
|
14
|
+
'timesSelectedInSearch', 'lastTimeMovedTo', 'schemaVersion',
|
|
15
|
+
'embeddedQueueViewMode',
|
|
16
|
+
'localUpdatedAt', 'lastPracticed',
|
|
17
|
+
]);
|
|
18
|
+
/** Token Slimming: 默认模式下,字段值匹配此表时省略输出。未列入的字段始终输出。 */
|
|
19
|
+
export const REM_DEFAULTS = {
|
|
20
|
+
backText: null,
|
|
21
|
+
type: 'default',
|
|
22
|
+
isDocument: false,
|
|
23
|
+
fontSize: null,
|
|
24
|
+
highlightColor: null,
|
|
25
|
+
isTodo: false,
|
|
26
|
+
todoStatus: null,
|
|
27
|
+
isCode: false,
|
|
28
|
+
isQuote: false,
|
|
29
|
+
isListItem: false,
|
|
30
|
+
isCardItem: false,
|
|
31
|
+
isTable: false,
|
|
32
|
+
isSlot: false,
|
|
33
|
+
isProperty: false,
|
|
34
|
+
portalType: null,
|
|
35
|
+
portalDirectlyIncludedRem: [],
|
|
36
|
+
propertyType: null,
|
|
37
|
+
enablePractice: false,
|
|
38
|
+
practiceDirection: 'forward',
|
|
39
|
+
tags: [],
|
|
40
|
+
sources: [],
|
|
41
|
+
aliases: [],
|
|
42
|
+
remsBeingReferenced: [],
|
|
43
|
+
remsReferencingThis: [],
|
|
44
|
+
taggedRem: [],
|
|
45
|
+
descendants: [],
|
|
46
|
+
siblingRem: [],
|
|
47
|
+
positionAmongstSiblings: null,
|
|
48
|
+
};
|
|
49
|
+
export function matchesDefault(value, defaultValue) {
|
|
50
|
+
if (Array.isArray(defaultValue) && defaultValue.length === 0) {
|
|
51
|
+
return Array.isArray(value) && value.length === 0;
|
|
52
|
+
}
|
|
53
|
+
return value === defaultValue;
|
|
54
|
+
}
|
|
55
|
+
/** Portal 简化输出字段(type === 'portal' 时默认输出这 8 个字段) */
|
|
56
|
+
export const PORTAL_FIELDS = [
|
|
57
|
+
'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
|
|
58
|
+
'parent', 'positionAmongstSiblings',
|
|
59
|
+
'createdAt', 'updatedAt',
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* 过滤 RemObject 字段。
|
|
63
|
+
*
|
|
64
|
+
* @param remObject 完整 RemObject
|
|
65
|
+
* @param options.full 返回全部字段
|
|
66
|
+
* @param options.fields 返回指定字段子集
|
|
67
|
+
* @returns 过滤后的对象
|
|
68
|
+
*/
|
|
69
|
+
export function filterRemFields(remObject, options) {
|
|
70
|
+
const { full, fields } = options ?? {};
|
|
71
|
+
if (full) {
|
|
72
|
+
return { ...remObject };
|
|
73
|
+
}
|
|
74
|
+
if (fields) {
|
|
75
|
+
const result = { id: remObject.id };
|
|
76
|
+
for (const field of fields) {
|
|
77
|
+
if (field in remObject) {
|
|
78
|
+
result[field] = remObject[field];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
if (remObject.type === 'portal') {
|
|
84
|
+
const result = {};
|
|
85
|
+
for (const field of PORTAL_FIELDS) {
|
|
86
|
+
if (field in remObject) {
|
|
87
|
+
result[field] = remObject[field];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
// 默认模式:排除 R-F 字段 + Token Slimming
|
|
93
|
+
const result = {};
|
|
94
|
+
for (const [key, value] of Object.entries(remObject)) {
|
|
95
|
+
if (RF_FIELDS.has(key))
|
|
96
|
+
continue;
|
|
97
|
+
if (key in REM_DEFAULTS && matchesDefault(value, REM_DEFAULTS[key]))
|
|
98
|
+
continue;
|
|
99
|
+
result[key] = value;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -2,13 +2,64 @@
|
|
|
2
2
|
* TreeEditHandler — edit-tree 请求的业务编排
|
|
3
3
|
*
|
|
4
4
|
* 职责:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
5
|
+
* 1. 模板展开({{remId}} → 缓存中对应行的完整内容)
|
|
6
|
+
* 2. 三道防线(缓存存在、变更检测、str_replace 精确匹配)
|
|
7
|
+
* 3. 解析新旧大纲并 diff
|
|
8
|
+
* 4. 逐项执行操作(通过 forwardToPlugin 调用原子操作)
|
|
9
|
+
* 5. 成功后重新 read-tree 更新缓存
|
|
9
10
|
*/
|
|
10
11
|
import { DEFAULT_DEFAULTS } from '../config.js';
|
|
11
12
|
import { parseOutline, diffTrees, parsePowerupPrefix } from './tree-parser.js';
|
|
13
|
+
// ────────────────────────── 模板展开 ──────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* 匹配 {{remId}} 占位符。
|
|
16
|
+
* 限定纯字母数字,避免与 RemNote cloze 语法 {{text}} 冲突
|
|
17
|
+
* (cloze 内容可能含中文、空格、标点,不会被此正则匹配)。
|
|
18
|
+
*/
|
|
19
|
+
const TEMPLATE_RE = /\{\{([a-zA-Z0-9]+)\}\}/g;
|
|
20
|
+
/** 行尾标记正则(与 tree-parser.ts 的 LINE_MARKER_RE 一致) */
|
|
21
|
+
const LINE_MARKER_RE = /<!--(\S+)(?:\s+\S+)*-->$/;
|
|
22
|
+
/** 省略占位符正则(与 tree-parser.ts 的 ELIDED_LINE_RE 一致) */
|
|
23
|
+
const ELIDED_LINE_RE = /^<!--\.\.\.elided\s/;
|
|
24
|
+
/**
|
|
25
|
+
* 从缓存大纲构建 remId → 去缩进完整行 的映射。
|
|
26
|
+
* 省略占位符行无 remId,自然不会进入映射。
|
|
27
|
+
*/
|
|
28
|
+
function buildLineMap(cachedOutline) {
|
|
29
|
+
const map = new Map();
|
|
30
|
+
for (const line of cachedOutline.split('\n')) {
|
|
31
|
+
const stripped = line.trimStart();
|
|
32
|
+
if (!stripped || ELIDED_LINE_RE.test(stripped))
|
|
33
|
+
continue;
|
|
34
|
+
const match = stripped.match(LINE_MARKER_RE);
|
|
35
|
+
if (match) {
|
|
36
|
+
map.set(match[1], stripped);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 展开 oldStr/newStr 中的 {{remId}} 占位符。
|
|
43
|
+
*
|
|
44
|
+
* {{remId}} 被替换为该 remId 在缓存大纲中对应行的完整内容(不含缩进)。
|
|
45
|
+
* 不含 {{}} 的文本原样返回(零开销,向后兼容)。
|
|
46
|
+
*
|
|
47
|
+
* 匹配到纯字母数字但不在 lineMap 中的 {{xxx}} 原样保留(可能是 cloze 内容),
|
|
48
|
+
* 不报错,避免与 RemNote 的 {{cloze}} 语法冲突。未展开的模板记录在 warnings 中。
|
|
49
|
+
*/
|
|
50
|
+
function expandTemplates(text, lineMap, warnings) {
|
|
51
|
+
if (!text.includes('{{'))
|
|
52
|
+
return text;
|
|
53
|
+
return text.replace(TEMPLATE_RE, (match, remId) => {
|
|
54
|
+
const content = lineMap.get(remId);
|
|
55
|
+
if (content === undefined) {
|
|
56
|
+
// 不在 lineMap 中:可能是 cloze 文本,原样保留并记录 warning
|
|
57
|
+
warnings.push(`{{${remId}}} 未在缓存大纲中找到,已原样保留(若为 cloze 可忽略;若为 remId 请检查拼写)`);
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
return content;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
12
63
|
export class TreeEditHandler {
|
|
13
64
|
cache;
|
|
14
65
|
forwardToPlugin;
|
|
@@ -42,8 +93,17 @@ export class TreeEditHandler {
|
|
|
42
93
|
// 不更新缓存 — 迫使 AI re-read
|
|
43
94
|
throw new Error(`Tree rooted at ${remId} has been modified since last read-tree. Please read-tree again.`);
|
|
44
95
|
}
|
|
96
|
+
// ── 模板展开:{{remId}} → 缓存中对应行的完整内容(不含缩进)──
|
|
97
|
+
const lineMap = buildLineMap(cachedOutline);
|
|
98
|
+
const templateWarnings = [];
|
|
99
|
+
const expandedOldStr = expandTemplates(oldStr, lineMap, templateWarnings);
|
|
100
|
+
const expandedNewStr = expandTemplates(newStr, lineMap, templateWarnings);
|
|
101
|
+
// 展开后 noop 检查(原始不等但展开后可能相等)
|
|
102
|
+
if (expandedOldStr === expandedNewStr) {
|
|
103
|
+
return { ok: true, operations: [], ...(templateWarnings.length > 0 && { templateWarnings }) };
|
|
104
|
+
}
|
|
45
105
|
// ── 防线 3: str_replace 精确匹配 ──
|
|
46
|
-
const matchCount = countOccurrences(cachedOutline,
|
|
106
|
+
const matchCount = countOccurrences(cachedOutline, expandedOldStr);
|
|
47
107
|
if (matchCount === 0) {
|
|
48
108
|
throw new Error(`old_str not found in the tree outline of ${remId}`);
|
|
49
109
|
}
|
|
@@ -51,7 +111,7 @@ export class TreeEditHandler {
|
|
|
51
111
|
throw new Error(`old_str matches ${matchCount} locations in the tree outline of ${remId}. ` +
|
|
52
112
|
`Make old_str more specific to match exactly once.`);
|
|
53
113
|
}
|
|
54
|
-
const modifiedOutline = cachedOutline.replace(
|
|
114
|
+
const modifiedOutline = cachedOutline.replace(expandedOldStr, expandedNewStr);
|
|
55
115
|
// ── 解析新旧大纲 ──
|
|
56
116
|
const oldTree = parseOutline(cachedOutline);
|
|
57
117
|
const newTree = parseOutline(modifiedOutline);
|
|
@@ -201,7 +261,7 @@ export class TreeEditHandler {
|
|
|
201
261
|
// ── D3: 成功后更新缓存(使用相同参数)──
|
|
202
262
|
const updatedResult = await this.forwardToPlugin('read_tree', { remId, depth, maxNodes, maxSiblings });
|
|
203
263
|
this.cache.set('tree:' + remId, updatedResult.outline);
|
|
204
|
-
return { ok: true, operations };
|
|
264
|
+
return { ok: true, operations, ...(templateWarnings.length > 0 && { templateWarnings }) };
|
|
205
265
|
}
|
|
206
266
|
}
|
|
207
267
|
/** 统计 needle 在 haystack 中出现的次数 */
|
|
@@ -32,9 +32,12 @@ export class TreeReadHandler {
|
|
|
32
32
|
const cacheKey = 'tree:' + remId;
|
|
33
33
|
const previousCachedAt = this.cache.getCreatedAt(cacheKey);
|
|
34
34
|
// 转发到 Plugin 的 read_tree service
|
|
35
|
-
const
|
|
35
|
+
const rawResult = await this.forwardToPlugin('read_tree', {
|
|
36
36
|
remId, depth, maxNodes, maxSiblings, ancestorLevels, includePowerup,
|
|
37
37
|
});
|
|
38
|
+
// 剥离内部字段 nodeRemIds(不暴露给 CLI 输出)
|
|
39
|
+
delete rawResult.nodeRemIds;
|
|
40
|
+
const result = rawResult;
|
|
38
41
|
// 缓存大纲文本 + 读取参数(供 edit-tree 乐观并发检测时复现相同查询)
|
|
39
42
|
this.cache.set(cacheKey, result.outline);
|
|
40
43
|
this.cache.set('tree-depth:' + remId, String(depth));
|