remnote-bridge 0.1.11 → 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.
- package/dist/cli/addon/addon-manager.js +163 -0
- package/dist/cli/addon/registry.js +24 -0
- package/dist/cli/commands/addon.js +149 -0
- package/dist/cli/commands/clean.js +121 -52
- package/dist/cli/commands/connect.js +72 -33
- package/dist/cli/commands/disconnect.js +19 -19
- package/dist/cli/commands/edit-rem.js +3 -31
- package/dist/cli/commands/edit-tree.js +3 -20
- package/dist/cli/commands/health.js +19 -18
- package/dist/cli/commands/read-context.js +3 -20
- package/dist/cli/commands/read-globe.js +3 -20
- package/dist/cli/commands/read-rem.js +3 -31
- package/dist/cli/commands/read-tree.js +3 -20
- package/dist/cli/commands/search.js +97 -21
- package/dist/cli/config.js +148 -72
- package/dist/cli/daemon/daemon.js +104 -24
- package/dist/cli/daemon/dev-server.js +9 -1
- package/dist/cli/daemon/pid.js +36 -22
- package/dist/cli/daemon/registry.js +160 -0
- package/dist/cli/daemon/send-request.js +11 -11
- package/dist/cli/daemon/static-server.js +97 -34
- package/dist/cli/handlers/read-handler.js +4 -3
- package/dist/cli/handlers/tree-parser.js +16 -9
- package/dist/cli/main.js +49 -9
- package/dist/cli/protocol.js +18 -4
- package/dist/cli/server/config-server.js +280 -14
- package/dist/cli/server/ws-server.js +93 -44
- package/dist/cli/utils/output.js +29 -0
- package/dist/mcp/instructions.js +101 -9
- package/dist/mcp/resources/edit-rem-guide.js +3 -4
- package/dist/mcp/resources/error-reference.js +2 -2
- package/dist/mcp/resources/rem-object-fields.js +3 -3
- package/dist/mcp/tools/infra-tools.js +54 -6
- package/dist/mcp/tools/read-tools.js +9 -2
- package/package.json +2 -2
- package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
- package/remnote-plugin/dist/bridge_widget.js +17 -17
- package/remnote-plugin/dist/index-sandbox.js +31 -31
- package/remnote-plugin/dist/index.js +31 -31
- package/remnote-plugin/dist/manifest.json +1 -1
- package/remnote-plugin/package.json +1 -1
- package/remnote-plugin/public/manifest.json +1 -1
- package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
- package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
- package/remnote-plugin/src/services/index.ts +0 -8
- package/remnote-plugin/src/services/read-rem.ts +1 -9
- package/remnote-plugin/src/services/search.ts +13 -10
- package/remnote-plugin/src/settings.ts +9 -7
- package/remnote-plugin/src/utils/index.ts +0 -5
- package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
- package/remnote-plugin/src/widgets/index.tsx +41 -44
- package/remnote-plugin/webpack.config.js +35 -0
- package/skills/remnote-bridge/SKILL.md +14 -9
- package/skills/remnote-bridge/instructions/addon.md +134 -0
- package/skills/remnote-bridge/instructions/clean.md +110 -0
- package/skills/remnote-bridge/instructions/connect.md +80 -37
- package/skills/remnote-bridge/instructions/disconnect.md +22 -9
- package/skills/remnote-bridge/instructions/edit-rem.md +37 -9
- package/skills/remnote-bridge/instructions/health.md +23 -13
- package/skills/remnote-bridge/instructions/install-skill.md +58 -0
- package/skills/remnote-bridge/instructions/overall.md +76 -21
- package/skills/remnote-bridge/instructions/read-rem.md +10 -10
- package/skills/remnote-bridge/instructions/search.md +73 -14
- package/skills/remnote-bridge/instructions/setup.md +1 -1
package/dist/cli/daemon/pid.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PID 文件管理
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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,
|
|
11
|
-
fs.writeFileSync(filePath,
|
|
12
|
+
export function writePid(filePath, info) {
|
|
13
|
+
fs.writeFileSync(filePath, JSON.stringify(info, null, 2) + '\n', 'utf-8');
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
|
-
* 读取 PID
|
|
16
|
+
* 读取 PID 文件。文件不存在或格式错误返回 null。
|
|
15
17
|
*/
|
|
16
|
-
export function
|
|
18
|
+
export function readPidInfo(filePath) {
|
|
17
19
|
try {
|
|
18
|
-
const content = fs.readFileSync(filePath, 'utf-8')
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
-
*
|
|
57
|
+
* 仅 kill -0 无法防止 PID recycling:OS 可能把同一 PID 分配给无关进程,
|
|
58
|
+
* 导致 cleanStaleSlots 误判槽位为"存活"而不清理。
|
|
59
|
+
* 此函数额外校验进程命令行是否包含 daemon 关键字。
|
|
55
60
|
*/
|
|
56
|
-
export function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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 {
|
|
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
|
-
*
|
|
30
|
+
* 流程:解析 instance → 查 registry → 建立 WS 连接 → 发送请求 → 等待响应 → 关闭连接
|
|
30
31
|
*
|
|
31
|
-
* @throws DaemonNotRunningError —
|
|
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
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:${
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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() {
|
|
@@ -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' 时默认输出这
|
|
21
|
+
/** Portal 简化输出字段(type === 'portal' 时默认输出这 8 个字段) */
|
|
21
22
|
export const PORTAL_FIELDS = [
|
|
22
23
|
'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
|
|
23
|
-
'parent', 'positionAmongstSiblings',
|
|
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 简化模式:只输出
|
|
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
|
|
202
|
-
['
|
|
203
|
-
['
|
|
204
|
-
['
|
|
202
|
+
const tailArrowChars = [
|
|
203
|
+
['↕', 'both'],
|
|
204
|
+
['↓', 'forward'],
|
|
205
|
+
['↑', 'backward'],
|
|
205
206
|
];
|
|
206
|
-
for (const [
|
|
207
|
-
if (content.endsWith(
|
|
208
|
-
content = content.slice(0, -
|
|
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('--
|
|
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,
|
|
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]')
|
|
@@ -331,4 +347,28 @@ program
|
|
|
331
347
|
const { json } = program.opts();
|
|
332
348
|
await cleanCommand({ json });
|
|
333
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
|
+
});
|
|
334
374
|
program.parse();
|
package/dist/cli/protocol.js
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
// ── 消息类型判断辅助 ──
|
|
8
8
|
export function isHelloMessage(msg) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
if (typeof msg !== 'object' || msg === null)
|
|
10
|
+
return false;
|
|
11
|
+
const obj = msg;
|
|
12
|
+
return (obj.type === 'hello' &&
|
|
13
|
+
typeof obj.version === 'string' &&
|
|
14
|
+
typeof obj.twinSlotIndex === 'number' &&
|
|
15
|
+
Number.isInteger(obj.twinSlotIndex) &&
|
|
16
|
+
obj.twinSlotIndex >= 0 &&
|
|
17
|
+
obj.twinSlotIndex <= 3);
|
|
13
18
|
}
|
|
14
19
|
export function isPingMessage(msg) {
|
|
15
20
|
return (typeof msg === 'object' &&
|
|
@@ -33,3 +38,12 @@ export function isBridgeResponse(msg) {
|
|
|
33
38
|
typeof msg.id === 'string' &&
|
|
34
39
|
!('action' in msg));
|
|
35
40
|
}
|
|
41
|
+
// ── WS Close Codes ──
|
|
42
|
+
/** 已有其他 Plugin 连接(非孪生),拒绝 */
|
|
43
|
+
export const WS_CLOSE_OTHER_CONNECTED = 4000;
|
|
44
|
+
/** 心跳超时,断开连接 */
|
|
45
|
+
export const WS_CLOSE_PONG_TIMEOUT = 4001;
|
|
46
|
+
/** 被孪生 Plugin 抢占(daemon 主动断开非孪生连接) */
|
|
47
|
+
export const WS_CLOSE_PREEMPTED = 4002;
|
|
48
|
+
/** 孪生已连,拒绝非孪生 */
|
|
49
|
+
export const WS_CLOSE_TWIN_EXISTS = 4003;
|