remnote-bridge 0.1.0

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.
Files changed (135) hide show
  1. package/dist/cli/commands/connect.d.ts +12 -0
  2. package/dist/cli/commands/connect.js +124 -0
  3. package/dist/cli/commands/disconnect.d.ts +11 -0
  4. package/dist/cli/commands/disconnect.js +100 -0
  5. package/dist/cli/commands/edit-rem.d.ts +13 -0
  6. package/dist/cli/commands/edit-rem.js +83 -0
  7. package/dist/cli/commands/edit-tree.d.ts +14 -0
  8. package/dist/cli/commands/edit-tree.js +67 -0
  9. package/dist/cli/commands/health.d.ts +12 -0
  10. package/dist/cli/commands/health.js +100 -0
  11. package/dist/cli/commands/install-skill.d.ts +6 -0
  12. package/dist/cli/commands/install-skill.js +39 -0
  13. package/dist/cli/commands/read-context.d.ts +20 -0
  14. package/dist/cli/commands/read-context.js +77 -0
  15. package/dist/cli/commands/read-globe.d.ts +16 -0
  16. package/dist/cli/commands/read-globe.js +60 -0
  17. package/dist/cli/commands/read-rem.d.ts +16 -0
  18. package/dist/cli/commands/read-rem.js +80 -0
  19. package/dist/cli/commands/read-tree.d.ts +17 -0
  20. package/dist/cli/commands/read-tree.js +85 -0
  21. package/dist/cli/commands/search.d.ts +12 -0
  22. package/dist/cli/commands/search.js +65 -0
  23. package/dist/cli/config.d.ts +55 -0
  24. package/dist/cli/config.js +139 -0
  25. package/dist/cli/daemon/daemon.d.ts +11 -0
  26. package/dist/cli/daemon/daemon.js +186 -0
  27. package/dist/cli/daemon/dev-server.d.ts +26 -0
  28. package/dist/cli/daemon/dev-server.js +81 -0
  29. package/dist/cli/daemon/pid.d.ts +34 -0
  30. package/dist/cli/daemon/pid.js +67 -0
  31. package/dist/cli/daemon/send-request.d.ts +24 -0
  32. package/dist/cli/daemon/send-request.js +92 -0
  33. package/dist/cli/handlers/context-read-handler.d.ts +18 -0
  34. package/dist/cli/handlers/context-read-handler.js +24 -0
  35. package/dist/cli/handlers/edit-handler.d.ts +30 -0
  36. package/dist/cli/handlers/edit-handler.js +133 -0
  37. package/dist/cli/handlers/globe-read-handler.d.ts +17 -0
  38. package/dist/cli/handlers/globe-read-handler.js +23 -0
  39. package/dist/cli/handlers/read-handler.d.ts +16 -0
  40. package/dist/cli/handlers/read-handler.js +78 -0
  41. package/dist/cli/handlers/rem-cache.d.ts +19 -0
  42. package/dist/cli/handlers/rem-cache.js +63 -0
  43. package/dist/cli/handlers/tree-edit-handler.d.ts +30 -0
  44. package/dist/cli/handlers/tree-edit-handler.js +188 -0
  45. package/dist/cli/handlers/tree-parser.d.ts +95 -0
  46. package/dist/cli/handlers/tree-parser.js +506 -0
  47. package/dist/cli/handlers/tree-read-handler.d.ts +28 -0
  48. package/dist/cli/handlers/tree-read-handler.js +53 -0
  49. package/dist/cli/main.d.ts +7 -0
  50. package/dist/cli/main.js +300 -0
  51. package/dist/cli/protocol.d.ts +39 -0
  52. package/dist/cli/protocol.js +35 -0
  53. package/dist/cli/server/config-server.d.ts +26 -0
  54. package/dist/cli/server/config-server.js +363 -0
  55. package/dist/cli/server/ws-server.d.ts +68 -0
  56. package/dist/cli/server/ws-server.js +335 -0
  57. package/dist/cli/utils/output.d.ts +11 -0
  58. package/dist/cli/utils/output.js +13 -0
  59. package/dist/mcp/daemon-client.d.ts +31 -0
  60. package/dist/mcp/daemon-client.js +99 -0
  61. package/dist/mcp/index.d.ts +7 -0
  62. package/dist/mcp/index.js +68 -0
  63. package/dist/mcp/instructions.d.ts +1 -0
  64. package/dist/mcp/instructions.js +249 -0
  65. package/dist/mcp/resources/edit-tree-guide.d.ts +1 -0
  66. package/dist/mcp/resources/edit-tree-guide.js +197 -0
  67. package/dist/mcp/resources/error-reference.d.ts +1 -0
  68. package/dist/mcp/resources/error-reference.js +132 -0
  69. package/dist/mcp/resources/outline-format.d.ts +1 -0
  70. package/dist/mcp/resources/outline-format.js +104 -0
  71. package/dist/mcp/resources/rem-object-fields.d.ts +1 -0
  72. package/dist/mcp/resources/rem-object-fields.js +331 -0
  73. package/dist/mcp/resources/separator-flashcard.d.ts +1 -0
  74. package/dist/mcp/resources/separator-flashcard.js +120 -0
  75. package/dist/mcp/tools/edit-tools.d.ts +5 -0
  76. package/dist/mcp/tools/edit-tools.js +47 -0
  77. package/dist/mcp/tools/infra-tools.d.ts +5 -0
  78. package/dist/mcp/tools/infra-tools.js +43 -0
  79. package/dist/mcp/tools/read-tools.d.ts +5 -0
  80. package/dist/mcp/tools/read-tools.js +195 -0
  81. package/dist/mcp/types.d.ts +12 -0
  82. package/dist/mcp/types.js +4 -0
  83. package/docs/instruction/connect.md +158 -0
  84. package/docs/instruction/disconnect.md +146 -0
  85. package/docs/instruction/edit-rem.md +509 -0
  86. package/docs/instruction/edit-tree.md +419 -0
  87. package/docs/instruction/health.md +159 -0
  88. package/docs/instruction/overall.md +751 -0
  89. package/docs/instruction/read-context.md +353 -0
  90. package/docs/instruction/read-globe.md +206 -0
  91. package/docs/instruction/read-rem.md +476 -0
  92. package/docs/instruction/read-tree.md +428 -0
  93. package/docs/instruction/search.md +196 -0
  94. package/package.json +41 -0
  95. package/remnote-plugin/package.json +48 -0
  96. package/remnote-plugin/postcss.config.js +5 -0
  97. package/remnote-plugin/public/bridge-icon.svg +8 -0
  98. package/remnote-plugin/public/manifest.json +22 -0
  99. package/remnote-plugin/src/bridge/message-router.ts +57 -0
  100. package/remnote-plugin/src/bridge/websocket-client.ts +245 -0
  101. package/remnote-plugin/src/index.css +1 -0
  102. package/remnote-plugin/src/services/breadcrumb.ts +26 -0
  103. package/remnote-plugin/src/services/create-rem.ts +59 -0
  104. package/remnote-plugin/src/services/delete-rem.ts +29 -0
  105. package/remnote-plugin/src/services/index.ts +16 -0
  106. package/remnote-plugin/src/services/move-rem.ts +39 -0
  107. package/remnote-plugin/src/services/powerup-filter.ts +31 -0
  108. package/remnote-plugin/src/services/read-context.ts +368 -0
  109. package/remnote-plugin/src/services/read-globe.ts +197 -0
  110. package/remnote-plugin/src/services/read-rem.ts +284 -0
  111. package/remnote-plugin/src/services/read-tree.ts +222 -0
  112. package/remnote-plugin/src/services/rem-builder.ts +124 -0
  113. package/remnote-plugin/src/services/reorder-children.ts +61 -0
  114. package/remnote-plugin/src/services/search.ts +56 -0
  115. package/remnote-plugin/src/services/write-rem-fields.ts +254 -0
  116. package/remnote-plugin/src/settings.ts +12 -0
  117. package/remnote-plugin/src/style.css +45 -0
  118. package/remnote-plugin/src/test-scripts/AGENTS.md +46 -0
  119. package/remnote-plugin/src/test-scripts/test-actions.ts +230 -0
  120. package/remnote-plugin/src/test-scripts/test-powerup-rendering.ts +722 -0
  121. package/remnote-plugin/src/test-scripts/test-rem-type-mapping.ts +283 -0
  122. package/remnote-plugin/src/test-scripts/test-richtext-builder.ts +207 -0
  123. package/remnote-plugin/src/test-scripts/test-richtext-matrix.ts +332 -0
  124. package/remnote-plugin/src/test-scripts/test-richtext-remaining.ts +245 -0
  125. package/remnote-plugin/src/test-scripts/test-rw-fields.ts +399 -0
  126. package/remnote-plugin/src/types.ts +419 -0
  127. package/remnote-plugin/src/utils/elision.ts +45 -0
  128. package/remnote-plugin/src/utils/index.ts +10 -0
  129. package/remnote-plugin/src/utils/tree-serializer.ts +269 -0
  130. package/remnote-plugin/src/widgets/bridge_widget.tsx +170 -0
  131. package/remnote-plugin/src/widgets/index.tsx +82 -0
  132. package/remnote-plugin/tailwind.config.js +7 -0
  133. package/remnote-plugin/tsconfig.json +21 -0
  134. package/remnote-plugin/webpack.config.js +125 -0
  135. package/skill/SKILL.md +428 -0
@@ -0,0 +1,509 @@
1
+ # edit-rem
2
+
3
+ > 通过 str_replace 语义修改单个 Rem 的属性。三道防线确保编辑安全。
4
+
5
+ ---
6
+
7
+ ## 功能
8
+
9
+ `edit-rem` 使用 str_replace 语义修改 Rem 属性——在 Rem 的序列化 JSON 文本中,将 `oldStr` 替换为 `newStr`,然后推导出变更字段并写入 SDK。
10
+
11
+ 核心特性:
12
+ - **str_replace 语义**:操作对象是 `JSON.stringify(remObject, null, 2)` 的文本
13
+ - **三道防线**:缓存存在性 → 乐观并发检测 → 精确匹配校验
14
+ - **前置条件**:必须先 `read-rem` 建立缓存,否则防线 1 拒绝
15
+
16
+ ---
17
+
18
+ ## 前置条件
19
+
20
+ `edit-rem` 依赖 `read-rem` 的缓存。工作流程为:
21
+
22
+ ```
23
+ 1. read-rem <remId> → 读取 Rem,缓存到 daemon 内存
24
+ 2. edit-rem <remId> ... → 基于缓存执行编辑
25
+ ```
26
+
27
+ 跳过 `read-rem` 直接调用 `edit-rem` 会触发防线 1 错误。
28
+
29
+ ---
30
+
31
+ ## 用法
32
+
33
+ ### 人类模式
34
+
35
+ ```bash
36
+ remnote-bridge edit-rem <remId> --old-str <oldStr> --new-str <newStr>
37
+ ```
38
+
39
+ | 参数/选项 | 类型 | 必需 | 说明 |
40
+ |-----------|------|:----:|------|
41
+ | `remId` | string(位置参数) | 是 | Rem ID |
42
+ | `--old-str <oldStr>` | string | 是 | 要替换的原始文本片段 |
43
+ | `--new-str <newStr>` | string | 是 | 替换后的新文本片段 |
44
+
45
+ 输出示例(成功):
46
+
47
+ ```
48
+ 已更新字段: text, fontSize
49
+ ```
50
+
51
+ 输出示例(无变更):
52
+
53
+ ```
54
+ 无变更(old_str 和 new_str 产生相同结果)
55
+ ```
56
+
57
+ ### JSON 模式
58
+
59
+ ```bash
60
+ remnote-bridge edit-rem --json '{"remId":"kLrIOHJLyMd8Y2lyA","oldStr":"\"concept\"","newStr":"\"descriptor\""}'
61
+ ```
62
+
63
+ ---
64
+
65
+ ## JSON 输入参数
66
+
67
+ | 字段 | 类型 | 必需 | 说明 |
68
+ |------|------|:----:|------|
69
+ | `remId` | string | 是 | Rem ID |
70
+ | `oldStr` | string | 是 | 要替换的原始文本片段(在序列化 JSON 中精确匹配) |
71
+ | `newStr` | string | 是 | 替换后的新文本片段 |
72
+
73
+ ---
74
+
75
+ ## JSON 输出
76
+
77
+ ### 成功(有变更)
78
+
79
+ ```json
80
+ {
81
+ "ok": true,
82
+ "command": "edit-rem",
83
+ "changes": ["text", "fontSize"],
84
+ "warnings": [],
85
+ "timestamp": "2026-03-06T10:00:00.000Z"
86
+ }
87
+ ```
88
+
89
+ ### 成功(无变更)
90
+
91
+ ```json
92
+ {
93
+ "ok": true,
94
+ "command": "edit-rem",
95
+ "changes": [],
96
+ "warnings": [],
97
+ "timestamp": "2026-03-06T10:00:00.000Z"
98
+ }
99
+ ```
100
+
101
+ ### 防线 1 拒绝:未先 read
102
+
103
+ ```json
104
+ {
105
+ "ok": false,
106
+ "command": "edit-rem",
107
+ "error": "Rem kLrIOHJLyMd8Y2lyA has not been read yet. Read it first before editing.",
108
+ "timestamp": "2026-03-06T10:00:00.000Z"
109
+ }
110
+ ```
111
+
112
+ ### 防线 2 拒绝:并发修改
113
+
114
+ ```json
115
+ {
116
+ "ok": false,
117
+ "command": "edit-rem",
118
+ "error": "Rem kLrIOHJLyMd8Y2lyA has been modified since last read. Please read it again before editing.",
119
+ "timestamp": "2026-03-06T10:00:00.000Z"
120
+ }
121
+ ```
122
+
123
+ ### 防线 3 拒绝:old_str 未找到
124
+
125
+ ```json
126
+ {
127
+ "ok": false,
128
+ "command": "edit-rem",
129
+ "error": "old_str not found in the serialized JSON of rem kLrIOHJLyMd8Y2lyA",
130
+ "timestamp": "2026-03-06T10:00:00.000Z"
131
+ }
132
+ ```
133
+
134
+ ### 防线 3 拒绝:old_str 多次匹配
135
+
136
+ ```json
137
+ {
138
+ "ok": false,
139
+ "command": "edit-rem",
140
+ "error": "old_str matches 3 locations in rem kLrIOHJLyMd8Y2lyA. Make old_str more specific to match exactly once.",
141
+ "timestamp": "2026-03-06T10:00:00.000Z"
142
+ }
143
+ ```
144
+
145
+ ### 后处理:替换产生非法 JSON
146
+
147
+ ```json
148
+ {
149
+ "ok": false,
150
+ "command": "edit-rem",
151
+ "error": "The replacement produced invalid JSON. Check your new_str for syntax errors.",
152
+ "timestamp": "2026-03-06T10:00:00.000Z"
153
+ }
154
+ ```
155
+
156
+ ### 部分写入失败
157
+
158
+ ```json
159
+ {
160
+ "ok": false,
161
+ "command": "edit-rem",
162
+ "changes": [],
163
+ "warnings": [],
164
+ "error": "Failed to update field 'type': Portal 不可通过 setType() 设置,只能通过 createPortal() 创建",
165
+ "appliedChanges": ["text"],
166
+ "failedField": "type",
167
+ "timestamp": "2026-03-06T10:00:00.000Z"
168
+ }
169
+ ```
170
+
171
+ ### 含只读字段警告
172
+
173
+ ```json
174
+ {
175
+ "ok": true,
176
+ "command": "edit-rem",
177
+ "changes": ["text"],
178
+ "warnings": ["Field 'children' is read-only and was ignored"],
179
+ "timestamp": "2026-03-06T10:00:00.000Z"
180
+ }
181
+ ```
182
+
183
+ ---
184
+
185
+ ## 内部流程
186
+
187
+ ```
188
+ 1. CLI 解析参数(remId, oldStr, newStr)
189
+ 2. sendRequest → WS → daemon
190
+ 3. daemon EditHandler:
191
+
192
+ ├─ 防线 1: 缓存存在性检查
193
+ │ └─ cache.get('rem:' + remId) 为 null → 抛出错误
194
+
195
+ ├─ 防线 2: 乐观并发检测
196
+ │ ├─ forwardToPlugin('read_rem', { remId }) → 获取当前 RemObject
197
+ │ ├─ JSON.stringify(currentRemObject, null, 2)
198
+ │ ├─ 与缓存 JSON 严格比较
199
+ │ └─ 不匹配 → 抛出错误(不更新缓存,迫使 AI re-read)
200
+
201
+ ├─ 防线 3: str_replace 精确匹配
202
+ │ ├─ countOccurrences(cachedJson, oldStr)
203
+ │ ├─ 0 次 → 抛出"未找到"错误
204
+ │ ├─ >1 次 → 抛出"多次匹配"错误
205
+ │ └─ 恰好 1 次 → cachedJson.replace(oldStr, newStr)
206
+
207
+ ├─ 后处理校验
208
+ │ ├─ JSON.parse(modifiedJson) → 失败则抛"非法 JSON"错误
209
+ │ ├─ 逐字段对比 original vs modified
210
+ │ ├─ READ_ONLY_FIELDS → 产生警告,不执行写入
211
+ │ ├─ 语义校验:todoStatus 非 null 但 isTodo=false → 警告
212
+ │ └─ 无变更 → 返回 ok=true, changes=[]
213
+
214
+ ├─ 发送到 Plugin
215
+ │ ├─ forwardToPlugin('write_rem_fields', { remId, changes })
216
+ │ ├─ Plugin 逐字段调用 SDK setter
217
+ │ └─ 首个失败即终止 → 返回 applied + failed
218
+
219
+ └─ 缓存更新
220
+ ├─ 写入成功 → 从 Plugin re-read 最新状态 → 更新缓存
221
+ └─ 写入失败 → 不更新缓存(迫使 AI re-read)
222
+ ```
223
+
224
+ ---
225
+
226
+ ## 三道防线详解
227
+
228
+ ### 防线 1:缓存存在性检查
229
+
230
+ **目的**:强制 AI 先 `read-rem` 再 `edit-rem`,建立编辑上下文。
231
+
232
+ ```
233
+ if cache.get('rem:' + remId) === null:
234
+ throw "Rem {remId} has not been read yet. Read it first before editing."
235
+ ```
236
+
237
+ **触发条件**:
238
+ - 从未对该 remId 执行过 `read-rem`
239
+ - daemon 重启后缓存已清空
240
+
241
+ **恢复方式**:执行 `read-rem <remId>` 后重试。
242
+
243
+ ### 防线 2:乐观并发检测
244
+
245
+ **目的**:检测自上次 read 以来,Rem 是否被外部修改(如用户在 RemNote UI 中编辑、其他 Agent 修改等)。
246
+
247
+ ```
248
+ currentRemObject = forwardToPlugin('read_rem', { remId })
249
+ currentJson = JSON.stringify(currentRemObject, null, 2)
250
+
251
+ if currentJson !== cachedJson:
252
+ // 不更新缓存 — 迫使 AI 重新 read 获取最新状态
253
+ throw "Rem {remId} has been modified since last read. Please read it again before editing."
254
+ ```
255
+
256
+ **关键设计**:
257
+ - 比较方式:**整个 JSON 文本严格二进制比较**(包括格式化空白)
258
+ - 失败时**不更新缓存**:防止 AI 跳过 re-read 直接重试
259
+ - RichText key 排序保证序列化确定性(`sortRichTextKeys()`)
260
+
261
+ **恢复方式**:执行 `read-rem <remId>` 获取最新状态后重试。
262
+
263
+ ### 防线 3:str_replace 精确匹配
264
+
265
+ **目的**:确保替换精确定位到唯一位置,避免意外修改。
266
+
267
+ ```
268
+ function countOccurrences(haystack, needle):
269
+ count = 0, pos = 0
270
+ while true:
271
+ pos = haystack.indexOf(needle, pos)
272
+ if pos === -1: break
273
+ count++
274
+ pos += needle.length // 非重叠匹配
275
+ return count
276
+
277
+ matchCount = countOccurrences(cachedJson, oldStr)
278
+
279
+ switch matchCount:
280
+ case 0: throw "old_str not found in the serialized JSON of rem {remId}"
281
+ case 1: modifiedJson = cachedJson.replace(oldStr, newStr) // 执行替换
282
+ default: throw "old_str matches {matchCount} locations in rem {remId}. Make old_str more specific to match exactly once."
283
+ ```
284
+
285
+ **后处理校验**:
286
+
287
+ ```
288
+ // 1. JSON 语法检查
289
+ modified = JSON.parse(modifiedJson)
290
+ // 失败 → throw "The replacement produced invalid JSON. Check your new_str for syntax errors."
291
+
292
+ // 2. 推导变更字段
293
+ for key in modified:
294
+ if modified[key] !== original[key]:
295
+ if key in READ_ONLY_FIELDS:
296
+ warnings.push("Field '{key}' is read-only and was ignored")
297
+ else:
298
+ changes[key] = modified[key]
299
+
300
+ // 3. 语义校验
301
+ if 'todoStatus' in changes && todoStatus !== null && !isTodo:
302
+ warnings.push("Setting 'todoStatus' without 'isTodo: true' may have no effect")
303
+
304
+ // 4. 空变更
305
+ if changes is empty: return { ok: true, changes: [], warnings }
306
+ ```
307
+
308
+ ### 三道防线判断树
309
+
310
+ ```
311
+ edit-rem(remId, oldStr, newStr)
312
+
313
+ ├─ 防线 1: 缓存存在?
314
+ │ ├─ 否 → ERROR: "has not been read yet"
315
+ │ └─ 是 → 继续
316
+
317
+ ├─ 防线 2: 当前值 === 缓存值?
318
+ │ ├─ 否 → ERROR: "has been modified since last read"
319
+ │ └─ 是 → 继续
320
+
321
+ ├─ 防线 3: old_str 匹配次数?
322
+ │ ├─ 0 次 → ERROR: "old_str not found"
323
+ │ ├─ >1 次 → ERROR: "old_str matches N locations"
324
+ │ └─ 1 次 → 执行替换
325
+
326
+ ├─ 后处理: JSON 合法?
327
+ │ ├─ 否 → ERROR: "invalid JSON"
328
+ │ └─ 是 → 推导变更字段
329
+
330
+ ├─ 有变更?
331
+ │ ├─ 否 → OK: changes=[]
332
+ │ └─ 是 → 发送到 Plugin
333
+
334
+ └─ Plugin 写入结果?
335
+ ├─ 全部成功 → 更新缓存 → OK: changes=[...]
336
+ └─ 部分失败 → 不更新缓存 → ERROR: failed field info
337
+ ```
338
+
339
+ ---
340
+
341
+ ## 可编辑字段约束表
342
+
343
+ RemObject 51 个字段中,20 个可编辑(RW),31 个只读(R + R-F)。
344
+
345
+ 以下为 20 个可编辑字段及其写入约束:
346
+
347
+ | 字段 | SDK setter | 值类型 | 约束 / 特殊处理 |
348
+ |------|-----------|--------|-----------------|
349
+ | `text` | `rem.setText()` | RichText | RichText 数组 |
350
+ | `backText` | `rem.setBackText()` | RichText \| null | null → `setBackText([])`(清除背面);字符串 → 包装为 `[string]` |
351
+ | `type` | `rem.setType()` | RemTypeValue | `portal` 不可设置(只能通过 `createPortal()` 创建) |
352
+ | `isDocument` | `rem.setIsDocument()` | boolean | — |
353
+ | `parent` | `rem.setParent(parentId, position?)` | string \| null | 与 `positionAmongstSiblings` 联动(见下方说明) |
354
+ | `fontSize` | `rem.setFontSize()` | FontSize \| null | null → `setFontSize(undefined)`(恢复普通大小) |
355
+ | `highlightColor` | `rem.setHighlightColor()` / `rem.removePowerup('h')` | HighlightColor \| null | null → `removePowerup('h')`(SDK 不接受 null) |
356
+ | `isTodo` | `rem.setIsTodo()` | boolean | 设为 true 时自动初始化 todoStatus |
357
+ | `todoStatus` | `rem.setTodoStatus()` | TodoStatus \| null | null → 跳过(清除 todo 应通过 `isTodo=false`) |
358
+ | `isCode` | `rem.setIsCode()` | boolean | — |
359
+ | `isQuote` | `rem.setIsQuote()` | boolean | — |
360
+ | `isListItem` | `rem.setIsListItem()` | boolean | — |
361
+ | `isCardItem` | `rem.setIsCardItem()` | boolean | — |
362
+ | `isSlot` | `rem.setIsSlot()` | boolean | 与 `isProperty` 底层相同 |
363
+ | `isProperty` | `rem.setIsProperty()` | boolean | 与 `isSlot` 底层相同 |
364
+ | `enablePractice` | `rem.setEnablePractice()` | boolean | — |
365
+ | `practiceDirection` | `rem.setPracticeDirection()` | PracticeDirection | `forward` / `backward` / `both` / `none` |
366
+ | `tags` | `rem.addTag()` / `rem.removeTag()` | string[] | **Diff 机制**:对比当前 vs 目标,增删差异项 |
367
+ | `sources` | `rem.addSource()` / `rem.removeSource()` | string[] | **Diff 机制**:对比当前 vs 目标,增删差异项 |
368
+ | `positionAmongstSiblings` | `rem.setParent(parent, position)` | number \| null | 与 `parent` 联动(见下方说明) |
369
+
370
+ ### parent + positionAmongstSiblings 联动
371
+
372
+ 这两个字段通过同一个 SDK 调用 `rem.setParent(parentId, position)` 写入:
373
+
374
+ | 场景 | 行为 |
375
+ |------|------|
376
+ | 两个字段都变更 | 合并为一次 `setParent(newParent, newPosition)` 调用 |
377
+ | 只有 `parent` 变更 | `setParent(newParent)` 不带 position(保持末尾) |
378
+ | 只有 `positionAmongstSiblings` 变更 | 获取当前 parent → `setParent(currentParent, newPosition)` |
379
+
380
+ ### tags / sources Diff 机制
381
+
382
+ 写入 `tags` 或 `sources` 时,不是整体替换,而是计算差异:
383
+
384
+ ```
385
+ currentTags = await rem.getTagRems()
386
+ targetIds = payload 中的 tags 字段
387
+ currentSet = Set(currentTags.map(r => r._id))
388
+ targetSet = Set(targetIds)
389
+
390
+ // 增加缺少的
391
+ for id in targetIds:
392
+ if id not in currentSet: await rem.addTag(id)
393
+
394
+ // 删除多余的
395
+ for id in currentSet:
396
+ if id not in targetSet: await rem.removeTag(id)
397
+ ```
398
+
399
+ ---
400
+
401
+ ## 只读字段列表
402
+
403
+ 以下 31 个字段在 str_replace 中被修改时,**只产生警告,不执行写入**:
404
+
405
+ ```
406
+ id,
407
+ children,
408
+ isTable,
409
+ portalType, portalDirectlyIncludedRem,
410
+ propertyType,
411
+ aliases,
412
+ remsBeingReferenced, deepRemsBeingReferenced, remsReferencingThis,
413
+ taggedRem, ancestorTagRem, descendantTagRem,
414
+ descendants, siblingRem,
415
+ portalsAndDocumentsIn, allRemInDocumentOrPortal, allRemInFolderQueue,
416
+ timesSelectedInSearch, lastTimeMovedTo, schemaVersion,
417
+ embeddedQueueViewMode,
418
+ createdAt, updatedAt, localUpdatedAt, lastPracticed,
419
+ isPowerup, isPowerupEnum, isPowerupProperty,
420
+ isPowerupPropertyListItem, isPowerupSlot
421
+ ```
422
+
423
+ 警告格式:`"Field '{fieldName}' is read-only and was ignored"`
424
+
425
+ ---
426
+
427
+ ## str_replace 语义详解
428
+
429
+ ### 操作对象
430
+
431
+ str_replace 操作的对象是 `JSON.stringify(remObject, null, 2)` 的文本——格式化缩进 2 空格的 JSON。
432
+
433
+ 示例(部分):
434
+
435
+ ```json
436
+ {
437
+ "id": "kLrIOHJLyMd8Y2lyA",
438
+ "text": [
439
+ "Hello World"
440
+ ],
441
+ "type": "concept",
442
+ "fontSize": null,
443
+ "isTodo": false
444
+ }
445
+ ```
446
+
447
+ ### 匹配规则
448
+
449
+ - **非重叠匹配**:与 `String.prototype.replace()` 行为一致
450
+ - **必须恰好匹配一次**:0 次=未找到错误,>1 次=多匹配错误
451
+ - **大小写敏感**:精确匹配,无模糊匹配
452
+
453
+ ### 使用技巧
454
+
455
+ 1. **包含足够上下文**:oldStr 应包含字段名和前后结构,避免模糊匹配
456
+
457
+ ```
458
+ 正确: "\"type\": \"concept\"" → 匹配字段名+值
459
+ 错误: "concept" → 可能匹配到 text 内容中的 "concept"
460
+ ```
461
+
462
+ 2. **替换后必须是合法 JSON**:检查引号、逗号、括号的完整性
463
+
464
+ 3. **修改 RichText 字段**:直接操作 JSON 数组结构
465
+
466
+ ```
467
+ oldStr: "\"text\": [\n \"Hello\"\n ]"
468
+ newStr: "\"text\": [\n \"World\"\n ]"
469
+ ```
470
+
471
+ ---
472
+
473
+ ## 缓存更新行为
474
+
475
+ | 场景 | 缓存行为 | 原因 |
476
+ |------|----------|------|
477
+ | 写入成功 | 从 Plugin re-read 最新状态 → 覆盖缓存 | 确保缓存与 SDK 状态同步 |
478
+ | 防线 1 拒绝 | 无缓存,不操作 | — |
479
+ | 防线 2 拒绝 | **不更新缓存** | 迫使 AI 执行 read-rem 获取最新状态 |
480
+ | 防线 3 拒绝 | 缓存保持不变 | AI 可调整 oldStr 后重试 |
481
+ | JSON 语法错误 | 缓存保持不变 | AI 可调整 newStr 后重试 |
482
+ | 部分写入失败 | **不更新缓存** | 迫使 AI 执行 read-rem 重新评估状态 |
483
+
484
+ **关键设计**:写入成功后**永远从 Plugin 重新读取**最新状态,而非本地推导修改后的 JSON。这保证缓存与实际 SDK 状态完全同步。
485
+
486
+ ---
487
+
488
+ ## 退出码
489
+
490
+ | 退出码 | 含义 | 触发条件 |
491
+ |:------:|------|----------|
492
+ | 0 | 成功 | 编辑成功(含无变更场景) |
493
+ | 1 | 业务错误 | 防线拒绝、写入失败、参数缺失等 |
494
+ | 2 | daemon 不可达 | daemon 未运行或 WS 连接失败 |
495
+
496
+ ---
497
+
498
+ ## 常见错误诊断
499
+
500
+ | 错误消息 | 原因 | 解决方案 |
501
+ |----------|------|----------|
502
+ | `has not been read yet` | 未先执行 read-rem | 执行 `read-rem <remId>` 后重试 |
503
+ | `has been modified since last read` | Rem 在 read 和 edit 之间被外部修改 | 执行 `read-rem <remId>` 获取最新状态后重试 |
504
+ | `old_str not found` | oldStr 在序列化 JSON 中不存在 | 检查 oldStr 是否精确匹配(含引号、空格、换行) |
505
+ | `old_str matches N locations` | oldStr 匹配到多个位置 | 扩大 oldStr 范围,包含更多上下文以唯一定位 |
506
+ | `invalid JSON` | 替换后的文本不是合法 JSON | 检查 newStr 的引号、逗号、括号完整性 |
507
+ | `Failed to update field` | SDK setter 调用失败 | 检查字段值是否在允许范围内(如 type 不能设为 portal) |
508
+ | `Field '...' is read-only and was ignored` | 修改了只读字段 | 该字段只能读取,不可通过 edit-rem 修改 |
509
+ | `守护进程未运行` | daemon 未启动 | 执行 `remnote-bridge connect` |