remnote-bridge 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -28
- package/README.zh-CN.md +368 -0
- package/dist/cli/commands/edit-rem.js +5 -5
- package/dist/cli/commands/health.js +231 -112
- package/dist/cli/commands/read-rem-in-tree.js +84 -0
- package/dist/cli/commands/read-rem.js +3 -1
- package/dist/cli/config.js +2 -0
- package/dist/cli/daemon/registry.js +8 -0
- package/dist/cli/handlers/edit-handler.js +49 -140
- package/dist/cli/handlers/patch-engine.js +347 -0
- package/dist/cli/handlers/read-handler.js +5 -57
- package/dist/cli/handlers/rem-cache.js +10 -5
- package/dist/cli/handlers/rem-field-filter.js +102 -0
- package/dist/cli/handlers/tree-edit-handler.js +67 -7
- package/dist/cli/handlers/tree-read-handler.js +4 -1
- package/dist/cli/handlers/tree-rem-read-handler.js +73 -0
- package/dist/cli/main.js +71 -12
- package/dist/cli/server/ws-server.js +9 -1
- package/dist/mcp/daemon-client.js +22 -2
- package/dist/mcp/format.js +43 -0
- package/dist/mcp/index.js +0 -55
- package/dist/mcp/instructions.js +447 -284
- package/dist/mcp/resources/edit-rem-guide.js +37 -157
- package/dist/mcp/resources/edit-tree-guide.js +1 -1
- package/dist/mcp/resources/error-reference.js +9 -13
- package/dist/mcp/resources/rem-object-fields.js +3 -3
- package/dist/mcp/tools/edit-tools.js +76 -10
- package/dist/mcp/tools/infra-tools.js +30 -33
- package/dist/mcp/tools/read-tools.js +221 -26
- package/package.json +1 -1
- package/remnote-plugin/dist/index-sandbox.js +24 -24
- package/remnote-plugin/dist/index.js +24 -24
- package/remnote-plugin/src/bridge/message-router.ts +3 -0
- package/remnote-plugin/src/services/read-rem-in-tree.ts +43 -0
- package/remnote-plugin/src/services/read-rem.ts +15 -0
- package/remnote-plugin/src/services/read-tree.ts +5 -0
- package/skills/remnote-bridge/SKILL.md +71 -38
- package/skills/remnote-bridge/instructions/connect.md +12 -1
- package/skills/remnote-bridge/instructions/disconnect.md +5 -0
- package/skills/remnote-bridge/instructions/edit-rem.md +105 -347
- package/skills/remnote-bridge/instructions/edit-tree.md +71 -2
- package/skills/remnote-bridge/instructions/health.md +81 -53
- package/skills/remnote-bridge/instructions/overall.md +55 -21
- package/skills/remnote-bridge/instructions/read-rem-in-tree.md +100 -0
- package/skills/remnote-bridge/instructions/read-rem.md +35 -16
- package/skills/remnote-bridge/instructions/search.md +4 -4
- package/skills/remnote-bridge/instructions/setup.md +5 -6
- package/skills/remnote-bridge-test/SKILL.md +847 -0
- package/skills/remnote-bridge-test/references/regression-suite.md +960 -0
- package/skills/remnote-bridge-test/references/verification-guide.md +161 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RemCache — LRU 缓存,存储 Rem
|
|
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.
|
|
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,
|
|
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.
|
|
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, {
|
|
54
|
+
this.cache.set(remId, { data, lastAccess: now, createdAt });
|
|
50
55
|
}
|
|
51
56
|
has(remId) {
|
|
52
57
|
return this.cache.has(remId);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rem-field-filter — RemObject 字段过滤逻辑
|
|
3
|
+
*
|
|
4
|
+
* 从 ReadHandler 提取的共享模块,供 ReadHandler 和 TreeRemReadHandler 使用。
|
|
5
|
+
*/
|
|
6
|
+
/** R-F 字段(仅 --full 模式输出,默认不输出) */
|
|
7
|
+
export const RF_FIELDS = new Set([
|
|
8
|
+
'children',
|
|
9
|
+
'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
|
|
10
|
+
'isPowerupPropertyListItem', 'isPowerupSlot',
|
|
11
|
+
'deepRemsBeingReferenced',
|
|
12
|
+
'ancestorTagRem', 'descendantTagRem',
|
|
13
|
+
'portalsAndDocumentsIn', 'allRemInDocumentOrPortal', 'allRemInFolderQueue',
|
|
14
|
+
'timesSelectedInSearch', 'lastTimeMovedTo', 'schemaVersion',
|
|
15
|
+
'embeddedQueueViewMode',
|
|
16
|
+
'localUpdatedAt', 'lastPracticed',
|
|
17
|
+
]);
|
|
18
|
+
/** Token Slimming: 默认模式下,字段值匹配此表时省略输出。未列入的字段始终输出。 */
|
|
19
|
+
export const REM_DEFAULTS = {
|
|
20
|
+
backText: null,
|
|
21
|
+
type: 'default',
|
|
22
|
+
isDocument: false,
|
|
23
|
+
fontSize: null,
|
|
24
|
+
highlightColor: null,
|
|
25
|
+
isTodo: false,
|
|
26
|
+
todoStatus: null,
|
|
27
|
+
isCode: false,
|
|
28
|
+
isQuote: false,
|
|
29
|
+
isListItem: false,
|
|
30
|
+
isCardItem: false,
|
|
31
|
+
isTable: false,
|
|
32
|
+
isSlot: false,
|
|
33
|
+
isProperty: false,
|
|
34
|
+
portalType: null,
|
|
35
|
+
portalDirectlyIncludedRem: [],
|
|
36
|
+
propertyType: null,
|
|
37
|
+
enablePractice: false,
|
|
38
|
+
practiceDirection: 'forward',
|
|
39
|
+
tags: [],
|
|
40
|
+
sources: [],
|
|
41
|
+
aliases: [],
|
|
42
|
+
remsBeingReferenced: [],
|
|
43
|
+
remsReferencingThis: [],
|
|
44
|
+
taggedRem: [],
|
|
45
|
+
descendants: [],
|
|
46
|
+
siblingRem: [],
|
|
47
|
+
positionAmongstSiblings: null,
|
|
48
|
+
};
|
|
49
|
+
export function matchesDefault(value, defaultValue) {
|
|
50
|
+
if (Array.isArray(defaultValue) && defaultValue.length === 0) {
|
|
51
|
+
return Array.isArray(value) && value.length === 0;
|
|
52
|
+
}
|
|
53
|
+
return value === defaultValue;
|
|
54
|
+
}
|
|
55
|
+
/** Portal 简化输出字段(type === 'portal' 时默认输出这 8 个字段) */
|
|
56
|
+
export const PORTAL_FIELDS = [
|
|
57
|
+
'id', 'type', 'portalType', 'portalDirectlyIncludedRem',
|
|
58
|
+
'parent', 'positionAmongstSiblings',
|
|
59
|
+
'createdAt', 'updatedAt',
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* 过滤 RemObject 字段。
|
|
63
|
+
*
|
|
64
|
+
* @param remObject 完整 RemObject
|
|
65
|
+
* @param options.full 返回全部字段
|
|
66
|
+
* @param options.fields 返回指定字段子集
|
|
67
|
+
* @returns 过滤后的对象
|
|
68
|
+
*/
|
|
69
|
+
export function filterRemFields(remObject, options) {
|
|
70
|
+
const { full, fields } = options ?? {};
|
|
71
|
+
if (full) {
|
|
72
|
+
return { ...remObject };
|
|
73
|
+
}
|
|
74
|
+
if (fields) {
|
|
75
|
+
const result = { id: remObject.id };
|
|
76
|
+
for (const field of fields) {
|
|
77
|
+
if (field in remObject) {
|
|
78
|
+
result[field] = remObject[field];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
if (remObject.type === 'portal') {
|
|
84
|
+
const result = {};
|
|
85
|
+
for (const field of PORTAL_FIELDS) {
|
|
86
|
+
if (field in remObject) {
|
|
87
|
+
result[field] = remObject[field];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
// 默认模式:排除 R-F 字段 + Token Slimming
|
|
93
|
+
const result = {};
|
|
94
|
+
for (const [key, value] of Object.entries(remObject)) {
|
|
95
|
+
if (RF_FIELDS.has(key))
|
|
96
|
+
continue;
|
|
97
|
+
if (key in REM_DEFAULTS && matchesDefault(value, REM_DEFAULTS[key]))
|
|
98
|
+
continue;
|
|
99
|
+
result[key] = value;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -2,13 +2,64 @@
|
|
|
2
2
|
* TreeEditHandler — edit-tree 请求的业务编排
|
|
3
3
|
*
|
|
4
4
|
* 职责:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
5
|
+
* 1. 模板展开({{remId}} → 缓存中对应行的完整内容)
|
|
6
|
+
* 2. 三道防线(缓存存在、变更检测、str_replace 精确匹配)
|
|
7
|
+
* 3. 解析新旧大纲并 diff
|
|
8
|
+
* 4. 逐项执行操作(通过 forwardToPlugin 调用原子操作)
|
|
9
|
+
* 5. 成功后重新 read-tree 更新缓存
|
|
9
10
|
*/
|
|
10
11
|
import { DEFAULT_DEFAULTS } from '../config.js';
|
|
11
12
|
import { parseOutline, diffTrees, parsePowerupPrefix } from './tree-parser.js';
|
|
13
|
+
// ────────────────────────── 模板展开 ──────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* 匹配 {{remId}} 占位符。
|
|
16
|
+
* 限定纯字母数字,避免与 RemNote cloze 语法 {{text}} 冲突
|
|
17
|
+
* (cloze 内容可能含中文、空格、标点,不会被此正则匹配)。
|
|
18
|
+
*/
|
|
19
|
+
const TEMPLATE_RE = /\{\{([a-zA-Z0-9]+)\}\}/g;
|
|
20
|
+
/** 行尾标记正则(与 tree-parser.ts 的 LINE_MARKER_RE 一致) */
|
|
21
|
+
const LINE_MARKER_RE = /<!--(\S+)(?:\s+\S+)*-->$/;
|
|
22
|
+
/** 省略占位符正则(与 tree-parser.ts 的 ELIDED_LINE_RE 一致) */
|
|
23
|
+
const ELIDED_LINE_RE = /^<!--\.\.\.elided\s/;
|
|
24
|
+
/**
|
|
25
|
+
* 从缓存大纲构建 remId → 去缩进完整行 的映射。
|
|
26
|
+
* 省略占位符行无 remId,自然不会进入映射。
|
|
27
|
+
*/
|
|
28
|
+
function buildLineMap(cachedOutline) {
|
|
29
|
+
const map = new Map();
|
|
30
|
+
for (const line of cachedOutline.split('\n')) {
|
|
31
|
+
const stripped = line.trimStart();
|
|
32
|
+
if (!stripped || ELIDED_LINE_RE.test(stripped))
|
|
33
|
+
continue;
|
|
34
|
+
const match = stripped.match(LINE_MARKER_RE);
|
|
35
|
+
if (match) {
|
|
36
|
+
map.set(match[1], stripped);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 展开 oldStr/newStr 中的 {{remId}} 占位符。
|
|
43
|
+
*
|
|
44
|
+
* {{remId}} 被替换为该 remId 在缓存大纲中对应行的完整内容(不含缩进)。
|
|
45
|
+
* 不含 {{}} 的文本原样返回(零开销,向后兼容)。
|
|
46
|
+
*
|
|
47
|
+
* 匹配到纯字母数字但不在 lineMap 中的 {{xxx}} 原样保留(可能是 cloze 内容),
|
|
48
|
+
* 不报错,避免与 RemNote 的 {{cloze}} 语法冲突。未展开的模板记录在 warnings 中。
|
|
49
|
+
*/
|
|
50
|
+
function expandTemplates(text, lineMap, warnings) {
|
|
51
|
+
if (!text.includes('{{'))
|
|
52
|
+
return text;
|
|
53
|
+
return text.replace(TEMPLATE_RE, (match, remId) => {
|
|
54
|
+
const content = lineMap.get(remId);
|
|
55
|
+
if (content === undefined) {
|
|
56
|
+
// 不在 lineMap 中:可能是 cloze 文本,原样保留并记录 warning
|
|
57
|
+
warnings.push(`{{${remId}}} 未在缓存大纲中找到,已原样保留(若为 cloze 可忽略;若为 remId 请检查拼写)`);
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
return content;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
12
63
|
export class TreeEditHandler {
|
|
13
64
|
cache;
|
|
14
65
|
forwardToPlugin;
|
|
@@ -42,8 +93,17 @@ export class TreeEditHandler {
|
|
|
42
93
|
// 不更新缓存 — 迫使 AI re-read
|
|
43
94
|
throw new Error(`Tree rooted at ${remId} has been modified since last read-tree. Please read-tree again.`);
|
|
44
95
|
}
|
|
96
|
+
// ── 模板展开:{{remId}} → 缓存中对应行的完整内容(不含缩进)──
|
|
97
|
+
const lineMap = buildLineMap(cachedOutline);
|
|
98
|
+
const templateWarnings = [];
|
|
99
|
+
const expandedOldStr = expandTemplates(oldStr, lineMap, templateWarnings);
|
|
100
|
+
const expandedNewStr = expandTemplates(newStr, lineMap, templateWarnings);
|
|
101
|
+
// 展开后 noop 检查(原始不等但展开后可能相等)
|
|
102
|
+
if (expandedOldStr === expandedNewStr) {
|
|
103
|
+
return { ok: true, operations: [], ...(templateWarnings.length > 0 && { templateWarnings }) };
|
|
104
|
+
}
|
|
45
105
|
// ── 防线 3: str_replace 精确匹配 ──
|
|
46
|
-
const matchCount = countOccurrences(cachedOutline,
|
|
106
|
+
const matchCount = countOccurrences(cachedOutline, expandedOldStr);
|
|
47
107
|
if (matchCount === 0) {
|
|
48
108
|
throw new Error(`old_str not found in the tree outline of ${remId}`);
|
|
49
109
|
}
|
|
@@ -51,7 +111,7 @@ export class TreeEditHandler {
|
|
|
51
111
|
throw new Error(`old_str matches ${matchCount} locations in the tree outline of ${remId}. ` +
|
|
52
112
|
`Make old_str more specific to match exactly once.`);
|
|
53
113
|
}
|
|
54
|
-
const modifiedOutline = cachedOutline.replace(
|
|
114
|
+
const modifiedOutline = cachedOutline.replace(expandedOldStr, expandedNewStr);
|
|
55
115
|
// ── 解析新旧大纲 ──
|
|
56
116
|
const oldTree = parseOutline(cachedOutline);
|
|
57
117
|
const newTree = parseOutline(modifiedOutline);
|
|
@@ -201,7 +261,7 @@ export class TreeEditHandler {
|
|
|
201
261
|
// ── D3: 成功后更新缓存(使用相同参数)──
|
|
202
262
|
const updatedResult = await this.forwardToPlugin('read_tree', { remId, depth, maxNodes, maxSiblings });
|
|
203
263
|
this.cache.set('tree:' + remId, updatedResult.outline);
|
|
204
|
-
return { ok: true, operations };
|
|
264
|
+
return { ok: true, operations, ...(templateWarnings.length > 0 && { templateWarnings }) };
|
|
205
265
|
}
|
|
206
266
|
}
|
|
207
267
|
/** 统计 needle 在 haystack 中出现的次数 */
|
|
@@ -32,9 +32,12 @@ export class TreeReadHandler {
|
|
|
32
32
|
const cacheKey = 'tree:' + remId;
|
|
33
33
|
const previousCachedAt = this.cache.getCreatedAt(cacheKey);
|
|
34
34
|
// 转发到 Plugin 的 read_tree service
|
|
35
|
-
const
|
|
35
|
+
const rawResult = await this.forwardToPlugin('read_tree', {
|
|
36
36
|
remId, depth, maxNodes, maxSiblings, ancestorLevels, includePowerup,
|
|
37
37
|
});
|
|
38
|
+
// 剥离内部字段 nodeRemIds(不暴露给 CLI 输出)
|
|
39
|
+
delete rawResult.nodeRemIds;
|
|
40
|
+
const result = rawResult;
|
|
38
41
|
// 缓存大纲文本 + 读取参数(供 edit-tree 乐观并发检测时复现相同查询)
|
|
39
42
|
this.cache.set(cacheKey, result.outline);
|
|
40
43
|
this.cache.set('tree-depth:' + remId, String(depth));
|
|
@@ -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
|
|
65
|
-
.option('--headless', '使用 headless
|
|
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';
|
|
@@ -121,7 +136,7 @@ program
|
|
|
121
136
|
const input = parseJsonInput('read-rem', remIdOrJson);
|
|
122
137
|
if (!input)
|
|
123
138
|
return;
|
|
124
|
-
await readRemCommand(input.remId, { json, fields: input.fields
|
|
139
|
+
await readRemCommand(input.remId, { json, fields: input.fields, full: input.full, includePowerup: input.includePowerup });
|
|
125
140
|
}
|
|
126
141
|
else {
|
|
127
142
|
if (!remIdOrJson) {
|
|
@@ -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 层级)')
|
|
@@ -282,7 +333,7 @@ program
|
|
|
282
333
|
process.exitCode = 1;
|
|
283
334
|
return;
|
|
284
335
|
}
|
|
285
|
-
await searchCommand(input.query, { json, limit: input.numResults?.toString() });
|
|
336
|
+
await searchCommand(input.query, { json, limit: (input.limit ?? input.numResults)?.toString() });
|
|
286
337
|
}
|
|
287
338
|
else {
|
|
288
339
|
if (!queryOrJson) {
|
|
@@ -295,16 +346,15 @@ program
|
|
|
295
346
|
});
|
|
296
347
|
program
|
|
297
348
|
.command('edit-rem [remIdOrJson]')
|
|
298
|
-
.description('
|
|
299
|
-
.option('--
|
|
300
|
-
.option('--new-str <newStr>', '替换后的新文本片段')
|
|
349
|
+
.description('直接修改 Rem 的属性字段')
|
|
350
|
+
.option('--changes <changesJson>', '要修改的字段及新值(JSON 字符串)')
|
|
301
351
|
.action(async (remIdOrJson, cmdOpts) => {
|
|
302
352
|
const { json } = program.opts();
|
|
303
353
|
if (json) {
|
|
304
|
-
const input = parseJsonInput('edit-rem', remIdOrJson, ['
|
|
354
|
+
const input = parseJsonInput('edit-rem', remIdOrJson, ['changes']);
|
|
305
355
|
if (!input)
|
|
306
356
|
return;
|
|
307
|
-
await editRemCommand(input.remId, { json,
|
|
357
|
+
await editRemCommand(input.remId, { json, changes: input.changes });
|
|
308
358
|
}
|
|
309
359
|
else {
|
|
310
360
|
if (!remIdOrJson) {
|
|
@@ -312,12 +362,21 @@ program
|
|
|
312
362
|
process.exitCode = 1;
|
|
313
363
|
return;
|
|
314
364
|
}
|
|
315
|
-
if (!cmdOpts.
|
|
316
|
-
console.error('错误: --
|
|
365
|
+
if (!cmdOpts.changes) {
|
|
366
|
+
console.error('错误: --changes 是必需的');
|
|
367
|
+
process.exitCode = 1;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
let changes;
|
|
371
|
+
try {
|
|
372
|
+
changes = JSON.parse(cmdOpts.changes);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
console.error('错误: --changes 不是合法的 JSON');
|
|
317
376
|
process.exitCode = 1;
|
|
318
377
|
return;
|
|
319
378
|
}
|
|
320
|
-
await editRemCommand(remIdOrJson, { json,
|
|
379
|
+
await editRemCommand(remIdOrJson, { json, changes });
|
|
321
380
|
}
|
|
322
381
|
});
|
|
323
382
|
// mcp 子命令
|
|
@@ -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:
|
|
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'
|
|
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
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP 返回值格式化工具
|
|
3
|
+
*
|
|
4
|
+
* 两种模式:
|
|
5
|
+
* - formatFrontmatter:YAML frontmatter + body(read 类工具)
|
|
6
|
+
* - formatDataJson:剥离 wrapper 的 JSON(action/infra 工具)
|
|
7
|
+
*/
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// 模式 A:Frontmatter + Body
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* 将元数据序列化为 YAML frontmatter,拼接 body 内容。
|
|
13
|
+
*
|
|
14
|
+
* - null/undefined 的值自动跳过
|
|
15
|
+
* - 数字/布尔:裸值(`nodeCount: 42`)
|
|
16
|
+
* - 字符串:JSON 引号(`mode: "focus"`)
|
|
17
|
+
* - 数组/对象:JSON 内联(`ancestors: [{"id":"x"}]`)
|
|
18
|
+
* - 无元数据时省略 `---` 分隔符,直接返回 body
|
|
19
|
+
*/
|
|
20
|
+
export function formatFrontmatter(meta, body) {
|
|
21
|
+
const entries = Object.entries(meta).filter(([, v]) => v !== undefined && v !== null);
|
|
22
|
+
if (entries.length === 0)
|
|
23
|
+
return body;
|
|
24
|
+
const lines = entries.map(([k, v]) => {
|
|
25
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
26
|
+
return `${k}: ${v}`;
|
|
27
|
+
return `${k}: ${JSON.stringify(v)}`;
|
|
28
|
+
});
|
|
29
|
+
return `---\n${lines.join('\n')}\n---\n\n${body}`;
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// 模式 B:Data JSON(剥离 ok/command/timestamp wrapper)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* 从 CLI 响应中剥离 ok/command/timestamp,返回剩余字段的 JSON。
|
|
36
|
+
*
|
|
37
|
+
* 用于 action/infra 工具——AI 不需要看到冗余的 wrapper 字段。
|
|
38
|
+
* callCli 已在 ok===false 时抛出 CliError,成功路径 ok 始终为 true。
|
|
39
|
+
*/
|
|
40
|
+
export function formatDataJson(response) {
|
|
41
|
+
const { ok: _ok, command: _cmd, timestamp: _ts, ...rest } = response;
|
|
42
|
+
return JSON.stringify(rest, null, 2);
|
|
43
|
+
}
|