remnote-bridge 0.1.10 → 0.1.12

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 (68) hide show
  1. package/dist/cli/addon/addon-manager.js +163 -0
  2. package/dist/cli/addon/registry.js +24 -0
  3. package/dist/cli/commands/addon.js +149 -0
  4. package/dist/cli/commands/clean.js +121 -52
  5. package/dist/cli/commands/connect.js +72 -33
  6. package/dist/cli/commands/disconnect.js +19 -19
  7. package/dist/cli/commands/edit-rem.js +3 -31
  8. package/dist/cli/commands/edit-tree.js +3 -20
  9. package/dist/cli/commands/health.js +19 -18
  10. package/dist/cli/commands/read-context.js +8 -23
  11. package/dist/cli/commands/read-globe.js +3 -20
  12. package/dist/cli/commands/read-rem.js +3 -31
  13. package/dist/cli/commands/read-tree.js +3 -20
  14. package/dist/cli/commands/search.js +97 -21
  15. package/dist/cli/config.js +148 -72
  16. package/dist/cli/daemon/daemon.js +104 -24
  17. package/dist/cli/daemon/dev-server.js +9 -1
  18. package/dist/cli/daemon/pid.js +36 -22
  19. package/dist/cli/daemon/registry.js +160 -0
  20. package/dist/cli/daemon/send-request.js +11 -11
  21. package/dist/cli/daemon/static-server.js +97 -34
  22. package/dist/cli/handlers/context-read-handler.js +5 -3
  23. package/dist/cli/handlers/read-handler.js +4 -3
  24. package/dist/cli/handlers/tree-parser.js +16 -9
  25. package/dist/cli/main.js +51 -9
  26. package/dist/cli/protocol.js +18 -4
  27. package/dist/cli/server/config-server.js +280 -14
  28. package/dist/cli/server/ws-server.js +93 -44
  29. package/dist/cli/utils/output.js +29 -0
  30. package/dist/mcp/instructions.js +103 -10
  31. package/dist/mcp/resources/edit-rem-guide.js +3 -4
  32. package/dist/mcp/resources/error-reference.js +5 -3
  33. package/dist/mcp/resources/rem-object-fields.js +3 -3
  34. package/dist/mcp/tools/infra-tools.js +54 -6
  35. package/dist/mcp/tools/read-tools.js +16 -3
  36. package/package.json +2 -2
  37. package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
  38. package/remnote-plugin/dist/bridge_widget.js +17 -17
  39. package/remnote-plugin/dist/index-sandbox.js +31 -31
  40. package/remnote-plugin/dist/index.js +31 -31
  41. package/remnote-plugin/dist/manifest.json +1 -1
  42. package/remnote-plugin/package.json +1 -1
  43. package/remnote-plugin/public/manifest.json +1 -1
  44. package/remnote-plugin/src/bridge/message-router.ts +1 -1
  45. package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
  46. package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
  47. package/remnote-plugin/src/services/index.ts +0 -8
  48. package/remnote-plugin/src/services/read-context.ts +13 -4
  49. package/remnote-plugin/src/services/read-rem.ts +1 -9
  50. package/remnote-plugin/src/services/search.ts +13 -10
  51. package/remnote-plugin/src/settings.ts +9 -7
  52. package/remnote-plugin/src/utils/index.ts +0 -5
  53. package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
  54. package/remnote-plugin/src/widgets/index.tsx +41 -44
  55. package/remnote-plugin/webpack.config.js +35 -0
  56. package/skills/remnote-bridge/SKILL.md +14 -9
  57. package/skills/remnote-bridge/instructions/addon.md +134 -0
  58. package/skills/remnote-bridge/instructions/clean.md +110 -0
  59. package/skills/remnote-bridge/instructions/connect.md +80 -37
  60. package/skills/remnote-bridge/instructions/disconnect.md +22 -9
  61. package/skills/remnote-bridge/instructions/edit-rem.md +37 -9
  62. package/skills/remnote-bridge/instructions/health.md +23 -13
  63. package/skills/remnote-bridge/instructions/install-skill.md +58 -0
  64. package/skills/remnote-bridge/instructions/overall.md +76 -21
  65. package/skills/remnote-bridge/instructions/read-context.md +34 -8
  66. package/skills/remnote-bridge/instructions/read-rem.md +10 -10
  67. package/skills/remnote-bridge/instructions/search.md +73 -14
  68. package/skills/remnote-bridge/instructions/setup.md +1 -1
@@ -61,7 +61,15 @@ export class DevServerManager {
61
61
  // shell: true 确保 Windows 上能找到 npm.cmd
62
62
  this.child = spawn('npm', ['run', 'dev'], {
63
63
  cwd: pluginDir,
64
- env: { ...process.env, PORT: String(port) },
64
+ env: {
65
+ ...process.env,
66
+ PORT: String(port),
67
+ // 供 webpack devServer.setupMiddlewares 劫持 /api/discovery
68
+ DISCOVERY_WS_PORT: process.env.SLOT_WS_PORT ?? '',
69
+ DISCOVERY_CONFIG_PORT: process.env.SLOT_CONFIG_PORT ?? '',
70
+ DISCOVERY_INSTANCE: process.env.REMNOTE_BRIDGE_INSTANCE ?? '',
71
+ DISCOVERY_SLOT_INDEX: process.env.SLOT_INDEX ?? '',
72
+ },
65
73
  stdio: 'pipe',
66
74
  shell: true,
67
75
  });
@@ -1,23 +1,28 @@
1
1
  /**
2
2
  * PID 文件管理
3
3
  *
4
- * 写入、读取、stale 检测、清理。
4
+ * PID 文件为 JSON 格式,包含 pid、slotIndex、instance 和端口信息。
5
+ * 路径:~/.remnote-bridge/instances/N.pid
5
6
  */
6
7
  import fs from 'fs';
8
+ import { execFileSync } from 'child_process';
7
9
  /**
8
- * 写入 PID 文件
10
+ * 写入 PID 文件(JSON 格式)
9
11
  */
10
- export function writePid(filePath, pid) {
11
- fs.writeFileSync(filePath, String(pid), 'utf-8');
12
+ export function writePid(filePath, info) {
13
+ fs.writeFileSync(filePath, JSON.stringify(info, null, 2) + '\n', 'utf-8');
12
14
  }
13
15
  /**
14
- * 读取 PID 文件。文件不存在返回 null。
16
+ * 读取 PID 文件。文件不存在或格式错误返回 null。
15
17
  */
16
- export function readPid(filePath) {
18
+ export function readPidInfo(filePath) {
17
19
  try {
18
- const content = fs.readFileSync(filePath, 'utf-8').trim();
19
- const pid = parseInt(content, 10);
20
- return isNaN(pid) ? null : pid;
20
+ const content = fs.readFileSync(filePath, 'utf-8');
21
+ const parsed = JSON.parse(content);
22
+ if (typeof parsed.pid === 'number' && typeof parsed.slotIndex === 'number') {
23
+ return parsed;
24
+ }
25
+ return null;
21
26
  }
22
27
  catch {
23
28
  return null;
@@ -47,21 +52,30 @@ export function isProcessAlive(pid) {
47
52
  }
48
53
  }
49
54
  /**
50
- * 检查守护进程状态。返回:
51
- * - { running: true, pid } — 守护进程正在运行
52
- * - { running: false } — 守护进程未运行(无 PID 文件或 stale)
55
+ * 检查进程是否是我们的 daemon。
53
56
  *
54
- * PID 文件存在但进程已死(stale),自动清理 PID 文件。
57
+ * kill -0 无法防止 PID recycling:OS 可能把同一 PID 分配给无关进程,
58
+ * 导致 cleanStaleSlots 误判槽位为"存活"而不清理。
59
+ * 此函数额外校验进程命令行是否包含 daemon 关键字。
55
60
  */
56
- export function checkDaemon(pidPath) {
57
- const pid = readPid(pidPath);
58
- if (pid === null) {
59
- return { running: false };
61
+ export function isDaemonAlive(pid) {
62
+ // PID 0 是 allocateSlot 的占位值,不是有效的 daemon PID
63
+ // process.kill(0, 0) 发信号给进程组,会误返回 true
64
+ if (pid <= 0)
65
+ return false;
66
+ if (!isProcessAlive(pid))
67
+ return false;
68
+ try {
69
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
70
+ encoding: 'utf-8',
71
+ timeout: 3000,
72
+ }).trim();
73
+ // daemon.ts 编译后路径包含 "daemon/daemon"(dist/cli/daemon/daemon.js)
74
+ // 使用更精确的匹配避免误匹配 dockerd 等含 "daemon" 的无关进程
75
+ return cmd.includes('daemon/daemon') || cmd.includes('daemon.js');
60
76
  }
61
- if (isProcessAlive(pid)) {
62
- return { running: true, pid };
77
+ catch {
78
+ // ps 失败时回退到基本检查(kill -0 已通过)
79
+ return true;
63
80
  }
64
- // stale PID 文件,清理
65
- removePid(pidPath);
66
- return { running: false };
67
81
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 多实例注册表
3
+ *
4
+ * 管理最多 4 个 daemon 实例的端口槽位分配。
5
+ * 所有运行时文件存放在 ~/.remnote-bridge/:
6
+ * config.json — 全局配置
7
+ * slots.json — 4 组端口定义
8
+ * registry.json — instance → slot 映射
9
+ * instances/N.pid — 槽位 PID 文件(JSON)
10
+ * instances/N.log — 槽位日志
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { isDaemonAlive } from './pid.js';
15
+ import { GLOBAL_DIR, ensureGlobalDir } from '../config.js';
16
+ // ── 常量 ──
17
+ export const MAX_SLOTS = 4;
18
+ const SLOTS_FILE = path.join(GLOBAL_DIR, 'slots.json');
19
+ const REGISTRY_FILE = path.join(GLOBAL_DIR, 'registry.json');
20
+ const INSTANCES_DIR = path.join(GLOBAL_DIR, 'instances');
21
+ /** 默认 4 组端口槽位(29100 段,高位端口避免冲突) */
22
+ export const DEFAULT_SLOTS = [
23
+ { wsPort: 29100, devServerPort: 29101, configPort: 29102 },
24
+ { wsPort: 29110, devServerPort: 29111, configPort: 29112 },
25
+ { wsPort: 29120, devServerPort: 29121, configPort: 29122 },
26
+ { wsPort: 29130, devServerPort: 29131, configPort: 29132 },
27
+ ];
28
+ // ── slots.json ──
29
+ export function loadSlots() {
30
+ try {
31
+ const raw = fs.readFileSync(SLOTS_FILE, 'utf-8');
32
+ const parsed = JSON.parse(raw);
33
+ if (Array.isArray(parsed) && parsed.length === MAX_SLOTS) {
34
+ return parsed;
35
+ }
36
+ }
37
+ catch {
38
+ // 不存在或损坏
39
+ }
40
+ // 自动生成默认
41
+ ensureGlobalDir();
42
+ const slots = [...DEFAULT_SLOTS];
43
+ fs.writeFileSync(SLOTS_FILE, JSON.stringify(slots, null, 2) + '\n', 'utf-8');
44
+ return slots;
45
+ }
46
+ // ── registry.json ──
47
+ export function loadRegistry() {
48
+ try {
49
+ const raw = fs.readFileSync(REGISTRY_FILE, 'utf-8');
50
+ const parsed = JSON.parse(raw);
51
+ if (parsed && parsed.version === 1 && Array.isArray(parsed.slots)) {
52
+ // 确保 slots 数组长度正确
53
+ while (parsed.slots.length < MAX_SLOTS)
54
+ parsed.slots.push(null);
55
+ return parsed;
56
+ }
57
+ }
58
+ catch {
59
+ // 不存在或损坏
60
+ }
61
+ return { version: 1, slots: new Array(MAX_SLOTS).fill(null) };
62
+ }
63
+ export function saveRegistry(registry) {
64
+ ensureGlobalDir();
65
+ const tmpPath = REGISTRY_FILE + '.tmp.' + process.pid;
66
+ fs.writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
67
+ fs.renameSync(tmpPath, REGISTRY_FILE);
68
+ }
69
+ // ── 槽位操作 ──
70
+ /** 清理 stale 槽位(进程已死但注册表未更新) */
71
+ export function cleanStaleSlots(registry) {
72
+ let changed = false;
73
+ for (let i = 0; i < registry.slots.length; i++) {
74
+ const entry = registry.slots[i];
75
+ if (entry && !isDaemonAlive(entry.pid)) {
76
+ registry.slots[i] = null;
77
+ // 清理对应的 PID 文件
78
+ try {
79
+ fs.unlinkSync(instancePidPath(i));
80
+ }
81
+ catch { /* ignore */ }
82
+ changed = true;
83
+ }
84
+ }
85
+ if (changed) {
86
+ saveRegistry(registry);
87
+ }
88
+ return changed;
89
+ }
90
+ /** 根据 instance 名查找已分配的槽位 */
91
+ export function findSlotByInstance(registry, instanceId) {
92
+ return registry.slots.find((e) => e?.instance === instanceId) ?? null;
93
+ }
94
+ /** 分配一个空闲槽位,返回槽位索引;无空闲返回 null */
95
+ export function allocateSlot(registry, instanceId, pid) {
96
+ const slots = loadSlots();
97
+ const freeIndex = registry.slots.findIndex((e) => e === null);
98
+ if (freeIndex === -1)
99
+ return null;
100
+ const ports = slots[freeIndex];
101
+ const entry = {
102
+ index: freeIndex,
103
+ instance: instanceId,
104
+ pid,
105
+ wsPort: ports.wsPort,
106
+ devServerPort: ports.devServerPort,
107
+ configPort: ports.configPort,
108
+ startedAt: new Date().toISOString(),
109
+ };
110
+ registry.slots[freeIndex] = entry;
111
+ saveRegistry(registry);
112
+ return entry;
113
+ }
114
+ /** 释放指定 instance 的槽位 */
115
+ export function releaseSlot(registry, instanceId) {
116
+ for (let i = 0; i < registry.slots.length; i++) {
117
+ if (registry.slots[i]?.instance === instanceId) {
118
+ registry.slots[i] = null;
119
+ break;
120
+ }
121
+ }
122
+ saveRegistry(registry);
123
+ }
124
+ // ── 实例路径 ──
125
+ /** 槽位 PID 文件路径 */
126
+ export function instancePidPath(slotIndex) {
127
+ return path.join(INSTANCES_DIR, `${slotIndex}.pid`);
128
+ }
129
+ /** 槽位日志文件路径 */
130
+ export function instanceLogPath(slotIndex) {
131
+ return path.join(INSTANCES_DIR, `${slotIndex}.log`);
132
+ }
133
+ // ── 实例标识解析 ──
134
+ /**
135
+ * 解析实例标识。
136
+ *
137
+ * 优先级:REMNOTE_HEADLESS(最高,覆盖一切)> cliArg > REMNOTE_BRIDGE_INSTANCE > "default"
138
+ */
139
+ export function resolveInstanceId(cliArg) {
140
+ // headless 模式覆盖 --instance,固定实例名
141
+ if (process.env.REMNOTE_HEADLESS === '1')
142
+ return 'headless';
143
+ if (cliArg)
144
+ return cliArg;
145
+ const fromEnv = process.env.REMNOTE_BRIDGE_INSTANCE;
146
+ if (fromEnv)
147
+ return fromEnv;
148
+ return 'default';
149
+ }
150
+ // ── 满载报错信息 ──
151
+ export function formatSlotsFullError(registry) {
152
+ const lines = [`错误: 已达最大实例数上限(${MAX_SLOTS}),无可用槽位。`, '', '运行中的实例:'];
153
+ for (const entry of registry.slots) {
154
+ if (entry) {
155
+ lines.push(` 槽位 ${entry.index}: ${entry.instance} (PID: ${entry.pid})`);
156
+ }
157
+ }
158
+ lines.push('', '请先执行 `remnote-bridge --instance <name> disconnect` 释放槽位。');
159
+ return lines.join('\n');
160
+ }
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * 封装 WS 连接建立、请求发送、响应等待、超时处理的完整流程。
5
5
  * 所有业务命令(health、read-rem、edit-rem 等)均通过此函数与 daemon 通信。
6
+ *
7
+ * 端口发现:通过 registry.json 查找当前 instance 对应的槽位端口。
6
8
  */
7
9
  import WebSocket from 'ws';
8
10
  import crypto from 'crypto';
9
- import { loadConfig, pidFilePath, findProjectRoot } from '../config.js';
10
- import { checkDaemon } from './pid.js';
11
+ import { resolveInstanceId, loadRegistry, cleanStaleSlots, findSlotByInstance } from './registry.js';
11
12
  import { isBridgeResponse } from '../protocol.js';
12
13
  const CONNECT_TIMEOUT_MS = 5_000;
13
14
  const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000;
@@ -26,24 +27,23 @@ export class DaemonUnreachableError extends Error {
26
27
  /**
27
28
  * 向守护进程发送请求并等待响应。
28
29
  *
29
- * 流程:读取 PID 文件 → 建立 WS 连接 → 发送 BridgeRequest 等待 BridgeResponse → 关闭连接
30
+ * 流程:解析 instance 查 registry → 建立 WS 连接 → 发送请求等待响应 → 关闭连接
30
31
  *
31
- * @throws DaemonNotRunningError — PID 文件不存在或进程已死
32
+ * @throws DaemonNotRunningError — registry 中找不到当前 instance 或进程已死
32
33
  * @throws DaemonUnreachableError — WS 连接失败
33
34
  * @throws Error — daemon 返回 error 字段或响应超时
34
35
  */
35
36
  export async function sendDaemonRequest(action, payload = {}, options) {
36
- const projectRoot = findProjectRoot();
37
- const config = loadConfig(projectRoot);
38
- const pidPath = pidFilePath(projectRoot);
39
- // 检查 daemon 是否运行
40
- const daemonStatus = checkDaemon(pidPath);
41
- if (!daemonStatus.running) {
37
+ const instanceId = resolveInstanceId(options?.instance);
38
+ const registry = loadRegistry();
39
+ cleanStaleSlots(registry);
40
+ const entry = findSlotByInstance(registry, instanceId);
41
+ if (!entry) {
42
42
  throw new DaemonNotRunningError();
43
43
  }
44
44
  const responseTimeout = options?.timeout ?? DEFAULT_RESPONSE_TIMEOUT_MS;
45
45
  return new Promise((resolve, reject) => {
46
- const ws = new WebSocket(`ws://127.0.0.1:${config.wsPort}`);
46
+ const ws = new WebSocket(`ws://127.0.0.1:${entry.wsPort}`);
47
47
  const requestId = crypto.randomUUID();
48
48
  let responseTimer = null;
49
49
  const connectTimer = setTimeout(() => {
@@ -18,55 +18,118 @@ const MIME_TYPES = {
18
18
  };
19
19
  export class StaticServer {
20
20
  server = null;
21
+ _actualPort = 0;
21
22
  options;
23
+ _discovery = null;
24
+ /** 实际监听的端口(可能与 options.port 不同,若原端口被占用则 OS 自动分配) */
25
+ get actualPort() { return this._actualPort; }
22
26
  constructor(options) {
23
27
  this.options = options;
24
28
  }
29
+ /** 动态更新 discovery 数据(daemon 启动后设置实际端口) */
30
+ setDiscovery(data) {
31
+ this._discovery = data;
32
+ }
25
33
  start() {
26
34
  const { distDir, port, onLog } = this.options;
27
- return new Promise((resolve, reject) => {
28
- this.server = http.createServer((req, res) => {
29
- // CORS headers(与 webpack.config.js 一致)
30
- res.setHeader('Access-Control-Allow-Origin', '*');
31
- res.setHeader('Access-Control-Allow-Headers', 'baggage, sentry-trace');
32
- // OPTIONS preflight
33
- if (req.method === 'OPTIONS') {
34
- res.writeHead(204);
35
- res.end();
36
- return;
35
+ const createHttpServer = () => http.createServer((req, res) => {
36
+ // CORS headers(与 webpack.config.js 一致)
37
+ res.setHeader('Access-Control-Allow-Origin', '*');
38
+ res.setHeader('Access-Control-Allow-Headers', 'baggage, sentry-trace');
39
+ // OPTIONS preflight
40
+ if (req.method === 'OPTIONS') {
41
+ res.writeHead(204);
42
+ res.end();
43
+ return;
44
+ }
45
+ const urlPath = req.url?.split('?')[0] || '/';
46
+ // /api/discovery 端点:返回 daemon 端口信息,供 Plugin 自动发现
47
+ if (urlPath === '/api/discovery') {
48
+ if (this._discovery) {
49
+ res.writeHead(200, { 'Content-Type': 'application/json' });
50
+ res.end(JSON.stringify(this._discovery));
37
51
  }
38
- const urlPath = req.url?.split('?')[0] || '/';
39
- const filePath = path.resolve(distDir, urlPath === '/' ? 'index.html' : '.' + urlPath);
40
- // 防止目录遍历(resolve 规范化后,确保仍在 distDir + sep 下)
41
- const safePrefix = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
42
- if (!filePath.startsWith(safePrefix) && filePath !== distDir) {
43
- res.writeHead(403);
44
- res.end('Forbidden');
45
- return;
52
+ else {
53
+ res.writeHead(503, { 'Content-Type': 'application/json' });
54
+ res.end(JSON.stringify({ error: 'discovery not ready' }));
46
55
  }
47
- fs.readFile(filePath, (err, data) => {
56
+ return;
57
+ }
58
+ // 多实例支持:非 default 实例动态修改 manifest.json 的 id 和 name,
59
+ // 使 RemNote 能同时加载多个同源 Plugin(RemNote 按 id 去重)
60
+ if (urlPath === '/manifest.json' && this._discovery && this._discovery.instance !== 'default') {
61
+ const manifestPath = path.resolve(distDir, 'manifest.json');
62
+ fs.readFile(manifestPath, 'utf-8', (err, raw) => {
48
63
  if (err) {
49
64
  res.writeHead(404);
50
65
  res.end('Not Found');
51
66
  return;
52
67
  }
53
- const ext = path.extname(filePath);
54
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
55
- res.writeHead(200, {
56
- 'Content-Type': contentType,
57
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
58
- });
59
- res.end(data);
68
+ try {
69
+ const manifest = JSON.parse(raw);
70
+ const suffix = this._discovery.instance;
71
+ manifest.id = `${manifest.id}_${suffix}`;
72
+ manifest.name = `${manifest.name} (${suffix})`;
73
+ res.writeHead(200, {
74
+ 'Content-Type': 'application/json',
75
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
76
+ });
77
+ res.end(JSON.stringify(manifest, null, 2));
78
+ }
79
+ catch {
80
+ res.writeHead(500);
81
+ res.end('manifest parse error');
82
+ }
60
83
  });
84
+ return;
85
+ }
86
+ const filePath = path.resolve(distDir, urlPath === '/' ? 'index.html' : '.' + urlPath);
87
+ // 防止目录遍历(resolve 规范化后,确保仍在 distDir + sep 下)
88
+ const safePrefix = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
89
+ if (!filePath.startsWith(safePrefix) && filePath !== distDir) {
90
+ res.writeHead(403);
91
+ res.end('Forbidden');
92
+ return;
93
+ }
94
+ fs.readFile(filePath, (err, data) => {
95
+ if (err) {
96
+ res.writeHead(404);
97
+ res.end('Not Found');
98
+ return;
99
+ }
100
+ const ext = path.extname(filePath);
101
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
102
+ res.writeHead(200, {
103
+ 'Content-Type': contentType,
104
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
105
+ });
106
+ res.end(data);
61
107
  });
62
- this.server.on('error', (err) => {
63
- onLog?.(`[static-server] 启动失败: ${err.message}`, 'error');
64
- reject(err);
65
- });
66
- this.server.listen(port, '127.0.0.1', () => {
67
- onLog?.(`[static-server] 已启动 http://127.0.0.1:${port} (serving ${distDir})`, 'info');
68
- resolve();
69
- });
108
+ });
109
+ return new Promise((resolve, reject) => {
110
+ const tryListen = (listenPort) => {
111
+ this.server = createHttpServer();
112
+ this.server.on('error', (err) => {
113
+ if (err.code === 'EADDRINUSE' && listenPort !== 0) {
114
+ onLog?.(`[static-server] 端口 ${listenPort} 被占用,尝试自动分配...`, 'warn');
115
+ this.server = null;
116
+ tryListen(0);
117
+ }
118
+ else {
119
+ onLog?.(`[static-server] 启动失败: ${err.message}`, 'error');
120
+ reject(err);
121
+ }
122
+ });
123
+ this.server.listen(listenPort, '127.0.0.1', () => {
124
+ this._actualPort = this.server.address().port;
125
+ if (this._actualPort !== port) {
126
+ onLog?.(`[static-server] 端口 ${port} 被占用,自动分配到 ${this._actualPort}`, 'warn');
127
+ }
128
+ onLog?.(`[static-server] 已启动 http://127.0.0.1:${this._actualPort} (serving ${distDir})`, 'info');
129
+ resolve();
130
+ });
131
+ };
132
+ tryListen(port);
70
133
  });
71
134
  }
72
135
  stop() {
@@ -17,8 +17,10 @@ export class ContextReadHandler {
17
17
  const depth = payload.depth ?? this.defaults.readContextDepth;
18
18
  const maxNodes = payload.maxNodes ?? this.defaults.maxNodes;
19
19
  const maxSiblings = payload.maxSiblings ?? this.defaults.maxSiblings;
20
- return await this.forwardToPlugin('read_context', {
21
- mode, ancestorLevels, depth, maxNodes, maxSiblings,
22
- });
20
+ const focusRemId = payload.focusRemId;
21
+ const pluginPayload = { mode, ancestorLevels, depth, maxNodes, maxSiblings };
22
+ if (focusRemId)
23
+ pluginPayload.focusRemId = focusRemId;
24
+ return await this.forwardToPlugin('read_context', pluginPayload);
23
25
  }
24
26
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  /** R-F 字段(仅 --full 模式输出,默认不输出) */
10
10
  const RF_FIELDS = new Set([
11
+ 'children',
11
12
  'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
12
13
  'isPowerupPropertyListItem', 'isPowerupSlot',
13
14
  'deepRemsBeingReferenced',
@@ -17,10 +18,10 @@ const RF_FIELDS = new Set([
17
18
  'embeddedQueueViewMode',
18
19
  'localUpdatedAt', 'lastPracticed',
19
20
  ]);
20
- /** Portal 简化输出字段(type === 'portal' 时默认输出这 9 个字段) */
21
+ /** Portal 简化输出字段(type === 'portal' 时默认输出这 8 个字段) */
21
22
  export const PORTAL_FIELDS = [
22
23
  'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
23
- 'parent', 'positionAmongstSiblings', 'children',
24
+ 'parent', 'positionAmongstSiblings',
24
25
  'createdAt', 'updatedAt',
25
26
  ];
26
27
  export class ReadHandler {
@@ -66,7 +67,7 @@ export class ReadHandler {
66
67
  }
67
68
  }
68
69
  else if (remObject.type === 'portal') {
69
- // Portal 简化模式:只输出 9 个关键字段
70
+ // Portal 简化模式:只输出 8 个关键字段
70
71
  const obj = remObject;
71
72
  result = {};
72
73
  for (const field of PORTAL_FIELDS) {
@@ -197,15 +197,22 @@ export function parsePowerupPrefix(rawContent) {
197
197
  }
198
198
  }
199
199
  // 尾部箭头(无 backText,multiline)
200
+ // 支持有空格 ` ↓` 和无空格 `)↓` 两种写法(模型常漏空格)
200
201
  if (backText === undefined) {
201
- const tailArrows = [
202
- [' ↕', 'both'],
203
- [' ↓', 'forward'],
204
- [' ↑', 'backward'],
202
+ const tailArrowChars = [
203
+ ['↕', 'both'],
204
+ ['↓', 'forward'],
205
+ ['↑', 'backward'],
205
206
  ];
206
- for (const [arrow, dir] of tailArrows) {
207
- if (content.endsWith(arrow)) {
208
- content = content.slice(0, -arrow.length);
207
+ for (const [ch, dir] of tailArrowChars) {
208
+ if (content.endsWith(` ${ch}`)) {
209
+ content = content.slice(0, -(ch.length + 1)); // 去掉 ` ↓`
210
+ practiceDirection = dir;
211
+ isMultiline = true;
212
+ break;
213
+ }
214
+ if (content.endsWith(ch)) {
215
+ content = content.slice(0, -ch.length); // 去掉 `↓`(无空格)
209
216
  practiceDirection = dir;
210
217
  isMultiline = true;
211
218
  break;
@@ -227,9 +234,9 @@ export function parsePowerupPrefix(rawContent) {
227
234
  return result;
228
235
  }
229
236
  // ────────────────────────── Multiline 检测 ──────────────────────────
230
- /** multiline 箭头正则:中间箭头 ↓↑↕ 或尾部箭头 ↓↑↕ */
237
+ /** multiline 箭头正则:中间箭头 ↓↑↕ 或尾部箭头 ↓↑↕(允许有无空格) */
231
238
  const MULTILINE_MID_RE = / [↓↑↕] /;
232
- const MULTILINE_TAIL_RE = / [↓↑↕]$/;
239
+ const MULTILINE_TAIL_RE = /[↓↑↕]$/;
233
240
  /** 从行内容判断是否为 multiline 父节点(内容包含 ↓↑↕ 箭头) */
234
241
  export function isContentMultiline(rawContent) {
235
242
  return MULTILINE_MID_RE.test(rawContent) || MULTILINE_TAIL_RE.test(rawContent);
package/dist/cli/main.js CHANGED
@@ -19,6 +19,7 @@ import { readContextCommand } from './commands/read-context.js';
19
19
  import { searchCommand } from './commands/search.js';
20
20
  import { installSkillCommand, installSkillCopyCommand } from './commands/install-skill.js';
21
21
  import { cleanCommand } from './commands/clean.js';
22
+ import { addonListCommand, addonInstallCommand, addonUninstallCommand } from './commands/addon.js';
22
23
  const require = createRequire(import.meta.url);
23
24
  const { version } = require('../../package.json');
24
25
  const program = new Command();
@@ -59,7 +60,23 @@ program
59
60
  .name('remnote-bridge')
60
61
  .description('RemNote Bridge — CLI + MCP Server + Plugin')
61
62
  .version(version)
62
- .option('--json', '以 JSON 格式输出(适用于程序化调用)');
63
+ .option('--json', '以 JSON 格式输出(适用于程序化调用)')
64
+ .option('--instance <name>', '指定 daemon 实例名(也可用 REMNOTE_BRIDGE_INSTANCE 环境变量)')
65
+ .option('--headless', '使用 headless 实例(覆盖 --instance,也可用 REMNOTE_HEADLESS=1 环境变量)');
66
+ // 全局参数同步到环境变量,使所有命令中的 resolveInstanceId() 自动生效
67
+ program.hook('preAction', () => {
68
+ const opts = program.opts();
69
+ const headlessEnv = process.env.REMNOTE_HEADLESS;
70
+ const isHeadless = opts.headless || headlessEnv === '1' || headlessEnv === 'true';
71
+ if (isHeadless) {
72
+ // headless 覆盖 instance,固定实例名
73
+ process.env.REMNOTE_HEADLESS = '1';
74
+ process.env.REMNOTE_BRIDGE_INSTANCE = 'headless';
75
+ }
76
+ else if (opts.instance) {
77
+ process.env.REMNOTE_BRIDGE_INSTANCE = opts.instance;
78
+ }
79
+ });
63
80
  program
64
81
  .command('setup')
65
82
  .description('启动 Chrome 让用户登录 RemNote(headless 模式前置步骤)')
@@ -71,11 +88,10 @@ program
71
88
  .command('connect')
72
89
  .description('启动守护进程,等待 Plugin 连接')
73
90
  .option('--dev', '开发模式:使用 webpack-dev-server(支持 HMR)')
74
- .option('--headless', '无头模式:自动启动 headless Chrome 加载 Plugin(需先 setup)')
75
- .option('--remote-debugging-port <port>', 'Chrome 远程调试端口(仅 --headless)', parseInt)
91
+ .option('--remote-debugging-port <port>', 'Chrome 远程调试端口(仅 headless 模式)', parseInt)
76
92
  .action(async (cmdOpts) => {
77
- const { json } = program.opts();
78
- await connectCommand({ json, dev: cmdOpts.dev, headless: cmdOpts.headless, remoteDebuggingPort: cmdOpts.remoteDebuggingPort });
93
+ const { json, instance } = program.opts();
94
+ await connectCommand({ json, instance, dev: cmdOpts.dev, remoteDebuggingPort: cmdOpts.remoteDebuggingPort });
79
95
  });
80
96
  program
81
97
  .command('health')
@@ -83,15 +99,15 @@ program
83
99
  .option('--diagnose', '诊断 headless Chrome(截图 + 状态 + console 错误)')
84
100
  .option('--reload', '重载 headless Chrome 页面')
85
101
  .action(async (cmdOpts) => {
86
- const { json } = program.opts();
87
- await healthCommand({ json, diagnose: cmdOpts.diagnose, reload: cmdOpts.reload });
102
+ const { json, instance } = program.opts();
103
+ await healthCommand({ json, instance, diagnose: cmdOpts.diagnose, reload: cmdOpts.reload });
88
104
  });
89
105
  program
90
106
  .command('disconnect')
91
107
  .description('停止守护进程,释放端口和资源')
92
108
  .action(async () => {
93
- const { json } = program.opts();
94
- await disconnectCommand({ json });
109
+ const { json, instance } = program.opts();
110
+ await disconnectCommand({ json, instance });
95
111
  });
96
112
  program
97
113
  .command('read-rem [remIdOrJson]')
@@ -187,6 +203,7 @@ program
187
203
  .option('--depth <depth>', '展开深度(默认 3,仅 page 模式)')
188
204
  .option('--max-nodes <maxNodes>', '全局节点上限(默认 200)')
189
205
  .option('--max-siblings <maxSiblings>', '每个父节点下展示的 children 上限(默认 20)')
206
+ .option('--focus-rem-id <remId>', '指定鱼眼中心 Rem ID(仅 focus 模式,默认使用当前焦点)')
190
207
  .action(async (jsonStr, cmdOpts) => {
191
208
  const { json } = program.opts();
192
209
  if (json) {
@@ -208,6 +225,7 @@ program
208
225
  depth: input.depth?.toString(),
209
226
  maxNodes: input.maxNodes?.toString(),
210
227
  maxSiblings: input.maxSiblings?.toString(),
228
+ focusRemId: input.focusRemId ? String(input.focusRemId) : undefined,
211
229
  });
212
230
  }
213
231
  else {
@@ -329,4 +347,28 @@ program
329
347
  const { json } = program.opts();
330
348
  await cleanCommand({ json });
331
349
  });
350
+ // addon 子命令组
351
+ const addonCmd = program.command('addon').description('管理增强项目(addon)');
352
+ addonCmd
353
+ .command('list')
354
+ .description('查看所有增强项目状态')
355
+ .action(async () => {
356
+ const { json } = program.opts();
357
+ await addonListCommand({ json });
358
+ });
359
+ addonCmd
360
+ .command('install <name>')
361
+ .description('安装指定增强项目')
362
+ .action(async (name) => {
363
+ const { json } = program.opts();
364
+ await addonInstallCommand(name, { json });
365
+ });
366
+ addonCmd
367
+ .command('uninstall <name>')
368
+ .description('卸载指定增强项目')
369
+ .option('--purge', '同时删除数据目录')
370
+ .action(async (name, cmdOpts) => {
371
+ const { json } = program.opts();
372
+ await addonUninstallCommand(name, { json, purge: cmdOpts.purge });
373
+ });
332
374
  program.parse();