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.
Files changed (42) hide show
  1. package/README.md +147 -28
  2. package/README.zh-CN.md +374 -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/edit-handler.js +14 -0
  8. package/dist/cli/handlers/patch-engine.js +347 -0
  9. package/dist/cli/handlers/read-handler.js +2 -53
  10. package/dist/cli/handlers/rem-field-filter.js +102 -0
  11. package/dist/cli/handlers/tree-edit-handler.js +67 -7
  12. package/dist/cli/handlers/tree-read-handler.js +4 -1
  13. package/dist/cli/handlers/tree-rem-read-handler.js +73 -0
  14. package/dist/cli/main.js +53 -2
  15. package/dist/cli/server/ws-server.js +9 -1
  16. package/dist/mcp/daemon-client.js +22 -2
  17. package/dist/mcp/instructions.js +99 -58
  18. package/dist/mcp/tools/edit-tools.js +7 -2
  19. package/dist/mcp/tools/infra-tools.js +20 -11
  20. package/dist/mcp/tools/read-tools.js +88 -2
  21. package/package.json +1 -1
  22. package/remnote-plugin/dist/index-sandbox.js +24 -24
  23. package/remnote-plugin/dist/index.js +24 -24
  24. package/remnote-plugin/dist/manifest.json +1 -1
  25. package/remnote-plugin/package.json +1 -1
  26. package/remnote-plugin/public/manifest.json +1 -1
  27. package/remnote-plugin/src/bridge/message-router.ts +3 -0
  28. package/remnote-plugin/src/services/read-rem-in-tree.ts +43 -0
  29. package/remnote-plugin/src/services/read-rem.ts +31 -16
  30. package/remnote-plugin/src/services/read-tree.ts +5 -0
  31. package/remnote-plugin/src/settings.ts +1 -1
  32. package/skills/remnote-bridge/SKILL.md +50 -8
  33. package/skills/remnote-bridge/instructions/connect.md +31 -8
  34. package/skills/remnote-bridge/instructions/disconnect.md +5 -0
  35. package/skills/remnote-bridge/instructions/edit-tree.md +117 -51
  36. package/skills/remnote-bridge/instructions/health.md +81 -53
  37. package/skills/remnote-bridge/instructions/overall.md +39 -8
  38. package/skills/remnote-bridge/instructions/read-rem-in-tree.md +100 -0
  39. package/skills/remnote-bridge/instructions/read-rem.md +30 -11
  40. package/skills/remnote-bridge-test/SKILL.md +847 -0
  41. package/skills/remnote-bridge-test/references/regression-suite.md +960 -0
  42. 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
  }
@@ -119,12 +119,20 @@ RemNote 格式设置(标题、高亮、代码等)底层通过 Powerup 机制
119
119
 
120
120
  所有操作都依赖一个活跃的会话(= 守护进程的生命周期)。
121
121
 
122
- ### 标准模式(需要用户配合)
122
+ > **⚠️ 关于 Plugin 加载方式(防幻觉红线)**:
123
+ > - 本插件是**开发者插件**,通过 RemNote「开发你的插件」功能加载本地 URL
124
+ > - **禁止**告诉用户"去插件市场/商店搜索安装"——本插件**不在 RemNote 插件市场中**
125
+ > - **禁止**告诉用户"Settings → Plugins"——这个路径不存在
126
+ > - **禁止**编造不存在的安装流程——严格按照下方步骤引导用户
127
+
128
+ ### 标准模式(默认,推荐)
129
+
130
+ **标准模式是日常使用的推荐方式**。用户在自己的浏览器中打开 RemNote 并加载 Plugin,Agent 可以通过 \`read_context\` 感知用户正在浏览的页面和焦点位置,实现真正的协作。
123
131
 
124
132
  \`\`\`
125
133
  connect → 启动 daemon(幂等)
126
134
 
127
- ⚠️ 用户操作:确保 RemNote 中已加载插件(见下方说明)
135
+ ⚠️ 引导用户加载 Plugin(见下方 ★ 标记的步骤)
128
136
 
129
137
  health → 确认三层就绪(daemon / Plugin / SDK)
130
138
 
@@ -133,11 +141,34 @@ health → 确认三层就绪(daemon / Plugin / SDK)
133
141
  disconnect → 关闭 daemon,清空所有缓存
134
142
  \`\`\`
135
143
 
136
- ### Headless 模式(自动连接)
144
+ ### 标准模式:connect 后引导用户加载 Plugin(核心步骤)
145
+
146
+ \`connect\`(不传 headless)成功只意味着 daemon 和 Plugin 服务已启动,**Plugin 并未自动连接**。你必须引导用户完成以下操作:
147
+
148
+ **首次使用**(RemNote 从未加载过此插件):
149
+ 1. 打开 RemNote 桌面端或网页端
150
+ 2. 点击左侧边栏底部的**插件图标**(拼图形状)
151
+ 3. 点击「**开发你的插件**」(Develop Your Plugin)
152
+ 4. 在输入框中填入 connect 输出的 **Plugin 服务地址**(如 \`http://localhost:29101\`)
153
+ 5. 等待插件加载完成
154
+
155
+ **非首次使用**(之前已加载过此插件):
156
+ - 只需**刷新 RemNote 页面**即可(浏览器 F5 或 Cmd+R)
157
+
158
+ **你必须**:执行 \`connect\` 后,**立即**将上述步骤告知用户,**禁止**跳过此步直接调用业务命令。引导用户完成后,用 \`health\` 确认三层就绪再继续。
159
+
160
+ ### Headless 模式(特殊场景,不推荐日常使用)
137
161
 
138
162
  通过 setup(一次性)+ headless Chrome 实现自动连接,后续 connect 无需用户介入。
139
163
 
140
- **⚠️ 模式选择建议**:日常使用推荐**标准模式**。Headless 模式下 Chrome 在后台运行,**无法感知用户正在 RemNote 中浏览和操作的界面**(\`read_context\` 返回的是 headless Chrome 的上下文,而非用户的浏览器)。只有在全自动化场景才建议使用 Headless 模式。
164
+ **⚠️ 不推荐日常使用**。Headless Chrome 是后台独立实例,**会丢失用户上下文**:\`read_context\` 返回的是 headless Chrome 的上下文,不是用户浏览器的。Agent 无法感知用户正在浏览和操作的页面,协作体验大打折扣。
165
+
166
+ **仅在以下场景使用 headless**:
167
+ - 用户明确要求在**服务器/无 GUI 环境**中运行
168
+ - 用户明确表示**不想参与操作**,希望全自动化(CI/CD、定时任务、批量处理等)
169
+ - 用户自己不在 RemNote 前面,不需要与 Agent 协作浏览
170
+
171
+ **默认始终使用标准模式**,除非用户主动要求 headless。
141
172
 
142
173
  #### 首次使用(setup)
143
174
 
@@ -155,12 +186,12 @@ setup 只需执行一次。之后每次连接直接用 \`connect(headless=true)\
155
186
 
156
187
  \`\`\`
157
188
  connect(headless=true) → 启动 daemon + headless Chrome 自动加载 RemNote 和 Plugin
158
-
189
+ MCP Server 自动记住 headless 状态
159
190
  health → 等待三层就绪(Plugin 需要 10-30 秒连接,可多次轮询)
191
+ ↓ 后续所有工具自动路由到 headless 实例
192
+ 业务操作(read / search / edit)
160
193
 
161
- 业务操作
162
-
163
- disconnect → 关闭 daemon + headless Chrome,清空所有缓存
194
+ disconnect → 关闭 daemon + headless Chrome,清空所有缓存,清除 headless 状态
164
195
  \`\`\`
165
196
 
166
197
  #### 排查
@@ -169,22 +200,6 @@ disconnect → 关闭 daemon + headless Chrome,清空所有缓存
169
200
  - \`health(reload=true)\`:重载 headless Chrome 页面
170
201
  - Plugin 始终不连接 → 可能登录 session 过期,需重新 setup
171
202
 
172
- ### ⚠️ 标准模式:connect 后需要用户配合(重要)
173
-
174
- \`connect\`(不传 headless)成功只意味着 daemon 和 Plugin 服务已启动,**Plugin 并未自动连接**。
175
-
176
- **首次使用**(RemNote 从未加载过此插件):
177
- 1. 打开 RemNote 桌面端或网页端
178
- 2. 点击左侧边栏底部的插件图标(拼图形状)
179
- 3. 点击「开发你的插件」(Develop Your Plugin)
180
- 4. 在输入框中填入 connect 输出的 Plugin 服务地址(如 \`http://localhost:29101\`)
181
- 5. 等待插件加载完成
182
-
183
- **非首次使用**(之前已加载过此插件):
184
- - 只需**刷新 RemNote 页面**即可(浏览器 F5 或 Cmd+R)
185
-
186
- **你必须**:执行 \`connect\` 后,**立即告知用户需要完成上述操作**,不要直接调用业务命令。引导用户完成后,用 \`health\` 确认三层就绪再继续。
187
-
188
203
  ### Windows 注意事项
189
204
 
190
205
  - 默认模式秒级启动(预构建 plugin)
@@ -231,6 +246,10 @@ disconnect → 关闭 daemon + headless Chrome,清空所有缓存
231
246
 
232
247
  \`read_rem\` → 确认当前属性 → \`edit_rem\` 传入 changes 对象。只需包含要修改的字段,未提及字段保持不变。
233
248
 
249
+ **注意**:\`read_rem\` 默认模式启用 Token Slimming——省略处于默认值的字段,未返回的字段即默认值(如 type 未显示 = "default",isTodo 未显示 = false,tags 未显示 = [])。需要完整字段用 \`full=true\`。
250
+
251
+ > **批量修改多个节点时**:改用 \`read_rem_in_tree\` 一次性获取全部节点属性,避免逐个 read_rem。详见场景 D2。
252
+
234
253
  #### edit_rem changes 示例
235
254
 
236
255
  \`\`\`jsonc
@@ -277,6 +296,22 @@ changes: { "type": "concept", "highlightColor": "Yellow", "fontSize": "H1" }
277
296
  - **todoStatus**:依赖 \`isTodo=true\` 才生效;清除 todo 应设 \`isTodo=false\`
278
297
  - **type**:不可设为 \`portal\`(Portal 只能通过 SDK \`createPortal()\` 或 edit_tree \`<!--portal-->\` 创建)
279
298
 
299
+ ### 场景 D2:批量读取 + 标注(课本划重点)
300
+
301
+ > "帮我标注这些笔记的重点"、"给关键词加高亮"、"批量修改格式"
302
+
303
+ 当需要读取一棵子树并对**多个节点**进行属性或富文本修改时,使用 \`read_rem_in_tree\` 一次性获取全部信息:
304
+
305
+ \`read_rem_in_tree\` → 同时获取大纲 + 每个节点的 RemObject(含完整 RichText)
306
+
307
+ 对目标节点直接调用 \`edit_rem\`(rem 缓存已就绪,无需再逐个 read_rem)
308
+ +
309
+ 如需结构变更,直接调用 \`edit_tree\`(tree 缓存已就绪,无需再 read_tree)
310
+
311
+ **为什么不用 read_tree + N×read_rem?** \`read_rem_in_tree\` 一次调用建立双重缓存,省去 N+1 次网络往返。对 30+ 节点的子树,差异是 1 次调用 vs 31+ 次调用。
312
+
313
+ **注意**:\`read_rem_in_tree\` 默认 maxNodes=50(每节点需 40+ SDK 调用),大子树需显式设置 maxNodes。
314
+
280
315
  ### 场景 E:修改结构(新增/删除/移动/重排)
281
316
 
282
317
  > "在这下面加几个子项"、"删掉这个"
@@ -306,52 +341,54 @@ changes: { "type": "concept", "highlightColor": "Yellow", "fontSize": "H1" }
306
341
  - \`<!--portal refs:id1,id2-->\`(创建并引用指定 Rem)
307
342
  - \`<!--portal-->\`(创建空 Portal)
308
343
 
309
- #### str_replace 构造示例
344
+ #### 行引用模板 \`{{remId}}\`
310
345
 
311
- **示例 1:在末尾新增行**
312
- \`\`\`
313
- oldStr:
314
- 最后一个兄弟 <!--idZ-->
315
- newStr:
316
- 最后一个兄弟 <!--idZ-->
317
- 新增行
318
- \`\`\`
346
+ 已有行支持两种写法:**模板模式**(推荐)和**完整匹配模式**(回退)。
347
+
348
+ **模板模式**(优先使用):用 \`{{remId}}\` 引用已有行,系统自动展开为完整内容(不含缩进)。节省 token、减少复制错误。
349
+ **完整匹配模式**(回退):直接从大纲复制完整行内容(含 \`<!--remId 元数据-->\`)。
350
+
351
+ **策略:优先模板,连续失败则回退**。如果模板模式连续 2+ 次因 ID 错误导致 \`old_str not found\`,说明当前上下文不足以准确引用 ID——立即切换到完整匹配模式(从最新大纲复制完整行内容),不要反复重试模板。
319
352
 
320
- **示例 2:删除一个带子节点的行(必须一起删)**
353
+ **模板模式示例**:
321
354
  \`\`\`
322
- oldStr:
323
- 子节点 A <!--idA-->
324
- 孙节点 A1 <!--idA1-->
325
- 子节点 B <!--idB-->
326
- newStr:
327
- 子节点 B <!--idB-->
355
+ # 重排两个节点
356
+ oldStr: " {{id1_1}}\\n {{id1_2}}"
357
+ newStr: " {{id1_2}}\\n {{id1_1}}"
358
+
359
+ # 删除带子节点的行
360
+ oldStr: " {{idA}}\\n {{idA1}}\\n {{idB}}"
361
+ newStr: " {{idB}}"
362
+
363
+ # 末尾新增行(新增行手动写,已有行用模板)
364
+ oldStr: " {{idZ}}"
365
+ newStr: " 新增行\\n {{idZ}}"
328
366
  \`\`\`
329
367
 
330
- **示例 3:创建多行闪卡**
368
+ **完整匹配模式示例**(回退时使用):
331
369
  \`\`\`
332
- oldStr:
333
- 子节点 A <!--idA-->
334
- newStr:
335
- 什么是线性回归? ↓
336
- 一种基本的回归分析方法
337
- 假设因变量与自变量呈线性关系
338
- 子节点 A <!--idA-->
370
+ oldStr: " 动态数组 <!--id1_1 type:concept-->\\n 静态数组 <!--id1_2 type:concept-->"
371
+ newStr: " 静态数组 <!--id1_2 type:concept-->\\n 动态数组 <!--id1_1 type:concept-->"
339
372
  \`\`\`
340
373
 
374
+ **模板规则**:
375
+ - \`{{remId}}\` 展开为**不含缩进**的完整行内容,缩进由你控制
376
+ - 只匹配纯字母数字(\`[a-zA-Z0-9]+\`),与 RemNote cloze 语法 \`{{text}}\` 不冲突
377
+ - 新增行不能用 \`{{}}\`(新增行没有 remId)
378
+ - 可以混用:部分行用 \`{{id}}\`,部分行手动写
379
+
341
380
  #### ⚠️ children_captured 详解
342
381
 
343
382
  在有子节点的 Rem 和其 children 之间插入新行,会导致新行"劫持"已有 children:
344
383
 
345
384
  \`\`\`
346
- 错误:插在父 Rem 和 children 之间
347
- oldStr: 水分子 <!--idA-->
348
- newStr: 水分子 ↓ <!--idA-->
349
- 新行 children 会变成新行的子节点!
385
+ 错误(模板):
386
+ oldStr: " {{idA}}" newStr: " {{idA}}\\n 新行" ← idA 有子节点,新行劫持 children!
387
+ 错误(完整匹配):
388
+ oldStr: " 水分子 ↓ <!--idA-->" newStr: " 水分子 ↓ <!--idA-->\\n 新行" 同理
350
389
 
351
390
  ✅ 正确:插在末尾
352
- oldStr: 最后一个兄弟 <!--idZ-->
353
- newStr: 最后一个兄弟 <!--idZ-->
354
- 新行
391
+ oldStr: " {{idZ}}" newStr: " {{idZ}}\\n 新行"
355
392
  \`\`\`
356
393
 
357
394
  #### 创建新节点并移动已有 children
@@ -381,7 +418,9 @@ newStr: 最后一个兄弟 <!--idZ-->
381
418
 
382
419
  ### 场景 G:排查连接问题
383
420
 
384
- \`health\` 检查三层状态:daemon 未运行 \`connect\`;Plugin 未连接 引导用户操作 RemNote;SDK 未就绪 等待重试。
421
+ \`health\` 默认查询所有活跃实例的三层状态(daemon / Plugin / SDK),返回 \`instances\` 数组。有 \`--instance\` \`--headless\` 时只查询指定实例。每个实例的 \`plugin.isTwin\` 标记是否为孪生连接。
422
+
423
+ 故障定位:无活跃实例 → \`connect\`;Plugin 未连接 → 引导用户操作 RemNote(标准模式下刷新页面或首次加载插件);SDK 未就绪 → 等待重试。
385
424
 
386
425
  ### 场景 H:管理增强项目
387
426
 
@@ -397,8 +436,9 @@ newStr: 最后一个兄弟 <!--idZ-->
397
436
 
398
437
  ### 黄金法则:先 read 再 edit
399
438
 
400
- - \`edit_rem\` 前必须先 \`read_rem\` 同一个 remId
401
- - \`edit_tree\` 前必须先 \`read_tree\` 同一个 remId
439
+ - \`edit_rem\` 前必须先 \`read_rem\` 或 \`read_rem_in_tree\` 同一个 remId
440
+ - \`edit_tree\` 前必须先 \`read_tree\` 或 \`read_rem_in_tree\` 同一个 remId
441
+ - \`read_rem_in_tree\` 同时建立两种缓存,调用后可直接 \`edit_tree\` 和 \`edit_rem\`
402
442
  - \`search\`/\`read_globe\`/\`read_context\` **不写入缓存**,不能作为 edit 前置
403
443
 
404
444
  跳过 read 直接 edit 会被拒绝(硬性要求)。
@@ -596,6 +636,7 @@ tags, sources, positionAmongstSiblings, portalDirectlyIncludedRem
596
636
 
597
637
  ### 诊断决策树
598
638
 
639
+
599
640
  \`\`\`
600
641
  命令报错
601
642
  ├─ "守护进程未运行" → connect 未执行或 daemon 已超时 → 执行 connect
@@ -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 后用户在自己的浏览器中加载 Plugin。优势:Agent 可通过 read_context 感知用户正在浏览的页面,实现协作\n- Headless 模式(headless=true,不推荐日常使用):自动启动 headless Chrome,无需用户操作。⚠️ 会丢失用户上下文(read_context 返回 headless 实例的上下文,不是用户浏览器的)。仅在以下场景使用:用户明确要求在服务器/无 GUI 环境运行、用户不想参与操作(全自动化)、用户不在 RemNote 前面。需先完成 setup\n\n⚠️ 标准模式 Plugin 加载(connect 后必须引导用户完成):\n本插件是开发者插件,不在 RemNote 插件市场中,禁止告诉用户去市场搜索安装。正确步骤:\n- 首次:RemNote → 左下角插件图标 → 「开发你的插件」→ 填入 Plugin 服务地址(如 http://localhost:29101)\n- 非首次:刷新 RemNote 页面即可\n引导用户完成后用 health 确认三层就绪再执行业务命令。\n\nHeadless 模式会话持久化:connect(headless=true) 后,MCP Server 自动记住 headless 状态,后续所有工具调用自动路由到 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
  });