remnote-bridge 0.1.13 → 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.
Files changed (37) hide show
  1. package/README.md +141 -28
  2. package/README.zh-CN.md +368 -0
  3. package/dist/cli/commands/health.js +231 -112
  4. package/dist/cli/commands/read-rem-in-tree.js +84 -0
  5. package/dist/cli/config.js +2 -0
  6. package/dist/cli/daemon/registry.js +8 -0
  7. package/dist/cli/handlers/patch-engine.js +347 -0
  8. package/dist/cli/handlers/read-handler.js +2 -53
  9. package/dist/cli/handlers/rem-field-filter.js +102 -0
  10. package/dist/cli/handlers/tree-edit-handler.js +67 -7
  11. package/dist/cli/handlers/tree-read-handler.js +4 -1
  12. package/dist/cli/handlers/tree-rem-read-handler.js +73 -0
  13. package/dist/cli/main.js +53 -2
  14. package/dist/cli/server/ws-server.js +9 -1
  15. package/dist/mcp/daemon-client.js +22 -2
  16. package/dist/mcp/instructions.js +54 -7
  17. package/dist/mcp/tools/edit-tools.js +7 -2
  18. package/dist/mcp/tools/infra-tools.js +20 -11
  19. package/dist/mcp/tools/read-tools.js +88 -2
  20. package/package.json +1 -1
  21. package/remnote-plugin/dist/index-sandbox.js +24 -24
  22. package/remnote-plugin/dist/index.js +24 -24
  23. package/remnote-plugin/src/bridge/message-router.ts +3 -0
  24. package/remnote-plugin/src/services/read-rem-in-tree.ts +43 -0
  25. package/remnote-plugin/src/services/read-rem.ts +15 -0
  26. package/remnote-plugin/src/services/read-tree.ts +5 -0
  27. package/skills/remnote-bridge/SKILL.md +37 -4
  28. package/skills/remnote-bridge/instructions/connect.md +12 -1
  29. package/skills/remnote-bridge/instructions/disconnect.md +5 -0
  30. package/skills/remnote-bridge/instructions/edit-tree.md +71 -2
  31. package/skills/remnote-bridge/instructions/health.md +81 -53
  32. package/skills/remnote-bridge/instructions/overall.md +33 -8
  33. package/skills/remnote-bridge/instructions/read-rem-in-tree.md +100 -0
  34. package/skills/remnote-bridge/instructions/read-rem.md +30 -11
  35. package/skills/remnote-bridge-test/SKILL.md +847 -0
  36. package/skills/remnote-bridge-test/references/regression-suite.md +960 -0
  37. package/skills/remnote-bridge-test/references/verification-guide.md +161 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * TreeRemReadHandler — read-rem-in-tree 请求的业务编排
3
+ *
4
+ * 职责:
5
+ * 1. 转发到 Plugin 获取 outline + remObjects
6
+ * 2. 缓存 tree:{remId} + 参数(供 edit_tree 使用)
7
+ * 3. 缓存 rem:{nodeRemId}(供 edit_rem 使用)
8
+ * 4. 对每个 RemObject 应用字段过滤
9
+ */
10
+ import { DEFAULT_DEFAULTS } from '../config.js';
11
+ import { filterRemFields } from './rem-field-filter.js';
12
+ export class TreeRemReadHandler {
13
+ cache;
14
+ forwardToPlugin;
15
+ onLog;
16
+ defaults;
17
+ constructor(cache, forwardToPlugin, onLog, defaults) {
18
+ this.cache = cache;
19
+ this.forwardToPlugin = forwardToPlugin;
20
+ this.onLog = onLog;
21
+ this.defaults = defaults ?? DEFAULT_DEFAULTS;
22
+ }
23
+ async handleReadRemInTree(payload) {
24
+ const remId = payload.remId;
25
+ if (!remId) {
26
+ throw new Error('缺少 remId 参数');
27
+ }
28
+ const depth = payload.depth ?? this.defaults.readTreeDepth;
29
+ const maxNodes = payload.maxNodes ?? this.defaults.readRemInTreeMaxNodes;
30
+ const maxSiblings = payload.maxSiblings ?? this.defaults.maxSiblings;
31
+ const ancestorLevels = payload.ancestorLevels ?? this.defaults.readTreeAncestorLevels;
32
+ const includePowerup = payload.includePowerup ?? this.defaults.readTreeIncludePowerup;
33
+ // 检查旧缓存
34
+ const treeCacheKey = 'tree:' + remId;
35
+ const previousTreeCachedAt = this.cache.getCreatedAt(treeCacheKey);
36
+ // 转发到 Plugin
37
+ const result = await this.forwardToPlugin('read_rem_in_tree', {
38
+ remId, depth, maxNodes, maxSiblings, ancestorLevels, includePowerup,
39
+ });
40
+ // 剥离内部字段 nodeRemIds(不暴露给 CLI 输出)
41
+ delete result.nodeRemIds;
42
+ // 缓存 tree outline + 参数(供 edit_tree 使用)
43
+ this.cache.set(treeCacheKey, result.outline);
44
+ this.cache.set('tree-depth:' + remId, String(depth));
45
+ this.cache.set('tree-maxNodes:' + remId, String(maxNodes));
46
+ this.cache.set('tree-maxSiblings:' + remId, String(maxSiblings));
47
+ this.onLog?.(`缓存树 ${remId.slice(0, 8)}... (${result.nodeCount} 节点, ${result.outline.length} bytes)`, 'info');
48
+ // 缓存每个 RemObject(供 edit_rem 使用)
49
+ const remObjects = result.remObjects ?? {};
50
+ let remCachedCount = 0;
51
+ for (const [nodeId, remObj] of Object.entries(remObjects)) {
52
+ this.cache.set('rem:' + nodeId, remObj);
53
+ remCachedCount++;
54
+ }
55
+ this.onLog?.(`缓存 ${remCachedCount} 个 RemObject`, 'info');
56
+ // 字段过滤
57
+ const fields = payload.fields;
58
+ const full = payload.full;
59
+ const filteredRemObjects = {};
60
+ for (const [nodeId, remObj] of Object.entries(remObjects)) {
61
+ filteredRemObjects[nodeId] = filterRemFields(remObj, { full, fields });
62
+ }
63
+ // 附加缓存覆盖提示
64
+ const finalResult = {
65
+ ...result,
66
+ remObjects: filteredRemObjects,
67
+ };
68
+ if (previousTreeCachedAt) {
69
+ finalResult._cacheOverridden = { id: remId, previousCachedAt: previousTreeCachedAt };
70
+ }
71
+ return finalResult;
72
+ }
73
+ }
package/dist/cli/main.js CHANGED
@@ -13,6 +13,7 @@ import { disconnectCommand } from './commands/disconnect.js';
13
13
  import { readRemCommand } from './commands/read-rem.js';
14
14
  import { editRemCommand } from './commands/edit-rem.js';
15
15
  import { readTreeCommand } from './commands/read-tree.js';
16
+ import { readRemInTreeCommand } from './commands/read-rem-in-tree.js';
16
17
  import { editTreeCommand } from './commands/edit-tree.js';
17
18
  import { readGlobeCommand } from './commands/read-globe.js';
18
19
  import { readContextCommand } from './commands/read-context.js';
@@ -61,13 +62,27 @@ program
61
62
  .description('RemNote Bridge — CLI + MCP Server + Plugin')
62
63
  .version(version)
63
64
  .option('--json', '以 JSON 格式输出(适用于程序化调用)')
64
- .option('--instance <name>', '指定 daemon 实例名(也可用 REMNOTE_BRIDGE_INSTANCE 环境变量)')
65
- .option('--headless', '使用 headless 实例(覆盖 --instance,也可用 REMNOTE_HEADLESS=1 环境变量)');
65
+ .option('--instance <name>', '指定 daemon 实例名("headless" 是保留名,不可使用)')
66
+ .option('--headless', '使用 headless 模式(固定实例名为 headless,也可用 REMNOTE_HEADLESS=1 环境变量)');
66
67
  // 全局参数同步到环境变量,使所有命令中的 resolveInstanceId() 自动生效
67
68
  program.hook('preAction', () => {
68
69
  const opts = program.opts();
69
70
  const headlessEnv = process.env.REMNOTE_HEADLESS;
70
71
  const isHeadless = opts.headless || headlessEnv === '1' || headlessEnv === 'true';
72
+ // 禁止 --instance headless,必须使用 --headless
73
+ if (!isHeadless && opts.instance === 'headless') {
74
+ const msg = '错误: --instance headless 不是合法用法。请使用 --headless 全局选项连接 headless 实例。\n'
75
+ + '用法: remnote-bridge --headless connect(启动)→ remnote-bridge --headless <命令>(后续操作)';
76
+ if (opts.json) {
77
+ console.log(JSON.stringify({ ok: false, command: 'unknown', error: msg }));
78
+ }
79
+ else {
80
+ console.error(msg);
81
+ }
82
+ process.exitCode = 1;
83
+ // 阻止后续命令执行
84
+ process.exit(1);
85
+ }
71
86
  if (isHeadless) {
72
87
  // headless 覆盖 instance,固定实例名
73
88
  process.env.REMNOTE_HEADLESS = '1';
@@ -164,6 +179,42 @@ program
164
179
  await readTreeCommand(remIdOrJson, { json, ...cmdOpts });
165
180
  }
166
181
  });
182
+ program
183
+ .command('read-rem-in-tree [remIdOrJson]')
184
+ .description('读取 Rem 子树大纲 + 每个节点的完整 RemObject(read-tree + read-rem 合体)')
185
+ .option('--depth <depth>', '展开深度(默认 3,-1 = 全部展开)')
186
+ .option('--max-nodes <maxNodes>', '全局节点上限(默认 50)')
187
+ .option('--max-siblings <maxSiblings>', '每个父节点下展示的 children 上限(默认 20)')
188
+ .option('--ancestor-levels <ancestorLevels>', '向上追溯祖先层数(默认 0,上限 10)')
189
+ .option('--fields <fields>', '只返回 RemObject 中指定字段(逗号分隔)')
190
+ .option('--full', '输出全部 51 个字段(含 R-F 低频字段)')
191
+ .option('--includePowerup', '包含 Powerup 系统数据(默认过滤)')
192
+ .action(async (remIdOrJson, cmdOpts) => {
193
+ const { json } = program.opts();
194
+ if (json) {
195
+ const input = parseJsonInput('read-rem-in-tree', remIdOrJson);
196
+ if (!input)
197
+ return;
198
+ await readRemInTreeCommand(input.remId, {
199
+ json,
200
+ depth: input.depth?.toString(),
201
+ maxNodes: input.maxNodes?.toString(),
202
+ maxSiblings: input.maxSiblings?.toString(),
203
+ ancestorLevels: input.ancestorLevels?.toString(),
204
+ fields: input.fields,
205
+ full: input.full,
206
+ includePowerup: input.includePowerup,
207
+ });
208
+ }
209
+ else {
210
+ if (!remIdOrJson) {
211
+ console.error('错误: 缺少 remId');
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ await readRemInTreeCommand(remIdOrJson, { json, ...cmdOpts });
216
+ }
217
+ });
167
218
  program
168
219
  .command('read-globe [jsonStr]')
169
220
  .description('读取知识库全局概览(仅 Document 层级)')
@@ -19,6 +19,7 @@ import { TreeReadHandler } from '../handlers/tree-read-handler.js';
19
19
  import { TreeEditHandler } from '../handlers/tree-edit-handler.js';
20
20
  import { GlobeReadHandler } from '../handlers/globe-read-handler.js';
21
21
  import { ContextReadHandler } from '../handlers/context-read-handler.js';
22
+ import { TreeRemReadHandler } from '../handlers/tree-rem-read-handler.js';
22
23
  import crypto from 'crypto';
23
24
  const PLUGIN_REQUEST_TIMEOUT_MS = 15_000;
24
25
  export class BridgeServer {
@@ -40,6 +41,7 @@ export class BridgeServer {
40
41
  treeEditHandler;
41
42
  globeReadHandler;
42
43
  contextReadHandler;
44
+ treeRemReadHandler;
43
45
  defaults;
44
46
  config;
45
47
  /** 实际监听的端口(可能与 config.port 不同,若原端口被占用则 OS 自动分配) */
@@ -69,6 +71,7 @@ export class BridgeServer {
69
71
  this.treeEditHandler = new TreeEditHandler(remCache, forwardFn, defaults);
70
72
  this.globeReadHandler = new GlobeReadHandler(forwardFn, defaults);
71
73
  this.contextReadHandler = new ContextReadHandler(forwardFn, defaults);
74
+ this.treeRemReadHandler = new TreeRemReadHandler(remCache, forwardFn, config.onLog, defaults);
72
75
  }
73
76
  log(message, level = 'info') {
74
77
  this.config.onLog?.(message, level);
@@ -322,6 +325,9 @@ export class BridgeServer {
322
325
  if (request.action === 'read_rem') {
323
326
  result = await this.readHandler.handleReadRem(request.payload);
324
327
  }
328
+ else if (request.action === 'read_rem_in_tree') {
329
+ result = await this.treeRemReadHandler.handleReadRemInTree(request.payload);
330
+ }
325
331
  else if (request.action === 'read_tree') {
326
332
  result = await this.treeReadHandler.handleReadTree(request.payload);
327
333
  }
@@ -430,8 +436,10 @@ export class BridgeServer {
430
436
  }
431
437
  /** 获取当前状态(timeoutRemaining 通过构造时注入的回调获取) */
432
438
  getStatus() {
439
+ const connected = this.pluginSocket?.readyState === WebSocket.OPEN;
433
440
  const result = {
434
- pluginConnected: this.pluginSocket?.readyState === WebSocket.OPEN,
441
+ pluginConnected: connected,
442
+ pluginIsTwin: connected ? this.pluginIsTwin : false,
435
443
  sdkReady: this.pluginSdkReady,
436
444
  uptime: Math.floor((Date.now() - this.startTime) / 1000),
437
445
  timeoutRemaining: this.config.getTimeoutRemaining?.() ?? 0,
@@ -18,6 +18,23 @@ const CLI_BIN = 'remnote-bridge';
18
18
  /** 子进程默认超时(毫秒) */
19
19
  const DEFAULT_TIMEOUT_MS = 30_000;
20
20
  // ---------------------------------------------------------------------------
21
+ // Headless 模式状态
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * 记住当前会话是否为 headless 模式。
25
+ * connect(headless=true) 后设为 true,disconnect/clean 后清除。
26
+ * callCli 会自动为所有 CLI 调用注入 --headless 全局选项。
27
+ */
28
+ let _headlessMode = false;
29
+ /** 设置 headless 模式状态(由 infra-tools 的 connect/disconnect/clean 调用) */
30
+ export function setHeadlessMode(enabled) {
31
+ _headlessMode = enabled;
32
+ }
33
+ /** 查询当前是否为 headless 模式 */
34
+ export function isHeadlessMode() {
35
+ return _headlessMode;
36
+ }
37
+ // ---------------------------------------------------------------------------
21
38
  // 错误类
22
39
  // ---------------------------------------------------------------------------
23
40
  export class CliError extends Error {
@@ -46,8 +63,11 @@ export class CliError extends Error {
46
63
  */
47
64
  export async function callCli(command, payload, options) {
48
65
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
49
- // 构造参数列表:--json <command> [flags...] [jsonStr]
50
- const args = ['--json', command];
66
+ // 构造参数列表:--json [--headless] <command> [flags...] [jsonStr]
67
+ const args = ['--json'];
68
+ if (_headlessMode)
69
+ args.push('--headless');
70
+ args.push(command);
51
71
  if (options?.flags) {
52
72
  args.push(...options.flags);
53
73
  }
@@ -155,12 +155,12 @@ setup 只需执行一次。之后每次连接直接用 \`connect(headless=true)\
155
155
 
156
156
  \`\`\`
157
157
  connect(headless=true) → 启动 daemon + headless Chrome 自动加载 RemNote 和 Plugin
158
-
158
+ MCP Server 自动记住 headless 状态
159
159
  health → 等待三层就绪(Plugin 需要 10-30 秒连接,可多次轮询)
160
+ ↓ 后续所有工具自动路由到 headless 实例
161
+ 业务操作(read / search / edit)
160
162
 
161
- 业务操作
162
-
163
- disconnect → 关闭 daemon + headless Chrome,清空所有缓存
163
+ disconnect → 关闭 daemon + headless Chrome,清空所有缓存,清除 headless 状态
164
164
  \`\`\`
165
165
 
166
166
  #### 排查
@@ -231,6 +231,10 @@ disconnect → 关闭 daemon + headless Chrome,清空所有缓存
231
231
 
232
232
  \`read_rem\` → 确认当前属性 → \`edit_rem\` 传入 changes 对象。只需包含要修改的字段,未提及字段保持不变。
233
233
 
234
+ **注意**:\`read_rem\` 默认模式启用 Token Slimming——省略处于默认值的字段,未返回的字段即默认值(如 type 未显示 = "default",isTodo 未显示 = false,tags 未显示 = [])。需要完整字段用 \`full=true\`。
235
+
236
+ > **批量修改多个节点时**:改用 \`read_rem_in_tree\` 一次性获取全部节点属性,避免逐个 read_rem。详见场景 D2。
237
+
234
238
  #### edit_rem changes 示例
235
239
 
236
240
  \`\`\`jsonc
@@ -277,6 +281,22 @@ changes: { "type": "concept", "highlightColor": "Yellow", "fontSize": "H1" }
277
281
  - **todoStatus**:依赖 \`isTodo=true\` 才生效;清除 todo 应设 \`isTodo=false\`
278
282
  - **type**:不可设为 \`portal\`(Portal 只能通过 SDK \`createPortal()\` 或 edit_tree \`<!--portal-->\` 创建)
279
283
 
284
+ ### 场景 D2:批量读取 + 标注(课本划重点)
285
+
286
+ > "帮我标注这些笔记的重点"、"给关键词加高亮"、"批量修改格式"
287
+
288
+ 当需要读取一棵子树并对**多个节点**进行属性或富文本修改时,使用 \`read_rem_in_tree\` 一次性获取全部信息:
289
+
290
+ \`read_rem_in_tree\` → 同时获取大纲 + 每个节点的 RemObject(含完整 RichText)
291
+
292
+ 对目标节点直接调用 \`edit_rem\`(rem 缓存已就绪,无需再逐个 read_rem)
293
+ +
294
+ 如需结构变更,直接调用 \`edit_tree\`(tree 缓存已就绪,无需再 read_tree)
295
+
296
+ **为什么不用 read_tree + N×read_rem?** \`read_rem_in_tree\` 一次调用建立双重缓存,省去 N+1 次网络往返。对 30+ 节点的子树,差异是 1 次调用 vs 31+ 次调用。
297
+
298
+ **注意**:\`read_rem_in_tree\` 默认 maxNodes=50(每节点需 40+ SDK 调用),大子树需显式设置 maxNodes。
299
+
280
300
  ### 场景 E:修改结构(新增/删除/移动/重排)
281
301
 
282
302
  > "在这下面加几个子项"、"删掉这个"
@@ -338,6 +358,30 @@ newStr:
338
358
  子节点 A <!--idA-->
339
359
  \`\`\`
340
360
 
361
+ #### 行引用模板 \`{{remId}}\`
362
+
363
+ 在 oldStr/newStr 中使用 \`{{remId}}\` 引用缓存大纲中已有行的完整内容(不含缩进)。系统在 str_replace 前自动展开。
364
+
365
+ **优势**:避免抄写完整行内容(remId、元数据标记),减少 token 浪费和复制错误。
366
+
367
+ **示例:重排两个节点**
368
+ \`\`\`
369
+ // 不用模板:
370
+ oldStr: " 动态数组 <!--id1_1 type:concept-->\\n 静态数组 <!--id1_2 type:concept-->"
371
+ newStr: " 静态数组 <!--id1_2 type:concept-->\\n 动态数组 <!--id1_1 type:concept-->"
372
+
373
+ // 用模板:
374
+ oldStr: " {{id1_1}}\\n {{id1_2}}"
375
+ newStr: " {{id1_2}}\\n {{id1_1}}"
376
+ \`\`\`
377
+
378
+ **规则**:
379
+ - \`{{remId}}\` 展开为**不含缩进**的完整行内容,缩进由你控制
380
+ - 只匹配纯字母数字(\`[a-zA-Z0-9]+\`),与 RemNote cloze 语法 \`{{text}}\` 不冲突(cloze 含中文/空格/标点,不会被匹配)
381
+ - 匹配到但不在缓存大纲中的 \`{{xxx}}\` 原样保留(可能是 cloze),并输出 warning
382
+ - 新增行不能用 \`{{}}\`(新增行没有 remId)
383
+ - 可以混用:部分行用 \`{{id}}\`,部分行手动写
384
+
341
385
  #### ⚠️ children_captured 详解
342
386
 
343
387
  在有子节点的 Rem 和其 children 之间插入新行,会导致新行"劫持"已有 children:
@@ -381,7 +425,9 @@ newStr: 最后一个兄弟 <!--idZ-->
381
425
 
382
426
  ### 场景 G:排查连接问题
383
427
 
384
- \`health\` 检查三层状态:daemon 未运行 \`connect\`;Plugin 未连接 引导用户操作 RemNote;SDK 未就绪 等待重试。
428
+ \`health\` 默认查询所有活跃实例的三层状态(daemon / Plugin / SDK),返回 \`instances\` 数组。有 \`--instance\` \`--headless\` 时只查询指定实例。每个实例的 \`plugin.isTwin\` 标记是否为孪生连接。
429
+
430
+ 故障定位:无活跃实例 → \`connect\`;Plugin 未连接 → 引导用户操作 RemNote(或使用 headless 模式);SDK 未就绪 → 等待重试。
385
431
 
386
432
  ### 场景 H:管理增强项目
387
433
 
@@ -397,8 +443,9 @@ newStr: 最后一个兄弟 <!--idZ-->
397
443
 
398
444
  ### 黄金法则:先 read 再 edit
399
445
 
400
- - \`edit_rem\` 前必须先 \`read_rem\` 同一个 remId
401
- - \`edit_tree\` 前必须先 \`read_tree\` 同一个 remId
446
+ - \`edit_rem\` 前必须先 \`read_rem\` 或 \`read_rem_in_tree\` 同一个 remId
447
+ - \`edit_tree\` 前必须先 \`read_tree\` 或 \`read_rem_in_tree\` 同一个 remId
448
+ - \`read_rem_in_tree\` 同时建立两种缓存,调用后可直接 \`edit_tree\` 和 \`edit_rem\`
402
449
  - \`search\`/\`read_globe\`/\`read_context\` **不写入缓存**,不能作为 edit 前置
403
450
 
404
451
  跳过 read 直接 edit 会被拒绝(硬性要求)。
@@ -75,6 +75,11 @@ export function registerEditTools(server) {
75
75
  '\\n- 箭头分隔符(闪卡):→ ← ↔(单行)、↓ ↑ ↕(多行,带 backText 或子节点为答案)' +
76
76
  '\\n- 元数据注释(可选):<!--type:concept--> <!--doc--> <!--tag:Name(id)--> 可组合' +
77
77
  '\\n- Portal 创建:<!--portal refs:id1,id2--> 或 <!--portal-->(空 Portal)' +
78
+ '\\n\\n行引用模板 {{remId}}:' +
79
+ '\\n在 oldStr/newStr 中使用 {{remId}} 引用缓存大纲中已有行的完整内容(不含缩进),系统在 str_replace 前自动展开。缩进仍由你控制。' +
80
+ '\\n优势:避免反复抄写行内容(含 remId 和元数据标记),减少 token 开销和复制错误。' +
81
+ '\\n示例 reorder:oldStr=" {{id1}}\\n {{id2}}" newStr=" {{id2}}\\n {{id1}}"' +
82
+ '\\n规则:只匹配纯字母数字(与 RemNote cloze {{text}} 不冲突);未找到的 {{xxx}} 原样保留并输出 templateWarnings;新增行不能使用 {{}};可混用模板和手写内容。' +
78
83
  '\\n\\n⚠️ 插入位置红线:新行必须插在目标层级所有兄弟的末尾,不能插在父 Rem 和它的 children 之间,否则触发 children_captured 错误。如需"创建新父节点并移入已有 children",必须分两次 edit_tree 完成。' +
79
84
  '\\n\\n六种禁止操作:' +
80
85
  '\\n- content_modified:修改已有行内容 → 改用 edit_rem' +
@@ -93,8 +98,8 @@ export function registerEditTools(server) {
93
98
  '\\n\\n关联工具:read_tree(前置读取大纲)、edit_rem(修改单个 Rem 属性/内容)',
94
99
  parameters: z.object({
95
100
  remId: z.string().describe('根 Rem 的 ID(与 read_tree 时相同)'),
96
- oldStr: z.string().describe('要替换的大纲片段(必须精确匹配缓存中的内容,且恰好匹配 1 次)'),
97
- newStr: z.string().describe('替换后的大纲片段(可新增/删除/移动/重排行,但禁止修改已有行内容)'),
101
+ oldStr: z.string().describe('要替换的大纲片段(必须精确匹配缓存中的内容,且恰好匹配 1 次)。支持 {{remId}} 引用已有行内容(不含缩进),系统自动展开'),
102
+ newStr: z.string().describe('替换后的大纲片段(可新增/删除/移动/重排行,但禁止修改已有行内容)。支持 {{remId}} 引用已有行内容(不含缩进),系统自动展开'),
98
103
  }),
99
104
  execute: async (args) => {
100
105
  const response = await callCli('edit-tree', {
@@ -2,7 +2,7 @@
2
2
  * 基础设施工具:setup、connect、disconnect、health
3
3
  */
4
4
  import { z } from 'zod';
5
- import { callCli } from '../daemon-client.js';
5
+ import { callCli, setHeadlessMode } from '../daemon-client.js';
6
6
  import { formatDataJson } from '../format.js';
7
7
  export function registerInfraTools(server) {
8
8
  // -------------------------------------------------------------------------
@@ -22,19 +22,26 @@ export function registerInfraTools(server) {
22
22
  // -------------------------------------------------------------------------
23
23
  server.addTool({
24
24
  name: 'connect',
25
- description: '启动守护进程(daemon),建立 CLI 与 RemNote Plugin 之间的通信通道。这是所有业务命令(read_rem、edit_rem、search 等)的前置步骤。\n\n适用场景:\n- 开始一次 RemNote 操作会话前,必须先调用此工具\n- 不确定 daemon 是否在运行时,也可安全调用(幂等)\n\n两种模式:\n- 标准模式(默认):启动 daemon 后需要用户在 RemNote 中手动加载 Plugin\n- Headless 模式(headless=true):自动启动 headless Chrome 加载 Plugin,无需用户操作。需先完成 setup(保存登录凭证)\n\n输出:返回 JSON,关键字段 ok、alreadyRunning、instance、slotIndex、pid、wsPort、headless。\n幂等:重复调用不会启动多个 daemon。daemon 默认 30 分钟无活动自动关闭。\n关联工具:setup(headless 前置)、disconnect(结束会话)、health(检查状态)',
25
+ description: '启动守护进程(daemon),建立 CLI 与 RemNote Plugin 之间的通信通道。这是所有业务命令(read_rem、edit_rem、search 等)的前置步骤。\n\n适用场景:\n- 开始一次 RemNote 操作会话前,必须先调用此工具\n- 不确定 daemon 是否在运行时,也可安全调用(幂等)\n\n两种模式:\n- 标准模式(默认):启动 daemon 后需要用户在 RemNote 中手动加载 Plugin\n- Headless 模式(headless=true):自动启动 headless Chrome 加载 Plugin,无需用户操作。需先完成 setup(保存登录凭证)\n\nHeadless 模式会话持久化:connect(headless=true) 后,MCP Server 自动记住 headless 状态,后续所有工具调用(health、read_rem、edit_rem 等)自动路由到 headless 实例,无需额外参数。disconnect 或 clean 后自动清除。\n\n输出:返回 JSON,关键字段 ok、alreadyRunning、instance、slotIndex、pid、wsPort、headless。\n幂等:重复调用不会启动多个 daemon。daemon 默认 30 分钟无活动自动关闭。\n关联工具:setup(headless 前置)、disconnect(结束会话)、health(检查状态)',
26
26
  parameters: z.object({
27
27
  headless: z.boolean().optional().describe('启用 headless 模式:自动启动 Chrome 加载 Plugin(需先 setup)'),
28
28
  }),
29
29
  execute: async (args) => {
30
- const flags = [];
30
+ // headless 模式:connect 前先设置,确保 callCli 注入 --headless
31
31
  if (args.headless)
32
- flags.push('--headless');
33
- const response = await callCli('connect', undefined, {
34
- timeoutMs: 90_000,
35
- flags: flags.length > 0 ? flags : undefined,
36
- });
37
- return formatDataJson(response);
32
+ setHeadlessMode(true);
33
+ try {
34
+ const response = await callCli('connect', undefined, {
35
+ timeoutMs: 90_000,
36
+ });
37
+ return formatDataJson(response);
38
+ }
39
+ catch (e) {
40
+ // connect 失败时清除 headless 状态
41
+ if (args.headless)
42
+ setHeadlessMode(false);
43
+ throw e;
44
+ }
38
45
  },
39
46
  });
40
47
  // -------------------------------------------------------------------------
@@ -42,10 +49,11 @@ export function registerInfraTools(server) {
42
49
  // -------------------------------------------------------------------------
43
50
  server.addTool({
44
51
  name: 'disconnect',
45
- description: '停止守护进程,释放所有端口、清空内存缓存、结束当前会话。\n\n适用场景:\n- 操作完成后主动释放资源\n- 需要重置连接状态(例如排查问题时先 disconnect 再 connect)\n\n输出:返回 JSON,关键字段 ok、wasRunning(之前是否在运行)、instance、pid。\n幂等:daemon 未运行时调用也返回 ok: true。\n所有缓存随 daemon 退出清空,下次 connect 后需重新 read。\n关联工具:connect(启动会话)、health(检查状态)',
52
+ description: '停止守护进程,释放所有端口、清空内存缓存、结束当前会话。自动清除 headless 模式状态。\n\n适用场景:\n- 操作完成后主动释放资源\n- 需要重置连接状态(例如排查问题时先 disconnect 再 connect)\n\n输出:返回 JSON,关键字段 ok、wasRunning(之前是否在运行)、instance、pid。\n幂等:daemon 未运行时调用也返回 ok: true。\n所有缓存随 daemon 退出清空,下次 connect 后需重新 read。\n关联工具:connect(启动会话)、health(检查状态)',
46
53
  parameters: z.object({}),
47
54
  execute: async () => {
48
55
  const response = await callCli('disconnect');
56
+ setHeadlessMode(false);
49
57
  return formatDataJson(response);
50
58
  },
51
59
  });
@@ -54,7 +62,7 @@ export function registerInfraTools(server) {
54
62
  // -------------------------------------------------------------------------
55
63
  server.addTool({
56
64
  name: 'health',
57
- description: '检查系统三层状态(daemon 守护进程 / Plugin 连接 / SDK 就绪),用于诊断连接问题。\n\n适用场景:\n- 业务命令失败时,首先调用 health 定位故障层级\n- 执行 connect 后确认通道是否完全就绪\n- 长时间未操作后,检查 daemon 是否仍在运行\n\n输出:返回 JSON,关键字段 ok(三层是否全部健康)、instance、slotIndex、daemon.running、plugin.connected、sdk.ready、timeoutRemainingheadless 模式下额外包含 headless 对象。\n三层有严格依赖:daemon 运行 → Plugin 连接 → SDK 就绪。\nok 为 false 时:daemon 未运行则 connect;Plugin 未连接则确认 RemNote 已打开(或使用 headless 模式);SDK 未就绪则等待重试。\n\n--diagnose 模式(headless 专用):截图 + 详细状态 + console 错误 + 排查建议。\n--reload 模式(headless 专用):重载 headless Chrome 页面。\n\n只读不写,不改变任何状态(--reload 除外)。\n关联工具:connect(启动)、disconnect(结束)',
65
+ description: '检查系统三层状态(daemon 守护进程 / Plugin 连接 / SDK 就绪),用于诊断连接问题。\n\n两种模式:\n- 全量模式(默认):返回所有活跃实例的状态,字段 instances 是数组,每个元素包含 instance、slotIndex、daemon、plugin(含 isTwin 孪生标记)、sdk、timeoutRemaining\n- 单实例模式(headless 会话中自动触发):返回当前实例的状态,结构与之前相同但 plugin 多了 isTwin 字段\n\nplugin.isTwin 表示 Plugin 连接是否为"孪生连接"——即 Plugin 的 twinSlotIndex 与 daemon 的槽位索引匹配。孪生连接优先级更高,可抢占非孪生连接。\n\n三层有严格依赖:daemon 运行 → Plugin 连接 → SDK 就绪。\nok 为 false 时:无活跃实例则 connect;Plugin 未连接则确认 RemNote 已打开(或使用 headless 模式);SDK 未就绪则等待重试。\n\n--diagnose 模式(headless 专用):截图 + 详细状态 + console 错误 + 排查建议。\n--reload 模式(headless 专用):重载 headless Chrome 页面。\n\n只读不写,不改变任何状态(--reload 除外)。\n关联工具:connect(启动)、disconnect(结束)',
58
66
  parameters: z.object({
59
67
  diagnose: z.boolean().optional().describe('诊断 headless Chrome(截图 + 状态 + console 错误)'),
60
68
  reload: z.boolean().optional().describe('重载 headless Chrome 页面'),
@@ -103,6 +111,7 @@ export function registerInfraTools(server) {
103
111
  parameters: z.object({}),
104
112
  execute: async () => {
105
113
  const response = await callCli('clean');
114
+ setHeadlessMode(false);
106
115
  return formatDataJson(response);
107
116
  },
108
117
  });
@@ -62,14 +62,16 @@ export function registerReadTools(server) {
62
62
  '\\n- full(可选,默认 false):返回全部 51 个字段(含低频 R-F 字段,如 children、isPowerup 系列、deepRemsBeingReferenced 等)' +
63
63
  '\\n- includePowerup(可选,默认 false):包含 Powerup 系统数据(默认过滤噪音)' +
64
64
  '\\n\\n输出格式:Data JSON,核心为 RemObject 对象。' +
65
- '\\n- 默认 33 个常用字段(RW + R),full=true 51 个,Portal 类型自动简化为 8 个关键字段' +
66
- '\\n- 关键字段:id, text, backText, type(concept/descriptor/default/portal), parent, isDocument, tags, fontSize(H1/H2/H3/null), highlightColor(Red/Orange/Yellow/Green/Blue/Purple/Gray/Brown/Pink/null), practiceDirection(forward/backward/both/none), isTodo, todoStatus(Finished/Unfinished/null)' +
65
+ '\\n- 默认模式(Token Slimming):省略处于默认值的字段,典型普通 Rem 仅输出 5-6 个差异字段。未显示的字段即为默认值(如 type 未显示 = "default",isTodo 未显示 = false,tags 未显示 = [])。始终输出的字段:id, text, parent, createdAt, updatedAt' +
66
+ '\\n- full=true 时返回全部 51 个字段,Portal 类型自动简化为 8 个关键字段' +
67
+ '\\n- 关键字段:id, text, backText(null), type(concept/descriptor/default/portal), parent, isDocument(false), tags([]), fontSize(H1/H2/H3/null), highlightColor(Red/.../Pink/null), practiceDirection(forward/backward/both/none), isTodo(false), todoStatus(Finished/Unfinished/null)——括号内为默认值' +
67
68
  '\\n- children 字段属于 R-F 层级,默认不输出,需 full=true 或 fields 指定' +
68
69
  '\\n- text/backText 是 RichText JSON 数组,元素为纯字符串或带 i 字段的对象(i:"m" 格式化文本, i:"q" Rem 引用, i:"x" LaTeX, i:"i" 图片, i:"a" 音视频)' +
69
70
  '\\n- 可能附加 cacheOverridden(覆盖旧缓存时)和 powerupFiltered(过滤统计)元数据' +
70
71
  '\\n\\n关键约束:' +
71
72
  '\\n- 结果自动写入缓存供 edit_rem 使用。缓存存储完整 RemObject,不受 fields/full 选项影响' +
72
73
  '\\n- 默认过滤 Powerup 噪音(系统 Tag 和隐藏子 Rem),includePowerup=true 可恢复' +
74
+ '\\n\\n⚠️ 如果你需要读取多个 Rem 的属性(≥3 个),且它们在同一棵子树下,请改用 read_rem_in_tree(一次调用批量读取,省去逐个 read_rem 的开销)。' +
73
75
  '\\n\\n典型工作流:search 定位 remId → read_rem 获取详情并建立缓存 → edit_rem 编辑属性。' +
74
76
  '\\n关联工具:search(定位 remId)、edit_rem(编辑属性,需先 read_rem)、read_tree(查看子树大纲)',
75
77
  parameters: z.object({
@@ -129,6 +131,7 @@ export function registerReadTools(server) {
129
131
  '\\n- 结果自动写入缓存供 edit_tree 使用。edit_tree 的 oldStr 必须精确匹配此大纲中的文本' +
130
132
  '\\n- 默认过滤 Powerup 噪音,includePowerup=true 可恢复' +
131
133
  '\\n- Portal 感知:Portal 类型 Rem 标注 type:portal 和 refs:id1,id2(引用的 Rem ID)' +
134
+ '\\n\\n⚠️ 如果你需要读取子树后对其中多个节点执行 edit_rem,请改用 read_rem_in_tree(一次调用同时建立 tree 和 rem 双重缓存,省去逐个 read_rem 的开销)。' +
132
135
  '\\n\\n典型工作流:search 定位 remId → read_tree 展开子树并建立缓存 → edit_tree 结构编辑。' +
133
136
  '\\n关联工具:search(定位 remId)、edit_tree(结构编辑,需先 read_tree)、read_rem(单 Rem JSON 详情)、read_globe(全局鸟瞰)',
134
137
  parameters: z.object({
@@ -182,6 +185,89 @@ export function registerReadTools(server) {
182
185
  },
183
186
  });
184
187
  // -------------------------------------------------------------------------
188
+ // read_rem_in_tree
189
+ // -------------------------------------------------------------------------
190
+ server.addTool({
191
+ name: 'read_rem_in_tree',
192
+ description: '⭐ 批量编辑场景的首选工具。当你需要读取子树并修改其中多个节点的属性/富文本时,优先使用此工具而非 read_tree + N×read_rem。' +
193
+ '\\nread_tree + read_rem 的完美结合体:一次调用同时获取子树 Markdown 大纲和每个节点的完整 RemObject JSON。' +
194
+ '\\n\\n适用场景:需要同时查看子树结构和节点详细属性时(如批量编辑前的全量读取);一次调用同时建立 tree 缓存(供 edit_tree)和 rem 缓存(供 edit_rem)。' +
195
+ '\\n不适用场景:只需大纲不需属性(用 read_tree,更轻量);只需单个 Rem 属性(用 read_rem)。' +
196
+ '\\n\\n前置条件:daemon 已连接;需要有效的 remId。' +
197
+ '\\n\\n参数说明:' +
198
+ '\\n- remId(必需):子树根节点的 Rem ID' +
199
+ '\\n- depth(可选,默认 3,-1 无限):递归展开深度' +
200
+ '\\n- maxNodes(可选,默认 50):全局节点总预算。注意默认值比 read_tree 的 200 低,因为每节点需 40+ SDK 调用' +
201
+ '\\n- maxSiblings(可选,默认 20):单个父节点下最大可见子节点数' +
202
+ '\\n- ancestorLevels(可选,默认 0,上限 10):向上追溯祖先层数' +
203
+ '\\n- fields(可选):RemObject 字段过滤,只返回指定字段子集' +
204
+ '\\n- full(可选,默认 false):返回全部 51 个 RemObject 字段' +
205
+ '\\n- includePowerup(可选,默认 false):包含 Powerup 系统数据' +
206
+ '\\n\\n输出格式:Data JSON,包含:' +
207
+ '\\n- outline:Markdown 大纲文本(与 read_tree 输出格式完全一致)' +
208
+ '\\n- remObjects:扁平 map { remId → RemObject },每个 RemObject 与 read_rem 输出一致(受 fields/full 参数影响)' +
209
+ '\\n- rootId, depth, nodeCount 等元数据' +
210
+ '\\n\\n关键约束:' +
211
+ '\\n- 同时建立双重缓存:tree:{remId} 供 edit_tree 使用,rem:{nodeRemId} 供 edit_rem 使用' +
212
+ '\\n- 缓存条目约 N+4(1 tree + 3 参数 + N rem),注意 LRU 上限 200' +
213
+ '\\n- RemObject 默认启用 Token Slimming(与 read_rem 一致)' +
214
+ '\\n\\n典型工作流:read_rem_in_tree 一次获取全部信息 → edit_tree 结构编辑 + edit_rem 属性编辑。' +
215
+ '\\n关联工具:read_tree(只需大纲)、read_rem(只需单 Rem)、edit_tree(结构编辑)、edit_rem(属性编辑)',
216
+ parameters: z.object({
217
+ remId: z.string().describe('根 Rem 的 ID'),
218
+ depth: z
219
+ .number()
220
+ .optional()
221
+ .describe('展开深度(默认 3)'),
222
+ maxNodes: z
223
+ .number()
224
+ .optional()
225
+ .describe('节点总数上限(默认 50)'),
226
+ maxSiblings: z
227
+ .number()
228
+ .optional()
229
+ .describe('同级节点显示上限'),
230
+ ancestorLevels: z
231
+ .number()
232
+ .optional()
233
+ .describe('向上展示的祖先层级数'),
234
+ fields: z
235
+ .array(z.string())
236
+ .optional()
237
+ .describe('RemObject 字段过滤'),
238
+ full: z
239
+ .boolean()
240
+ .optional()
241
+ .describe('返回全部 RemObject 字段'),
242
+ includePowerup: z
243
+ .boolean()
244
+ .optional()
245
+ .describe('是否包含 Powerup 元信息'),
246
+ }),
247
+ execute: async (args) => {
248
+ const payload = { remId: args.remId };
249
+ if (args.depth !== undefined)
250
+ payload.depth = args.depth;
251
+ if (args.maxNodes !== undefined)
252
+ payload.maxNodes = args.maxNodes;
253
+ if (args.maxSiblings !== undefined)
254
+ payload.maxSiblings = args.maxSiblings;
255
+ if (args.ancestorLevels !== undefined)
256
+ payload.ancestorLevels = args.ancestorLevels;
257
+ if (args.fields !== undefined)
258
+ payload.fields = args.fields;
259
+ if (args.full !== undefined)
260
+ payload.full = args.full;
261
+ if (args.includePowerup !== undefined)
262
+ payload.includePowerup = args.includePowerup;
263
+ const response = await callCli('read-rem-in-tree', payload, { timeoutMs: 60_000 });
264
+ if (!response.data) {
265
+ return `read_rem_in_tree 返回了空的 data,remId: ${args.remId}。请检查该 Rem 是否存在。`;
266
+ }
267
+ return formatDataJson(response);
268
+ },
269
+ });
270
+ // -------------------------------------------------------------------------
185
271
  // read_globe
186
272
  // -------------------------------------------------------------------------
187
273
  server.addTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remnote-bridge",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "RemNote 自动化桥接工具集:CLI + MCP Server + Plugin",
5
5
  "type": "module",
6
6
  "bin": {