remnote-bridge 0.1.12 → 0.1.14
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 +141 -28
- package/README.zh-CN.md +368 -0
- package/dist/cli/commands/edit-rem.js +5 -5
- package/dist/cli/commands/health.js +231 -112
- package/dist/cli/commands/read-rem-in-tree.js +84 -0
- package/dist/cli/commands/read-rem.js +3 -1
- package/dist/cli/config.js +2 -0
- package/dist/cli/daemon/registry.js +8 -0
- package/dist/cli/handlers/edit-handler.js +49 -140
- package/dist/cli/handlers/patch-engine.js +347 -0
- package/dist/cli/handlers/read-handler.js +5 -57
- package/dist/cli/handlers/rem-cache.js +10 -5
- 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 +71 -12
- package/dist/cli/server/ws-server.js +9 -1
- package/dist/mcp/daemon-client.js +22 -2
- package/dist/mcp/format.js +43 -0
- package/dist/mcp/index.js +0 -55
- package/dist/mcp/instructions.js +447 -284
- package/dist/mcp/resources/edit-rem-guide.js +37 -157
- package/dist/mcp/resources/edit-tree-guide.js +1 -1
- package/dist/mcp/resources/error-reference.js +9 -13
- package/dist/mcp/resources/rem-object-fields.js +3 -3
- package/dist/mcp/tools/edit-tools.js +76 -10
- package/dist/mcp/tools/infra-tools.js +30 -33
- package/dist/mcp/tools/read-tools.js +221 -26
- 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/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 +15 -0
- package/remnote-plugin/src/services/read-tree.ts +5 -0
- package/skills/remnote-bridge/SKILL.md +71 -38
- package/skills/remnote-bridge/instructions/connect.md +12 -1
- package/skills/remnote-bridge/instructions/disconnect.md +5 -0
- package/skills/remnote-bridge/instructions/edit-rem.md +105 -347
- package/skills/remnote-bridge/instructions/edit-tree.md +71 -2
- package/skills/remnote-bridge/instructions/health.md +81 -53
- package/skills/remnote-bridge/instructions/overall.md +55 -21
- package/skills/remnote-bridge/instructions/read-rem-in-tree.md +100 -0
- package/skills/remnote-bridge/instructions/read-rem.md +35 -16
- package/skills/remnote-bridge/instructions/search.md +4 -4
- package/skills/remnote-bridge/instructions/setup.md +5 -6
- 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
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EditHandler — edit-rem 命令的编排器
|
|
3
3
|
*
|
|
4
|
-
* 在 daemon
|
|
4
|
+
* 在 daemon 层实现两道防线 + 字段白名单校验 + 直接转发 changes。
|
|
5
5
|
* Plugin 只负责原子写入(write_rem_fields)。
|
|
6
6
|
*
|
|
7
7
|
* 防线 1:缓存存在性检查(必须先 read 再 edit)
|
|
8
8
|
* 防线 2:乐观并发检测(当前 JSON 与缓存 JSON 比较)
|
|
9
|
-
* 防线 3:str_replace 精确匹配(old_str 必须唯一匹配)
|
|
10
|
-
*
|
|
11
|
-
* Portal 专用路径:type === 'portal' 时,在简化 JSON(9 字段)上执行 str_replace,
|
|
12
|
-
* 推导变更后调用专用写入逻辑。
|
|
13
9
|
*/
|
|
14
|
-
import { PORTAL_FIELDS } from './read-handler.js';
|
|
15
10
|
/** 只读字段集合 — 变更这些字段只产生警告,不执行写入 */
|
|
16
11
|
const READ_ONLY_FIELDS = new Set([
|
|
17
12
|
'id',
|
|
@@ -30,10 +25,22 @@ const READ_ONLY_FIELDS = new Set([
|
|
|
30
25
|
'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
|
|
31
26
|
'isPowerupPropertyListItem', 'isPowerupSlot',
|
|
32
27
|
]);
|
|
33
|
-
/**
|
|
34
|
-
const
|
|
35
|
-
'
|
|
28
|
+
/** 可写字段白名单 — 21 个可写字段 */
|
|
29
|
+
const WRITABLE_FIELDS = new Set([
|
|
30
|
+
'text', 'backText', 'type', 'isDocument', 'parent',
|
|
31
|
+
'fontSize', 'highlightColor',
|
|
32
|
+
'isTodo', 'todoStatus', 'isCode', 'isQuote', 'isListItem', 'isCardItem', 'isSlot', 'isProperty',
|
|
33
|
+
'enablePractice', 'practiceDirection',
|
|
34
|
+
'tags', 'sources', 'positionAmongstSiblings', 'portalDirectlyIncludedRem',
|
|
36
35
|
]);
|
|
36
|
+
/** 枚举字段的合法值 */
|
|
37
|
+
const ENUM_VALUES = {
|
|
38
|
+
type: new Set(['concept', 'descriptor', 'default']),
|
|
39
|
+
practiceDirection: new Set(['forward', 'backward', 'both', 'none']),
|
|
40
|
+
highlightColor: new Set(['Red', 'Orange', 'Yellow', 'Green', 'Blue', 'Purple', 'Gray', 'Brown', 'Pink', null]),
|
|
41
|
+
fontSize: new Set(['H1', 'H2', 'H3', null]),
|
|
42
|
+
todoStatus: new Set(['Finished', 'Unfinished', null]),
|
|
43
|
+
};
|
|
37
44
|
export class EditHandler {
|
|
38
45
|
cache;
|
|
39
46
|
forwardToPlugin;
|
|
@@ -42,145 +49,61 @@ export class EditHandler {
|
|
|
42
49
|
this.forwardToPlugin = forwardToPlugin;
|
|
43
50
|
}
|
|
44
51
|
async handleEditRem(payload) {
|
|
45
|
-
const { remId,
|
|
52
|
+
const { remId, changes } = payload;
|
|
53
|
+
if (!changes || typeof changes !== 'object' || Object.keys(changes).length === 0) {
|
|
54
|
+
return { ok: true, changes: [], warnings: [] };
|
|
55
|
+
}
|
|
46
56
|
// ── 防线 1: 缓存存在性检查 ──
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
57
|
+
const cachedObj = this.cache.get('rem:' + remId);
|
|
58
|
+
if (!cachedObj) {
|
|
49
59
|
throw new Error(`Rem ${remId} has not been read yet. Read it first before editing.`);
|
|
50
60
|
}
|
|
51
61
|
// ── 防线 2: 乐观并发检测 ──
|
|
52
62
|
const currentRemObject = await this.forwardToPlugin('read_rem', { remId });
|
|
53
63
|
const currentJson = JSON.stringify(currentRemObject, null, 2);
|
|
64
|
+
const cachedJson = JSON.stringify(cachedObj, null, 2);
|
|
54
65
|
if (currentJson !== cachedJson) {
|
|
55
66
|
// 不更新缓存 — 迫使 AI re-read
|
|
56
67
|
throw new Error(`Rem ${remId} has been modified since last read. Please read it again before editing.`);
|
|
57
68
|
}
|
|
58
|
-
// ──
|
|
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 = {};
|
|
69
|
+
// ── 遍历 changes keys:分类过滤 ──
|
|
96
70
|
const warnings = [];
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
71
|
+
const writableChanges = {};
|
|
72
|
+
for (const key of Object.keys(changes)) {
|
|
73
|
+
if (READ_ONLY_FIELDS.has(key)) {
|
|
74
|
+
warnings.push(`Field '${key}' is read-only and was ignored`);
|
|
75
|
+
}
|
|
76
|
+
else if (!WRITABLE_FIELDS.has(key)) {
|
|
77
|
+
warnings.push(`Field '${key}' is unknown and was ignored`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
writableChanges[key] = changes[key];
|
|
105
81
|
}
|
|
106
82
|
}
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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) {
|
|
138
|
-
// ── 防线 3: str_replace 精确匹配 ──
|
|
139
|
-
const matchCount = countOccurrences(cachedJson, oldStr);
|
|
140
|
-
if (matchCount === 0) {
|
|
141
|
-
throw new Error(`old_str not found in the serialized JSON of rem ${remId}`);
|
|
142
|
-
}
|
|
143
|
-
if (matchCount > 1) {
|
|
144
|
-
throw new Error(`old_str matches ${matchCount} locations in rem ${remId}. ` +
|
|
145
|
-
`Make old_str more specific to match exactly once.`);
|
|
146
|
-
}
|
|
147
|
-
const modifiedJson = cachedJson.replace(oldStr, newStr);
|
|
148
|
-
// 1. JSON 解析
|
|
149
|
-
let modified;
|
|
150
|
-
try {
|
|
151
|
-
modified = JSON.parse(modifiedJson);
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
|
|
155
|
-
}
|
|
156
|
-
// 2. 推导变更字段
|
|
157
|
-
const changes = {};
|
|
158
|
-
const warnings = [];
|
|
159
|
-
for (const key of Object.keys(modified)) {
|
|
160
|
-
if (JSON.stringify(modified[key]) !== JSON.stringify(original[key])) {
|
|
161
|
-
if (READ_ONLY_FIELDS.has(key)) {
|
|
162
|
-
warnings.push(`Field '${key}' is read-only and was ignored`);
|
|
163
|
-
}
|
|
164
|
-
else {
|
|
165
|
-
changes[key] = modified[key];
|
|
83
|
+
// ── 枚举值范围校验 ──
|
|
84
|
+
for (const [field, allowedValues] of Object.entries(ENUM_VALUES)) {
|
|
85
|
+
if (field in writableChanges) {
|
|
86
|
+
const value = writableChanges[field];
|
|
87
|
+
if (!allowedValues.has(value)) {
|
|
88
|
+
throw new Error(`Invalid value for '${field}': ${JSON.stringify(value)}. Allowed: ${[...allowedValues].map(v => JSON.stringify(v)).join(', ')}`);
|
|
166
89
|
}
|
|
167
90
|
}
|
|
168
91
|
}
|
|
169
|
-
//
|
|
170
|
-
if ('todoStatus' in
|
|
171
|
-
const isTodo =
|
|
92
|
+
// ── 语义校验:todoStatus 非 null 但 isTodo 未启用 ──
|
|
93
|
+
if ('todoStatus' in writableChanges && writableChanges.todoStatus !== null) {
|
|
94
|
+
const isTodo = writableChanges.isTodo ?? cachedObj.isTodo;
|
|
172
95
|
if (!isTodo) {
|
|
173
96
|
warnings.push("Setting 'todoStatus' without 'isTodo: true' may have no effect");
|
|
174
97
|
}
|
|
175
98
|
}
|
|
176
|
-
//
|
|
177
|
-
if (Object.keys(
|
|
99
|
+
// ── 空变更检查 ──
|
|
100
|
+
if (Object.keys(writableChanges).length === 0) {
|
|
178
101
|
return { ok: true, changes: [], warnings };
|
|
179
102
|
}
|
|
180
103
|
// ── 发送变更到 Plugin ──
|
|
181
104
|
const writeResult = (await this.forwardToPlugin('write_rem_fields', {
|
|
182
105
|
remId,
|
|
183
|
-
changes,
|
|
106
|
+
changes: writableChanges,
|
|
184
107
|
}));
|
|
185
108
|
if (writeResult.failed) {
|
|
186
109
|
// 部分失败 — 不更新缓存(迫使 AI re-read)
|
|
@@ -193,27 +116,13 @@ export class EditHandler {
|
|
|
193
116
|
failedField: writeResult.failed.field,
|
|
194
117
|
};
|
|
195
118
|
}
|
|
196
|
-
// ── 写入成功 → 从 Plugin 重新获取完整 Rem
|
|
119
|
+
// ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存 ──
|
|
197
120
|
const freshRemObject = await this.forwardToPlugin('read_rem', { remId });
|
|
198
|
-
|
|
199
|
-
this.cache.set('rem:' + remId, freshJson);
|
|
121
|
+
this.cache.set('rem:' + remId, freshRemObject);
|
|
200
122
|
return {
|
|
201
123
|
ok: true,
|
|
202
|
-
changes: Object.keys(
|
|
124
|
+
changes: Object.keys(writableChanges),
|
|
203
125
|
warnings,
|
|
204
126
|
};
|
|
205
127
|
}
|
|
206
128
|
}
|
|
207
|
-
/** 统计 needle 在 haystack 中出现的次数 */
|
|
208
|
-
function countOccurrences(haystack, needle) {
|
|
209
|
-
let count = 0;
|
|
210
|
-
let pos = 0;
|
|
211
|
-
while (true) {
|
|
212
|
-
pos = haystack.indexOf(needle, pos);
|
|
213
|
-
if (pos === -1)
|
|
214
|
-
break;
|
|
215
|
-
count++;
|
|
216
|
-
pos += needle.length; // 非重叠匹配,与 String.replace 行为一致
|
|
217
|
-
}
|
|
218
|
-
return count;
|
|
219
|
-
}
|
|
@@ -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;
|
|
@@ -44,48 +27,13 @@ export class ReadHandler {
|
|
|
44
27
|
const includePowerup = payload.includePowerup ?? false;
|
|
45
28
|
// 转发到 Plugin
|
|
46
29
|
const remObject = await this.forwardToPlugin('read_rem', { remId, includePowerup });
|
|
47
|
-
// 缓存完整
|
|
48
|
-
|
|
49
|
-
this.
|
|
50
|
-
this.onLog?.(`缓存 Rem ${remId.slice(0, 8)}... (${fullJson.length} bytes)`, 'info');
|
|
30
|
+
// 缓存完整 RemObject 对象
|
|
31
|
+
this.cache.set(cacheKey, remObject);
|
|
32
|
+
this.onLog?.(`缓存 Rem ${remId.slice(0, 8)}...`, 'info');
|
|
51
33
|
// 字段过滤
|
|
52
34
|
const fields = payload.fields;
|
|
53
35
|
const full = payload.full;
|
|
54
|
-
let result;
|
|
55
|
-
if (full) {
|
|
56
|
-
// --full → 返回完整对象(含 R-F 字段)
|
|
57
|
-
result = remObject;
|
|
58
|
-
}
|
|
59
|
-
else if (fields) {
|
|
60
|
-
// --fields 过滤:只返回指定字段 + id
|
|
61
|
-
const obj = remObject;
|
|
62
|
-
result = { id: obj.id };
|
|
63
|
-
for (const field of fields) {
|
|
64
|
-
if (field in obj) {
|
|
65
|
-
result[field] = obj[field];
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
else if (remObject.type === 'portal') {
|
|
70
|
-
// Portal 简化模式:只输出 8 个关键字段
|
|
71
|
-
const obj = remObject;
|
|
72
|
-
result = {};
|
|
73
|
-
for (const field of PORTAL_FIELDS) {
|
|
74
|
-
if (field in obj) {
|
|
75
|
-
result[field] = obj[field];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
// 默认模式:排除 R-F 字段
|
|
81
|
-
const obj = remObject;
|
|
82
|
-
result = {};
|
|
83
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
84
|
-
if (!RF_FIELDS.has(key)) {
|
|
85
|
-
result[key] = value;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
36
|
+
let result = filterRemFields(remObject, { full, fields });
|
|
89
37
|
// 附加缓存覆盖提示
|
|
90
38
|
if (previousCachedAt) {
|
|
91
39
|
result._cacheOverridden = { id: remId, previousCachedAt };
|