remnote-bridge 0.1.11 → 0.1.13

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 (70) 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 +8 -36
  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 +3 -20
  11. package/dist/cli/commands/read-globe.js +3 -20
  12. package/dist/cli/commands/read-rem.js +6 -32
  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/edit-handler.js +49 -140
  23. package/dist/cli/handlers/read-handler.js +9 -9
  24. package/dist/cli/handlers/rem-cache.js +10 -5
  25. package/dist/cli/handlers/tree-parser.js +16 -9
  26. package/dist/cli/main.js +67 -19
  27. package/dist/cli/protocol.js +18 -4
  28. package/dist/cli/server/config-server.js +280 -14
  29. package/dist/cli/server/ws-server.js +93 -44
  30. package/dist/cli/utils/output.js +29 -0
  31. package/dist/mcp/format.js +43 -0
  32. package/dist/mcp/index.js +0 -55
  33. package/dist/mcp/instructions.js +424 -216
  34. package/dist/mcp/resources/edit-rem-guide.js +37 -158
  35. package/dist/mcp/resources/edit-tree-guide.js +1 -1
  36. package/dist/mcp/resources/error-reference.js +9 -13
  37. package/dist/mcp/resources/rem-object-fields.js +6 -6
  38. package/dist/mcp/tools/edit-tools.js +69 -8
  39. package/dist/mcp/tools/infra-tools.js +44 -8
  40. package/dist/mcp/tools/read-tools.js +136 -20
  41. package/package.json +2 -2
  42. package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
  43. package/remnote-plugin/dist/bridge_widget.js +17 -17
  44. package/remnote-plugin/dist/index-sandbox.js +31 -31
  45. package/remnote-plugin/dist/index.js +31 -31
  46. package/remnote-plugin/dist/manifest.json +1 -1
  47. package/remnote-plugin/package.json +1 -1
  48. package/remnote-plugin/public/manifest.json +1 -1
  49. package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
  50. package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
  51. package/remnote-plugin/src/services/index.ts +0 -8
  52. package/remnote-plugin/src/services/read-rem.ts +1 -9
  53. package/remnote-plugin/src/services/search.ts +13 -10
  54. package/remnote-plugin/src/settings.ts +9 -7
  55. package/remnote-plugin/src/utils/index.ts +0 -5
  56. package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
  57. package/remnote-plugin/src/widgets/index.tsx +41 -44
  58. package/remnote-plugin/webpack.config.js +35 -0
  59. package/skills/remnote-bridge/SKILL.md +45 -40
  60. package/skills/remnote-bridge/instructions/addon.md +134 -0
  61. package/skills/remnote-bridge/instructions/clean.md +110 -0
  62. package/skills/remnote-bridge/instructions/connect.md +80 -37
  63. package/skills/remnote-bridge/instructions/disconnect.md +22 -9
  64. package/skills/remnote-bridge/instructions/edit-rem.md +113 -327
  65. package/skills/remnote-bridge/instructions/health.md +23 -13
  66. package/skills/remnote-bridge/instructions/install-skill.md +58 -0
  67. package/skills/remnote-bridge/instructions/overall.md +99 -35
  68. package/skills/remnote-bridge/instructions/read-rem.md +15 -15
  69. package/skills/remnote-bridge/instructions/search.md +77 -18
  70. package/skills/remnote-bridge/instructions/setup.md +5 -6
@@ -1,17 +1,12 @@
1
1
  /**
2
2
  * EditHandler — edit-rem 命令的编排器
3
3
  *
4
- * 在 daemon 层实现三道防线 + str_replace + 后处理校验。
4
+ * 在 daemon 层实现两道防线 + 字段白名单校验 + 直接转发 changes。
5
5
  * Plugin 只负责原子写入(write_rem_fields)。
6
6
  *
7
7
  * 防线 1:缓存存在性检查(必须先 read 再 edit)
8
8
  * 防线 2:乐观并发检测(当前 JSON 与缓存 JSON 比较)
9
- * 防线 3:str_replace 精确匹配(old_str 必须唯一匹配)
10
- *
11
- * Portal 专用路径:type === 'portal' 时,在简化 JSON(9 字段)上执行 str_replace,
12
- * 推导变更后调用专用写入逻辑。
13
9
  */
14
- import { PORTAL_FIELDS } from './read-handler.js';
15
10
  /** 只读字段集合 — 变更这些字段只产生警告,不执行写入 */
16
11
  const READ_ONLY_FIELDS = new Set([
17
12
  'id',
@@ -30,10 +25,22 @@ const READ_ONLY_FIELDS = new Set([
30
25
  'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
31
26
  'isPowerupPropertyListItem', 'isPowerupSlot',
32
27
  ]);
33
- /** Portal 简化 JSON 中的只读字段 */
34
- const PORTAL_READONLY_FIELDS = new Set([
35
- 'id', 'type', 'portalType', 'children', 'createdAt', 'updatedAt',
28
+ /** 可写字段白名单 21 个可写字段 */
29
+ const WRITABLE_FIELDS = new Set([
30
+ 'text', 'backText', 'type', 'isDocument', 'parent',
31
+ 'fontSize', 'highlightColor',
32
+ 'isTodo', 'todoStatus', 'isCode', 'isQuote', 'isListItem', 'isCardItem', 'isSlot', 'isProperty',
33
+ 'enablePractice', 'practiceDirection',
34
+ 'tags', 'sources', 'positionAmongstSiblings', 'portalDirectlyIncludedRem',
36
35
  ]);
36
+ /** 枚举字段的合法值 */
37
+ const ENUM_VALUES = {
38
+ type: new Set(['concept', 'descriptor', 'default']),
39
+ practiceDirection: new Set(['forward', 'backward', 'both', 'none']),
40
+ highlightColor: new Set(['Red', 'Orange', 'Yellow', 'Green', 'Blue', 'Purple', 'Gray', 'Brown', 'Pink', null]),
41
+ fontSize: new Set(['H1', 'H2', 'H3', null]),
42
+ todoStatus: new Set(['Finished', 'Unfinished', null]),
43
+ };
37
44
  export class EditHandler {
38
45
  cache;
39
46
  forwardToPlugin;
@@ -42,145 +49,61 @@ export class EditHandler {
42
49
  this.forwardToPlugin = forwardToPlugin;
43
50
  }
44
51
  async handleEditRem(payload) {
45
- const { remId, oldStr, newStr } = payload;
52
+ const { remId, changes } = payload;
53
+ if (!changes || typeof changes !== 'object' || Object.keys(changes).length === 0) {
54
+ return { ok: true, changes: [], warnings: [] };
55
+ }
46
56
  // ── 防线 1: 缓存存在性检查 ──
47
- const cachedJson = this.cache.get('rem:' + remId);
48
- if (!cachedJson) {
57
+ const cachedObj = this.cache.get('rem:' + remId);
58
+ if (!cachedObj) {
49
59
  throw new Error(`Rem ${remId} has not been read yet. Read it first before editing.`);
50
60
  }
51
61
  // ── 防线 2: 乐观并发检测 ──
52
62
  const currentRemObject = await this.forwardToPlugin('read_rem', { remId });
53
63
  const currentJson = JSON.stringify(currentRemObject, null, 2);
64
+ const cachedJson = JSON.stringify(cachedObj, null, 2);
54
65
  if (currentJson !== cachedJson) {
55
66
  // 不更新缓存 — 迫使 AI re-read
56
67
  throw new Error(`Rem ${remId} has been modified since last read. Please read it again before editing.`);
57
68
  }
58
- // ── 检测 Portal 类型,分流到专用路径 ──
59
- const cachedObj = JSON.parse(cachedJson);
60
- if (cachedObj.type === 'portal') {
61
- return this.handlePortalEdit(remId, cachedJson, cachedObj, oldStr, newStr);
62
- }
63
- // ── 普通 Rem 路径 ──
64
- return this.handleNormalEdit(remId, cachedJson, cachedObj, oldStr, newStr);
65
- }
66
- /** Portal 专用编辑路径:在简化 JSON 上执行 str_replace */
67
- async handlePortalEdit(remId, cachedJson, fullObj, oldStr, newStr) {
68
- // 从完整对象提取简化 JSON
69
- const simplified = {};
70
- for (const field of PORTAL_FIELDS) {
71
- if (field in fullObj) {
72
- simplified[field] = fullObj[field];
73
- }
74
- }
75
- const simplifiedJson = JSON.stringify(simplified, null, 2);
76
- // ── 防线 3: str_replace 在简化 JSON 上精确匹配 ──
77
- const matchCount = countOccurrences(simplifiedJson, oldStr);
78
- if (matchCount === 0) {
79
- throw new Error(`old_str not found in the simplified Portal JSON of rem ${remId}`);
80
- }
81
- if (matchCount > 1) {
82
- throw new Error(`old_str matches ${matchCount} locations in Portal rem ${remId}. ` +
83
- `Make old_str more specific to match exactly once.`);
84
- }
85
- const modifiedSimplifiedJson = simplifiedJson.replace(oldStr, newStr);
86
- // JSON 解析
87
- let modified;
88
- try {
89
- modified = JSON.parse(modifiedSimplifiedJson);
90
- }
91
- catch {
92
- throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
93
- }
94
- // 推导变更字段
95
- const changes = {};
69
+ // ── 遍历 changes keys:分类过滤 ──
96
70
  const warnings = [];
97
- for (const key of Object.keys(modified)) {
98
- if (JSON.stringify(modified[key]) !== JSON.stringify(simplified[key])) {
99
- if (PORTAL_READONLY_FIELDS.has(key)) {
100
- warnings.push(`Field '${key}' is read-only and was ignored`);
101
- }
102
- else {
103
- changes[key] = modified[key];
104
- }
71
+ const writableChanges = {};
72
+ for (const key of Object.keys(changes)) {
73
+ if (READ_ONLY_FIELDS.has(key)) {
74
+ warnings.push(`Field '${key}' is read-only and was ignored`);
75
+ }
76
+ else if (!WRITABLE_FIELDS.has(key)) {
77
+ warnings.push(`Field '${key}' is unknown and was ignored`);
78
+ }
79
+ else {
80
+ writableChanges[key] = changes[key];
105
81
  }
106
82
  }
107
- // 空变更检查
108
- if (Object.keys(changes).length === 0) {
109
- return { ok: true, changes: [], warnings };
110
- }
111
- // ── 发送变更到 Plugin ──
112
- const writeResult = (await this.forwardToPlugin('write_rem_fields', {
113
- remId,
114
- changes,
115
- }));
116
- if (writeResult.failed) {
117
- return {
118
- ok: false,
119
- changes: [],
120
- warnings,
121
- error: `Failed to update field '${writeResult.failed.field}': ${writeResult.failed.error}`,
122
- appliedChanges: writeResult.applied,
123
- failedField: writeResult.failed.field,
124
- };
125
- }
126
- // ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存(D5)──
127
- const freshRemObject = await this.forwardToPlugin('read_rem', { remId });
128
- const freshJson = JSON.stringify(freshRemObject, null, 2);
129
- this.cache.set('rem:' + remId, freshJson);
130
- return {
131
- ok: true,
132
- changes: Object.keys(changes),
133
- warnings,
134
- };
135
- }
136
- /** 普通 Rem 编辑路径:在完整 JSON 上执行 str_replace */
137
- async handleNormalEdit(remId, cachedJson, original, oldStr, newStr) {
138
- // ── 防线 3: str_replace 精确匹配 ──
139
- const matchCount = countOccurrences(cachedJson, oldStr);
140
- if (matchCount === 0) {
141
- throw new Error(`old_str not found in the serialized JSON of rem ${remId}`);
142
- }
143
- if (matchCount > 1) {
144
- throw new Error(`old_str matches ${matchCount} locations in rem ${remId}. ` +
145
- `Make old_str more specific to match exactly once.`);
146
- }
147
- const modifiedJson = cachedJson.replace(oldStr, newStr);
148
- // 1. JSON 解析
149
- let modified;
150
- try {
151
- modified = JSON.parse(modifiedJson);
152
- }
153
- catch {
154
- throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
155
- }
156
- // 2. 推导变更字段
157
- const changes = {};
158
- const warnings = [];
159
- for (const key of Object.keys(modified)) {
160
- if (JSON.stringify(modified[key]) !== JSON.stringify(original[key])) {
161
- if (READ_ONLY_FIELDS.has(key)) {
162
- warnings.push(`Field '${key}' is read-only and was ignored`);
163
- }
164
- else {
165
- changes[key] = modified[key];
83
+ // ── 枚举值范围校验 ──
84
+ for (const [field, allowedValues] of Object.entries(ENUM_VALUES)) {
85
+ if (field in writableChanges) {
86
+ const value = writableChanges[field];
87
+ if (!allowedValues.has(value)) {
88
+ throw new Error(`Invalid value for '${field}': ${JSON.stringify(value)}. Allowed: ${[...allowedValues].map(v => JSON.stringify(v)).join(', ')}`);
166
89
  }
167
90
  }
168
91
  }
169
- // 3. 语义一致性校验
170
- if ('todoStatus' in changes && changes.todoStatus !== null) {
171
- const isTodo = modified.isTodo ?? original.isTodo;
92
+ // ── 语义校验:todoStatus 非 null 但 isTodo 未启用 ──
93
+ if ('todoStatus' in writableChanges && writableChanges.todoStatus !== null) {
94
+ const isTodo = writableChanges.isTodo ?? cachedObj.isTodo;
172
95
  if (!isTodo) {
173
96
  warnings.push("Setting 'todoStatus' without 'isTodo: true' may have no effect");
174
97
  }
175
98
  }
176
- // 4. 空变更检查
177
- if (Object.keys(changes).length === 0) {
99
+ // ── 空变更检查 ──
100
+ if (Object.keys(writableChanges).length === 0) {
178
101
  return { ok: true, changes: [], warnings };
179
102
  }
180
103
  // ── 发送变更到 Plugin ──
181
104
  const writeResult = (await this.forwardToPlugin('write_rem_fields', {
182
105
  remId,
183
- changes,
106
+ changes: writableChanges,
184
107
  }));
185
108
  if (writeResult.failed) {
186
109
  // 部分失败 — 不更新缓存(迫使 AI re-read)
@@ -193,27 +116,13 @@ export class EditHandler {
193
116
  failedField: writeResult.failed.field,
194
117
  };
195
118
  }
196
- // ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存(D5)──
119
+ // ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存 ──
197
120
  const freshRemObject = await this.forwardToPlugin('read_rem', { remId });
198
- const freshJson = JSON.stringify(freshRemObject, null, 2);
199
- this.cache.set('rem:' + remId, freshJson);
121
+ this.cache.set('rem:' + remId, freshRemObject);
200
122
  return {
201
123
  ok: true,
202
- changes: Object.keys(changes),
124
+ changes: Object.keys(writableChanges),
203
125
  warnings,
204
126
  };
205
127
  }
206
128
  }
207
- /** 统计 needle 在 haystack 中出现的次数 */
208
- function countOccurrences(haystack, needle) {
209
- let count = 0;
210
- let pos = 0;
211
- while (true) {
212
- pos = haystack.indexOf(needle, pos);
213
- if (pos === -1)
214
- break;
215
- count++;
216
- pos += needle.length; // 非重叠匹配,与 String.replace 行为一致
217
- }
218
- return count;
219
- }
@@ -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 {
@@ -43,17 +44,16 @@ export class ReadHandler {
43
44
  const includePowerup = payload.includePowerup ?? false;
44
45
  // 转发到 Plugin
45
46
  const remObject = await this.forwardToPlugin('read_rem', { remId, includePowerup });
46
- // 缓存完整 JSON
47
- const fullJson = JSON.stringify(remObject, null, 2);
48
- this.cache.set(cacheKey, fullJson);
49
- this.onLog?.(`缓存 Rem ${remId.slice(0, 8)}... (${fullJson.length} bytes)`, 'info');
47
+ // 缓存完整 RemObject 对象
48
+ this.cache.set(cacheKey, remObject);
49
+ this.onLog?.(`缓存 Rem ${remId.slice(0, 8)}...`, 'info');
50
50
  // 字段过滤
51
51
  const fields = payload.fields;
52
52
  const full = payload.full;
53
53
  let result;
54
54
  if (full) {
55
- // --full → 返回完整对象(含 R-F 字段)
56
- result = remObject;
55
+ // --full → 返回完整对象(含 R-F 字段)。浅拷贝避免污染缓存对象。
56
+ result = { ...remObject };
57
57
  }
58
58
  else if (fields) {
59
59
  // --fields 过滤:只返回指定字段 + id
@@ -66,7 +66,7 @@ export class ReadHandler {
66
66
  }
67
67
  }
68
68
  else if (remObject.type === 'portal') {
69
- // Portal 简化模式:只输出 9 个关键字段
69
+ // Portal 简化模式:只输出 8 个关键字段
70
70
  const obj = remObject;
71
71
  result = {};
72
72
  for (const field of PORTAL_FIELDS) {
@@ -1,8 +1,13 @@
1
1
  /**
2
- * RemCache — LRU 缓存,存储 Rem 的序列化 JSON
2
+ * RemCache — LRU 缓存,存储 Rem 数据
3
3
  *
4
4
  * 缓存存储在 daemon 内存中,生命周期与 daemon 一致。
5
5
  * disconnect 关闭 daemon → 缓存自然消失。
6
+ *
7
+ * 泛化值类型:不同 key 前缀存储不同类型的数据:
8
+ * - rem:{remId} → RemObject 对象
9
+ * - tree:{remId} → Markdown outline 字符串
10
+ * - tree-depth:{remId} 等 → 参数值字符串
6
11
  */
7
12
  export class RemCache {
8
13
  cache = new Map();
@@ -15,19 +20,19 @@ export class RemCache {
15
20
  if (!entry)
16
21
  return null;
17
22
  entry.lastAccess = Date.now();
18
- return entry.json;
23
+ return entry.data;
19
24
  }
20
25
  /** 获取缓存条目的创建时间(ISO 8601),不存在返回 null */
21
26
  getCreatedAt(remId) {
22
27
  const entry = this.cache.get(remId);
23
28
  return entry ? entry.createdAt : null;
24
29
  }
25
- set(remId, json) {
30
+ set(remId, data) {
26
31
  const now = Date.now();
27
32
  const createdAt = new Date(now).toISOString();
28
33
  if (this.cache.has(remId)) {
29
34
  const entry = this.cache.get(remId);
30
- entry.json = json;
35
+ entry.data = data;
31
36
  entry.lastAccess = now;
32
37
  entry.createdAt = createdAt;
33
38
  return;
@@ -46,7 +51,7 @@ export class RemCache {
46
51
  this.cache.delete(oldestId);
47
52
  }
48
53
  }
49
- this.cache.set(remId, { json, lastAccess: now, createdAt });
54
+ this.cache.set(remId, { data, lastAccess: now, createdAt });
50
55
  }
51
56
  has(remId) {
52
57
  return this.cache.has(remId);
@@ -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]')
@@ -105,7 +121,7 @@ program
105
121
  const input = parseJsonInput('read-rem', remIdOrJson);
106
122
  if (!input)
107
123
  return;
108
- await readRemCommand(input.remId, { json, fields: input.fields?.join(','), full: input.full, includePowerup: input.includePowerup });
124
+ await readRemCommand(input.remId, { json, fields: input.fields, full: input.full, includePowerup: input.includePowerup });
109
125
  }
110
126
  else {
111
127
  if (!remIdOrJson) {
@@ -266,7 +282,7 @@ program
266
282
  process.exitCode = 1;
267
283
  return;
268
284
  }
269
- await searchCommand(input.query, { json, limit: input.numResults?.toString() });
285
+ await searchCommand(input.query, { json, limit: (input.limit ?? input.numResults)?.toString() });
270
286
  }
271
287
  else {
272
288
  if (!queryOrJson) {
@@ -279,16 +295,15 @@ program
279
295
  });
280
296
  program
281
297
  .command('edit-rem [remIdOrJson]')
282
- .description('通过 str_replace 编辑 Rem 的 JSON 字段')
283
- .option('--old-str <oldStr>', '要替换的原始文本片段')
284
- .option('--new-str <newStr>', '替换后的新文本片段')
298
+ .description('直接修改 Rem 的属性字段')
299
+ .option('--changes <changesJson>', '要修改的字段及新值(JSON 字符串)')
285
300
  .action(async (remIdOrJson, cmdOpts) => {
286
301
  const { json } = program.opts();
287
302
  if (json) {
288
- const input = parseJsonInput('edit-rem', remIdOrJson, ['oldStr', 'newStr']);
303
+ const input = parseJsonInput('edit-rem', remIdOrJson, ['changes']);
289
304
  if (!input)
290
305
  return;
291
- await editRemCommand(input.remId, { json, oldStr: input.oldStr, newStr: input.newStr });
306
+ await editRemCommand(input.remId, { json, changes: input.changes });
292
307
  }
293
308
  else {
294
309
  if (!remIdOrJson) {
@@ -296,12 +311,21 @@ program
296
311
  process.exitCode = 1;
297
312
  return;
298
313
  }
299
- if (!cmdOpts.oldStr || cmdOpts.newStr === undefined) {
300
- console.error('错误: --old-str 和 --new-str 是必需的');
314
+ if (!cmdOpts.changes) {
315
+ console.error('错误: --changes 是必需的');
301
316
  process.exitCode = 1;
302
317
  return;
303
318
  }
304
- await editRemCommand(remIdOrJson, { json, oldStr: cmdOpts.oldStr, newStr: cmdOpts.newStr });
319
+ let changes;
320
+ try {
321
+ changes = JSON.parse(cmdOpts.changes);
322
+ }
323
+ catch {
324
+ console.error('错误: --changes 不是合法的 JSON');
325
+ process.exitCode = 1;
326
+ return;
327
+ }
328
+ await editRemCommand(remIdOrJson, { json, changes });
305
329
  }
306
330
  });
307
331
  // mcp 子命令
@@ -331,4 +355,28 @@ program
331
355
  const { json } = program.opts();
332
356
  await cleanCommand({ json });
333
357
  });
358
+ // addon 子命令组
359
+ const addonCmd = program.command('addon').description('管理增强项目(addon)');
360
+ addonCmd
361
+ .command('list')
362
+ .description('查看所有增强项目状态')
363
+ .action(async () => {
364
+ const { json } = program.opts();
365
+ await addonListCommand({ json });
366
+ });
367
+ addonCmd
368
+ .command('install <name>')
369
+ .description('安装指定增强项目')
370
+ .action(async (name) => {
371
+ const { json } = program.opts();
372
+ await addonInstallCommand(name, { json });
373
+ });
374
+ addonCmd
375
+ .command('uninstall <name>')
376
+ .description('卸载指定增强项目')
377
+ .option('--purge', '同时删除数据目录')
378
+ .action(async (name, cmdOpts) => {
379
+ const { json } = program.opts();
380
+ await addonUninstallCommand(name, { json, purge: cmdOpts.purge });
381
+ });
334
382
  program.parse();
@@ -6,10 +6,15 @@
6
6
  */
7
7
  // ── 消息类型判断辅助 ──
8
8
  export function isHelloMessage(msg) {
9
- return (typeof msg === 'object' &&
10
- msg !== null &&
11
- msg.type === 'hello' &&
12
- typeof msg.version === 'string');
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;