remnote-bridge 0.1.0

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 (135) hide show
  1. package/dist/cli/commands/connect.d.ts +12 -0
  2. package/dist/cli/commands/connect.js +124 -0
  3. package/dist/cli/commands/disconnect.d.ts +11 -0
  4. package/dist/cli/commands/disconnect.js +100 -0
  5. package/dist/cli/commands/edit-rem.d.ts +13 -0
  6. package/dist/cli/commands/edit-rem.js +83 -0
  7. package/dist/cli/commands/edit-tree.d.ts +14 -0
  8. package/dist/cli/commands/edit-tree.js +67 -0
  9. package/dist/cli/commands/health.d.ts +12 -0
  10. package/dist/cli/commands/health.js +100 -0
  11. package/dist/cli/commands/install-skill.d.ts +6 -0
  12. package/dist/cli/commands/install-skill.js +39 -0
  13. package/dist/cli/commands/read-context.d.ts +20 -0
  14. package/dist/cli/commands/read-context.js +77 -0
  15. package/dist/cli/commands/read-globe.d.ts +16 -0
  16. package/dist/cli/commands/read-globe.js +60 -0
  17. package/dist/cli/commands/read-rem.d.ts +16 -0
  18. package/dist/cli/commands/read-rem.js +80 -0
  19. package/dist/cli/commands/read-tree.d.ts +17 -0
  20. package/dist/cli/commands/read-tree.js +85 -0
  21. package/dist/cli/commands/search.d.ts +12 -0
  22. package/dist/cli/commands/search.js +65 -0
  23. package/dist/cli/config.d.ts +55 -0
  24. package/dist/cli/config.js +139 -0
  25. package/dist/cli/daemon/daemon.d.ts +11 -0
  26. package/dist/cli/daemon/daemon.js +186 -0
  27. package/dist/cli/daemon/dev-server.d.ts +26 -0
  28. package/dist/cli/daemon/dev-server.js +81 -0
  29. package/dist/cli/daemon/pid.d.ts +34 -0
  30. package/dist/cli/daemon/pid.js +67 -0
  31. package/dist/cli/daemon/send-request.d.ts +24 -0
  32. package/dist/cli/daemon/send-request.js +92 -0
  33. package/dist/cli/handlers/context-read-handler.d.ts +18 -0
  34. package/dist/cli/handlers/context-read-handler.js +24 -0
  35. package/dist/cli/handlers/edit-handler.d.ts +30 -0
  36. package/dist/cli/handlers/edit-handler.js +133 -0
  37. package/dist/cli/handlers/globe-read-handler.d.ts +17 -0
  38. package/dist/cli/handlers/globe-read-handler.js +23 -0
  39. package/dist/cli/handlers/read-handler.d.ts +16 -0
  40. package/dist/cli/handlers/read-handler.js +78 -0
  41. package/dist/cli/handlers/rem-cache.d.ts +19 -0
  42. package/dist/cli/handlers/rem-cache.js +63 -0
  43. package/dist/cli/handlers/tree-edit-handler.d.ts +30 -0
  44. package/dist/cli/handlers/tree-edit-handler.js +188 -0
  45. package/dist/cli/handlers/tree-parser.d.ts +95 -0
  46. package/dist/cli/handlers/tree-parser.js +506 -0
  47. package/dist/cli/handlers/tree-read-handler.d.ts +28 -0
  48. package/dist/cli/handlers/tree-read-handler.js +53 -0
  49. package/dist/cli/main.d.ts +7 -0
  50. package/dist/cli/main.js +300 -0
  51. package/dist/cli/protocol.d.ts +39 -0
  52. package/dist/cli/protocol.js +35 -0
  53. package/dist/cli/server/config-server.d.ts +26 -0
  54. package/dist/cli/server/config-server.js +363 -0
  55. package/dist/cli/server/ws-server.d.ts +68 -0
  56. package/dist/cli/server/ws-server.js +335 -0
  57. package/dist/cli/utils/output.d.ts +11 -0
  58. package/dist/cli/utils/output.js +13 -0
  59. package/dist/mcp/daemon-client.d.ts +31 -0
  60. package/dist/mcp/daemon-client.js +99 -0
  61. package/dist/mcp/index.d.ts +7 -0
  62. package/dist/mcp/index.js +68 -0
  63. package/dist/mcp/instructions.d.ts +1 -0
  64. package/dist/mcp/instructions.js +249 -0
  65. package/dist/mcp/resources/edit-tree-guide.d.ts +1 -0
  66. package/dist/mcp/resources/edit-tree-guide.js +197 -0
  67. package/dist/mcp/resources/error-reference.d.ts +1 -0
  68. package/dist/mcp/resources/error-reference.js +132 -0
  69. package/dist/mcp/resources/outline-format.d.ts +1 -0
  70. package/dist/mcp/resources/outline-format.js +104 -0
  71. package/dist/mcp/resources/rem-object-fields.d.ts +1 -0
  72. package/dist/mcp/resources/rem-object-fields.js +331 -0
  73. package/dist/mcp/resources/separator-flashcard.d.ts +1 -0
  74. package/dist/mcp/resources/separator-flashcard.js +120 -0
  75. package/dist/mcp/tools/edit-tools.d.ts +5 -0
  76. package/dist/mcp/tools/edit-tools.js +47 -0
  77. package/dist/mcp/tools/infra-tools.d.ts +5 -0
  78. package/dist/mcp/tools/infra-tools.js +43 -0
  79. package/dist/mcp/tools/read-tools.d.ts +5 -0
  80. package/dist/mcp/tools/read-tools.js +195 -0
  81. package/dist/mcp/types.d.ts +12 -0
  82. package/dist/mcp/types.js +4 -0
  83. package/docs/instruction/connect.md +158 -0
  84. package/docs/instruction/disconnect.md +146 -0
  85. package/docs/instruction/edit-rem.md +509 -0
  86. package/docs/instruction/edit-tree.md +419 -0
  87. package/docs/instruction/health.md +159 -0
  88. package/docs/instruction/overall.md +751 -0
  89. package/docs/instruction/read-context.md +353 -0
  90. package/docs/instruction/read-globe.md +206 -0
  91. package/docs/instruction/read-rem.md +476 -0
  92. package/docs/instruction/read-tree.md +428 -0
  93. package/docs/instruction/search.md +196 -0
  94. package/package.json +41 -0
  95. package/remnote-plugin/package.json +48 -0
  96. package/remnote-plugin/postcss.config.js +5 -0
  97. package/remnote-plugin/public/bridge-icon.svg +8 -0
  98. package/remnote-plugin/public/manifest.json +22 -0
  99. package/remnote-plugin/src/bridge/message-router.ts +57 -0
  100. package/remnote-plugin/src/bridge/websocket-client.ts +245 -0
  101. package/remnote-plugin/src/index.css +1 -0
  102. package/remnote-plugin/src/services/breadcrumb.ts +26 -0
  103. package/remnote-plugin/src/services/create-rem.ts +59 -0
  104. package/remnote-plugin/src/services/delete-rem.ts +29 -0
  105. package/remnote-plugin/src/services/index.ts +16 -0
  106. package/remnote-plugin/src/services/move-rem.ts +39 -0
  107. package/remnote-plugin/src/services/powerup-filter.ts +31 -0
  108. package/remnote-plugin/src/services/read-context.ts +368 -0
  109. package/remnote-plugin/src/services/read-globe.ts +197 -0
  110. package/remnote-plugin/src/services/read-rem.ts +284 -0
  111. package/remnote-plugin/src/services/read-tree.ts +222 -0
  112. package/remnote-plugin/src/services/rem-builder.ts +124 -0
  113. package/remnote-plugin/src/services/reorder-children.ts +61 -0
  114. package/remnote-plugin/src/services/search.ts +56 -0
  115. package/remnote-plugin/src/services/write-rem-fields.ts +254 -0
  116. package/remnote-plugin/src/settings.ts +12 -0
  117. package/remnote-plugin/src/style.css +45 -0
  118. package/remnote-plugin/src/test-scripts/AGENTS.md +46 -0
  119. package/remnote-plugin/src/test-scripts/test-actions.ts +230 -0
  120. package/remnote-plugin/src/test-scripts/test-powerup-rendering.ts +722 -0
  121. package/remnote-plugin/src/test-scripts/test-rem-type-mapping.ts +283 -0
  122. package/remnote-plugin/src/test-scripts/test-richtext-builder.ts +207 -0
  123. package/remnote-plugin/src/test-scripts/test-richtext-matrix.ts +332 -0
  124. package/remnote-plugin/src/test-scripts/test-richtext-remaining.ts +245 -0
  125. package/remnote-plugin/src/test-scripts/test-rw-fields.ts +399 -0
  126. package/remnote-plugin/src/types.ts +419 -0
  127. package/remnote-plugin/src/utils/elision.ts +45 -0
  128. package/remnote-plugin/src/utils/index.ts +10 -0
  129. package/remnote-plugin/src/utils/tree-serializer.ts +269 -0
  130. package/remnote-plugin/src/widgets/bridge_widget.tsx +170 -0
  131. package/remnote-plugin/src/widgets/index.tsx +82 -0
  132. package/remnote-plugin/tailwind.config.js +7 -0
  133. package/remnote-plugin/tsconfig.json +21 -0
  134. package/remnote-plugin/webpack.config.js +125 -0
  135. package/skill/SKILL.md +428 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * EditHandler — edit-rem 命令的编排器
3
+ *
4
+ * 在 daemon 层实现三道防线 + str_replace + 后处理校验。
5
+ * Plugin 只负责原子写入(write_rem_fields)。
6
+ *
7
+ * 防线 1:缓存存在性检查(必须先 read 再 edit)
8
+ * 防线 2:乐观并发检测(当前 JSON 与缓存 JSON 比较)
9
+ * 防线 3:str_replace 精确匹配(old_str 必须唯一匹配)
10
+ */
11
+ /** 只读字段集合 — 变更这些字段只产生警告,不执行写入 */
12
+ const READ_ONLY_FIELDS = new Set([
13
+ 'id',
14
+ 'children',
15
+ 'isTable',
16
+ 'portalType', 'portalDirectlyIncludedRem',
17
+ 'propertyType',
18
+ 'aliases',
19
+ 'remsBeingReferenced', 'deepRemsBeingReferenced', 'remsReferencingThis',
20
+ 'taggedRem', 'ancestorTagRem', 'descendantTagRem',
21
+ 'descendants', 'siblingRem',
22
+ 'portalsAndDocumentsIn', 'allRemInDocumentOrPortal', 'allRemInFolderQueue',
23
+ 'timesSelectedInSearch', 'lastTimeMovedTo', 'schemaVersion',
24
+ 'embeddedQueueViewMode',
25
+ 'createdAt', 'updatedAt', 'localUpdatedAt', 'lastPracticed',
26
+ 'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
27
+ 'isPowerupPropertyListItem', 'isPowerupSlot',
28
+ ]);
29
+ export class EditHandler {
30
+ cache;
31
+ forwardToPlugin;
32
+ constructor(cache, forwardToPlugin) {
33
+ this.cache = cache;
34
+ this.forwardToPlugin = forwardToPlugin;
35
+ }
36
+ async handleEditRem(payload) {
37
+ const { remId, oldStr, newStr } = payload;
38
+ // ── 防线 1: 缓存存在性检查 ──
39
+ const cachedJson = this.cache.get('rem:' + remId);
40
+ if (!cachedJson) {
41
+ throw new Error(`Rem ${remId} has not been read yet. Read it first before editing.`);
42
+ }
43
+ // ── 防线 2: 乐观并发检测 ──
44
+ const currentRemObject = await this.forwardToPlugin('read_rem', { remId });
45
+ const currentJson = JSON.stringify(currentRemObject, null, 2);
46
+ if (currentJson !== cachedJson) {
47
+ // 不更新缓存 — 迫使 AI re-read
48
+ throw new Error(`Rem ${remId} has been modified since last read. Please read it again before editing.`);
49
+ }
50
+ // ── 防线 3: str_replace 精确匹配 ──
51
+ const matchCount = countOccurrences(cachedJson, oldStr);
52
+ if (matchCount === 0) {
53
+ throw new Error(`old_str not found in the serialized JSON of rem ${remId}`);
54
+ }
55
+ if (matchCount > 1) {
56
+ throw new Error(`old_str matches ${matchCount} locations in rem ${remId}. ` +
57
+ `Make old_str more specific to match exactly once.`);
58
+ }
59
+ const modifiedJson = cachedJson.replace(oldStr, newStr);
60
+ // ── 后处理校验 ──
61
+ // 1. JSON 解析
62
+ let modified;
63
+ try {
64
+ modified = JSON.parse(modifiedJson);
65
+ }
66
+ catch {
67
+ throw new Error('The replacement produced invalid JSON. Check your new_str for syntax errors.');
68
+ }
69
+ const original = JSON.parse(cachedJson);
70
+ // 2. 推导变更字段
71
+ const changes = {};
72
+ const warnings = [];
73
+ for (const key of Object.keys(modified)) {
74
+ if (JSON.stringify(modified[key]) !== JSON.stringify(original[key])) {
75
+ if (READ_ONLY_FIELDS.has(key)) {
76
+ warnings.push(`Field '${key}' is read-only and was ignored`);
77
+ }
78
+ else {
79
+ changes[key] = modified[key];
80
+ }
81
+ }
82
+ }
83
+ // 3. 语义一致性校验
84
+ if ('todoStatus' in changes && changes.todoStatus !== null) {
85
+ const isTodo = modified.isTodo ?? original.isTodo;
86
+ if (!isTodo) {
87
+ warnings.push("Setting 'todoStatus' without 'isTodo: true' may have no effect");
88
+ }
89
+ }
90
+ // 4. 空变更检查
91
+ if (Object.keys(changes).length === 0) {
92
+ return { ok: true, changes: [], warnings };
93
+ }
94
+ // ── 发送变更到 Plugin ──
95
+ const writeResult = (await this.forwardToPlugin('write_rem_fields', {
96
+ remId,
97
+ changes,
98
+ }));
99
+ if (writeResult.failed) {
100
+ // 部分失败 — 不更新缓存(迫使 AI re-read)
101
+ return {
102
+ ok: false,
103
+ changes: [],
104
+ warnings,
105
+ error: `Failed to update field '${writeResult.failed.field}': ${writeResult.failed.error}`,
106
+ appliedChanges: writeResult.applied,
107
+ failedField: writeResult.failed.field,
108
+ };
109
+ }
110
+ // ── 写入成功 → 从 Plugin 重新获取完整 Rem 并更新缓存(D5)──
111
+ const freshRemObject = await this.forwardToPlugin('read_rem', { remId });
112
+ const freshJson = JSON.stringify(freshRemObject, null, 2);
113
+ this.cache.set('rem:' + remId, freshJson);
114
+ return {
115
+ ok: true,
116
+ changes: Object.keys(changes),
117
+ warnings,
118
+ };
119
+ }
120
+ }
121
+ /** 统计 needle 在 haystack 中出现的次数 */
122
+ function countOccurrences(haystack, needle) {
123
+ let count = 0;
124
+ let pos = 0;
125
+ while (true) {
126
+ pos = haystack.indexOf(needle, pos);
127
+ if (pos === -1)
128
+ break;
129
+ count++;
130
+ pos += needle.length; // 非重叠匹配,与 String.replace 行为一致
131
+ }
132
+ return count;
133
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * GlobeReadHandler — read-globe 请求的业务编排
3
+ *
4
+ * 职责:转发到 Plugin 获取知识库概览,返回结果。
5
+ * globe 是低频操作,不做缓存。
6
+ */
7
+ import type { DefaultsConfig } from '../config.js';
8
+ export interface GlobeReadResult {
9
+ nodeCount: number;
10
+ outline: string;
11
+ }
12
+ export declare class GlobeReadHandler {
13
+ private forwardToPlugin;
14
+ private defaults;
15
+ constructor(forwardToPlugin: (action: string, payload: Record<string, unknown>) => Promise<unknown>, defaults?: DefaultsConfig);
16
+ handleReadGlobe(payload: Record<string, unknown>): Promise<GlobeReadResult>;
17
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * GlobeReadHandler — read-globe 请求的业务编排
3
+ *
4
+ * 职责:转发到 Plugin 获取知识库概览,返回结果。
5
+ * globe 是低频操作,不做缓存。
6
+ */
7
+ import { DEFAULT_DEFAULTS } from '../config.js';
8
+ export class GlobeReadHandler {
9
+ forwardToPlugin;
10
+ defaults;
11
+ constructor(forwardToPlugin, defaults) {
12
+ this.forwardToPlugin = forwardToPlugin;
13
+ this.defaults = defaults ?? DEFAULT_DEFAULTS;
14
+ }
15
+ async handleReadGlobe(payload) {
16
+ const depth = payload.depth ?? this.defaults.readGlobeDepth;
17
+ const maxNodes = payload.maxNodes ?? this.defaults.maxNodes;
18
+ const maxSiblings = payload.maxSiblings ?? this.defaults.maxSiblings;
19
+ return await this.forwardToPlugin('read_globe', {
20
+ depth, maxNodes, maxSiblings,
21
+ });
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ReadHandler — read-rem 请求的业务编排
3
+ *
4
+ * 职责:
5
+ * 1. 转发到 Plugin 获取完整 RemObject
6
+ * 2. 序列化为 JSON 字符串并缓存(完整版本)
7
+ * 3. 根据 fields/full 参数过滤字段返回给 CLI
8
+ */
9
+ import { RemCache } from './rem-cache.js';
10
+ export declare class ReadHandler {
11
+ private cache;
12
+ private forwardToPlugin;
13
+ private onLog?;
14
+ constructor(cache: RemCache, forwardToPlugin: (action: string, payload: Record<string, unknown>) => Promise<unknown>, onLog?: ((message: string, level: "info" | "warn" | "error") => void) | undefined);
15
+ handleReadRem(payload: Record<string, unknown>): Promise<unknown>;
16
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ReadHandler — read-rem 请求的业务编排
3
+ *
4
+ * 职责:
5
+ * 1. 转发到 Plugin 获取完整 RemObject
6
+ * 2. 序列化为 JSON 字符串并缓存(完整版本)
7
+ * 3. 根据 fields/full 参数过滤字段返回给 CLI
8
+ */
9
+ /** R-F 字段(仅 --full 模式输出,默认不输出) */
10
+ const RF_FIELDS = new Set([
11
+ 'isPowerup', 'isPowerupEnum', 'isPowerupProperty',
12
+ 'isPowerupPropertyListItem', 'isPowerupSlot',
13
+ 'deepRemsBeingReferenced',
14
+ 'ancestorTagRem', 'descendantTagRem',
15
+ 'portalsAndDocumentsIn', 'allRemInDocumentOrPortal', 'allRemInFolderQueue',
16
+ 'timesSelectedInSearch', 'lastTimeMovedTo', 'schemaVersion',
17
+ 'embeddedQueueViewMode',
18
+ 'localUpdatedAt', 'lastPracticed',
19
+ ]);
20
+ export class ReadHandler {
21
+ cache;
22
+ forwardToPlugin;
23
+ onLog;
24
+ constructor(cache, forwardToPlugin, onLog) {
25
+ this.cache = cache;
26
+ this.forwardToPlugin = forwardToPlugin;
27
+ this.onLog = onLog;
28
+ }
29
+ async handleReadRem(payload) {
30
+ const remId = payload.remId;
31
+ if (!remId) {
32
+ throw new Error('缺少 remId 参数');
33
+ }
34
+ // 检查旧缓存
35
+ const cacheKey = 'rem:' + remId;
36
+ const previousCachedAt = this.cache.getCreatedAt(cacheKey);
37
+ const includePowerup = payload.includePowerup ?? false;
38
+ // 转发到 Plugin
39
+ const remObject = await this.forwardToPlugin('read_rem', { remId, includePowerup });
40
+ // 缓存完整 JSON
41
+ const fullJson = JSON.stringify(remObject, null, 2);
42
+ this.cache.set(cacheKey, fullJson);
43
+ this.onLog?.(`缓存 Rem ${remId.slice(0, 8)}... (${fullJson.length} bytes)`, 'info');
44
+ // 字段过滤
45
+ const fields = payload.fields;
46
+ const full = payload.full;
47
+ let result;
48
+ if (full) {
49
+ // --full → 返回完整对象(含 R-F 字段)
50
+ result = remObject;
51
+ }
52
+ else if (fields) {
53
+ // --fields 过滤:只返回指定字段 + id
54
+ const obj = remObject;
55
+ result = { id: obj.id };
56
+ for (const field of fields) {
57
+ if (field in obj) {
58
+ result[field] = obj[field];
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ // 默认模式:排除 R-F 字段
64
+ const obj = remObject;
65
+ result = {};
66
+ for (const [key, value] of Object.entries(obj)) {
67
+ if (!RF_FIELDS.has(key)) {
68
+ result[key] = value;
69
+ }
70
+ }
71
+ }
72
+ // 附加缓存覆盖提示
73
+ if (previousCachedAt) {
74
+ result._cacheOverridden = { id: remId, previousCachedAt };
75
+ }
76
+ return result;
77
+ }
78
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * RemCache — LRU 缓存,存储 Rem 的序列化 JSON
3
+ *
4
+ * 缓存存储在 daemon 内存中,生命周期与 daemon 一致。
5
+ * disconnect 关闭 daemon → 缓存自然消失。
6
+ */
7
+ export declare class RemCache {
8
+ private cache;
9
+ private maxSize;
10
+ constructor(maxSize?: number);
11
+ get(remId: string): string | null;
12
+ /** 获取缓存条目的创建时间(ISO 8601),不存在返回 null */
13
+ getCreatedAt(remId: string): string | null;
14
+ set(remId: string, json: string): void;
15
+ has(remId: string): boolean;
16
+ delete(remId: string): void;
17
+ clear(): void;
18
+ get size(): number;
19
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * RemCache — LRU 缓存,存储 Rem 的序列化 JSON
3
+ *
4
+ * 缓存存储在 daemon 内存中,生命周期与 daemon 一致。
5
+ * disconnect 关闭 daemon → 缓存自然消失。
6
+ */
7
+ export class RemCache {
8
+ cache = new Map();
9
+ maxSize;
10
+ constructor(maxSize = 200) {
11
+ this.maxSize = maxSize;
12
+ }
13
+ get(remId) {
14
+ const entry = this.cache.get(remId);
15
+ if (!entry)
16
+ return null;
17
+ entry.lastAccess = Date.now();
18
+ return entry.json;
19
+ }
20
+ /** 获取缓存条目的创建时间(ISO 8601),不存在返回 null */
21
+ getCreatedAt(remId) {
22
+ const entry = this.cache.get(remId);
23
+ return entry ? entry.createdAt : null;
24
+ }
25
+ set(remId, json) {
26
+ const now = Date.now();
27
+ const createdAt = new Date(now).toISOString();
28
+ if (this.cache.has(remId)) {
29
+ const entry = this.cache.get(remId);
30
+ entry.json = json;
31
+ entry.lastAccess = now;
32
+ entry.createdAt = createdAt;
33
+ return;
34
+ }
35
+ // LRU 淘汰
36
+ if (this.cache.size >= this.maxSize) {
37
+ let oldestId = null;
38
+ let oldestTime = Infinity;
39
+ for (const [id, entry] of this.cache) {
40
+ if (entry.lastAccess < oldestTime) {
41
+ oldestTime = entry.lastAccess;
42
+ oldestId = id;
43
+ }
44
+ }
45
+ if (oldestId) {
46
+ this.cache.delete(oldestId);
47
+ }
48
+ }
49
+ this.cache.set(remId, { json, lastAccess: now, createdAt });
50
+ }
51
+ has(remId) {
52
+ return this.cache.has(remId);
53
+ }
54
+ delete(remId) {
55
+ this.cache.delete(remId);
56
+ }
57
+ clear() {
58
+ this.cache.clear();
59
+ }
60
+ get size() {
61
+ return this.cache.size;
62
+ }
63
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * TreeEditHandler — edit-tree 请求的业务编排
3
+ *
4
+ * 职责:
5
+ * 1. 三道防线(缓存存在、变更检测、str_replace 精确匹配)
6
+ * 2. 解析新旧大纲并 diff
7
+ * 3. 逐项执行操作(通过 forwardToPlugin 调用原子操作)
8
+ * 4. 成功后重新 read-tree 更新缓存
9
+ */
10
+ import type { DefaultsConfig } from '../config.js';
11
+ import { RemCache } from './rem-cache.js';
12
+ import { type TreeOp } from './tree-parser.js';
13
+ export interface TreeEditPayload {
14
+ remId: string;
15
+ oldStr: string;
16
+ newStr: string;
17
+ }
18
+ export interface TreeEditResult {
19
+ ok: boolean;
20
+ operations: TreeOp[];
21
+ error?: string;
22
+ details?: Record<string, unknown>;
23
+ }
24
+ export declare class TreeEditHandler {
25
+ private cache;
26
+ private forwardToPlugin;
27
+ private defaults;
28
+ constructor(cache: RemCache, forwardToPlugin: (action: string, payload: Record<string, unknown>) => Promise<unknown>, defaults?: DefaultsConfig);
29
+ handleEditTree(payload: TreeEditPayload): Promise<TreeEditResult>;
30
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * TreeEditHandler — edit-tree 请求的业务编排
3
+ *
4
+ * 职责:
5
+ * 1. 三道防线(缓存存在、变更检测、str_replace 精确匹配)
6
+ * 2. 解析新旧大纲并 diff
7
+ * 3. 逐项执行操作(通过 forwardToPlugin 调用原子操作)
8
+ * 4. 成功后重新 read-tree 更新缓存
9
+ */
10
+ import { DEFAULT_DEFAULTS } from '../config.js';
11
+ import { parseOutline, diffTrees, parsePowerupPrefix } from './tree-parser.js';
12
+ export class TreeEditHandler {
13
+ cache;
14
+ forwardToPlugin;
15
+ defaults;
16
+ constructor(cache, forwardToPlugin, defaults) {
17
+ this.cache = cache;
18
+ this.forwardToPlugin = forwardToPlugin;
19
+ this.defaults = defaults ?? DEFAULT_DEFAULTS;
20
+ }
21
+ async handleEditTree(payload) {
22
+ const { remId, oldStr, newStr } = payload;
23
+ // ── noop 检查 ──
24
+ if (oldStr === newStr) {
25
+ return { ok: true, operations: [] };
26
+ }
27
+ // ── 防线 1: 缓存存在性检查 ──
28
+ const cachedOutline = this.cache.get('tree:' + remId);
29
+ if (!cachedOutline) {
30
+ throw new Error(`Tree rooted at ${remId} has not been read yet. Use read-tree first.`);
31
+ }
32
+ // ── 防线 2: 乐观并发检测 ──
33
+ // 用与 read-tree 相同的参数重新获取最新大纲
34
+ const cachedDepthStr = this.cache.get('tree-depth:' + remId);
35
+ const cachedMaxNodesStr = this.cache.get('tree-maxNodes:' + remId);
36
+ const cachedMaxSiblingsStr = this.cache.get('tree-maxSiblings:' + remId);
37
+ const depth = cachedDepthStr ? Number(cachedDepthStr) : this.defaults.readTreeDepth;
38
+ const maxNodes = cachedMaxNodesStr ? Number(cachedMaxNodesStr) : this.defaults.maxNodes;
39
+ const maxSiblings = cachedMaxSiblingsStr ? Number(cachedMaxSiblingsStr) : this.defaults.maxSiblings;
40
+ const freshResult = await this.forwardToPlugin('read_tree', { remId, depth, maxNodes, maxSiblings });
41
+ if (freshResult.outline !== cachedOutline) {
42
+ // 不更新缓存 — 迫使 AI re-read
43
+ throw new Error(`Tree rooted at ${remId} has been modified since last read-tree. Please read-tree again.`);
44
+ }
45
+ // ── 防线 3: str_replace 精确匹配 ──
46
+ const matchCount = countOccurrences(cachedOutline, oldStr);
47
+ if (matchCount === 0) {
48
+ throw new Error(`old_str not found in the tree outline of ${remId}`);
49
+ }
50
+ if (matchCount > 1) {
51
+ throw new Error(`old_str matches ${matchCount} locations in the tree outline of ${remId}. ` +
52
+ `Make old_str more specific to match exactly once.`);
53
+ }
54
+ const modifiedOutline = cachedOutline.replace(oldStr, newStr);
55
+ // ── 解析新旧大纲 ──
56
+ const oldTree = parseOutline(cachedOutline);
57
+ const newTree = parseOutline(modifiedOutline);
58
+ // ── diff ──
59
+ const diffResult = diffTrees(oldTree, newTree);
60
+ // 检查是否是错误
61
+ if ('type' in diffResult) {
62
+ const err = diffResult;
63
+ return {
64
+ ok: false,
65
+ operations: [],
66
+ error: err.message,
67
+ details: err.details,
68
+ };
69
+ }
70
+ const { operations } = diffResult;
71
+ // 空操作(结构无变化,可能只是元数据变化被 D9 忽略了)
72
+ if (operations.length === 0) {
73
+ return { ok: true, operations: [] };
74
+ }
75
+ // ── 执行操作(D4 操作顺序已在 diffTrees 中排好)──
76
+ // newRemIdMap: 映射新增行的占位 ID → 实际创建的 remId
77
+ const newRemIdMap = new Map();
78
+ for (let i = 0; i < operations.length; i++) {
79
+ const op = operations[i];
80
+ switch (op.type) {
81
+ case 'create': {
82
+ // 解析父节点 ID(可能是占位标记 __new_N__)
83
+ let parentId = op.parentId;
84
+ const placeholderMatch = parentId.match(/^__new_(\d+)__$/);
85
+ if (placeholderMatch) {
86
+ const refIndex = parseInt(placeholderMatch[1], 10);
87
+ const actualId = newRemIdMap.get(refIndex);
88
+ if (!actualId) {
89
+ throw new Error(`内部错误:新增行的父节点(占位 ${refIndex})尚未创建`);
90
+ }
91
+ parentId = actualId;
92
+ }
93
+ // 解析 Markdown 前缀 + 箭头分隔符 → 属性
94
+ const { cleanContent, powerups, backText, practiceDirection } = parsePowerupPrefix(op.content);
95
+ const createResult = await this.forwardToPlugin('create_rem', {
96
+ content: cleanContent,
97
+ parentId,
98
+ position: op.position,
99
+ });
100
+ // 合并所有需要写入的属性(Powerup + 箭头分隔符推导的字段)
101
+ const changes = { ...powerups };
102
+ if (backText !== undefined)
103
+ changes.backText = backText;
104
+ if (practiceDirection !== undefined)
105
+ changes.practiceDirection = practiceDirection;
106
+ // 父节点为 multiline 时,子行标记 isCardItem
107
+ // ⚠ SDK bug: setIsCardItem(true) 会偷偷设 practiceDirection: "forward"
108
+ // 但 practiceDirection 应该只存在于父行(问题行),card-item(答案行)上不应该有。
109
+ // 如果 card-item 带着 practiceDirection: "forward" 且有子行,会被 RemNote 错误渲染成 multiline 卡片。
110
+ // 对策:setIsCardItem(true) 后立即用 practiceDirection: 'none' 覆盖掉副作用。
111
+ if (op.parentIsMultiline) {
112
+ changes.isCardItem = true;
113
+ if (!changes.practiceDirection)
114
+ changes.practiceDirection = 'none';
115
+ }
116
+ if (Object.keys(changes).length > 0) {
117
+ await this.forwardToPlugin('write_rem_fields', {
118
+ remId: createResult.remId,
119
+ changes,
120
+ });
121
+ }
122
+ // 记录新创建的 remId,供后续嵌套引用
123
+ newRemIdMap.set(i, createResult.remId);
124
+ break;
125
+ }
126
+ case 'delete': {
127
+ await this.forwardToPlugin('delete_rem', { remId: op.remId });
128
+ break;
129
+ }
130
+ case 'move': {
131
+ await this.forwardToPlugin('move_rem', {
132
+ remId: op.remId,
133
+ newParentId: op.toParentId,
134
+ position: op.position,
135
+ });
136
+ // 同步 isCardItem:移入 multiline 父节点 → true,移出 → false
137
+ if (op.toParentIsMultiline && !op.fromParentIsMultiline) {
138
+ // ⚠ SDK bug: setIsCardItem(true) 会偷设 practiceDirection: "forward"
139
+ // 对策:覆盖为 'none',但如果 Rem 自身有合法的 practiceDirection(如 ↔ 闪卡)则保留
140
+ const changes = { isCardItem: true };
141
+ if (!op.selfHasPracticeDirection)
142
+ changes.practiceDirection = 'none';
143
+ await this.forwardToPlugin('write_rem_fields', {
144
+ remId: op.remId,
145
+ changes,
146
+ });
147
+ }
148
+ else if (!op.toParentIsMultiline && op.fromParentIsMultiline) {
149
+ // ⚠ SDK bug: setIsCardItem(false) 不会清 practiceDirection(不对称行为)
150
+ // 移出时清掉 SDK 副作用残留,但如果 Rem 自身有合法的 practiceDirection 则保留
151
+ const changes = { isCardItem: false };
152
+ if (!op.selfHasPracticeDirection)
153
+ changes.practiceDirection = 'none';
154
+ await this.forwardToPlugin('write_rem_fields', {
155
+ remId: op.remId,
156
+ changes,
157
+ });
158
+ }
159
+ break;
160
+ }
161
+ case 'reorder': {
162
+ await this.forwardToPlugin('reorder_children', {
163
+ parentId: op.parentId,
164
+ order: op.order,
165
+ });
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ // ── D3: 成功后更新缓存(使用相同参数)──
171
+ const updatedResult = await this.forwardToPlugin('read_tree', { remId, depth, maxNodes, maxSiblings });
172
+ this.cache.set('tree:' + remId, updatedResult.outline);
173
+ return { ok: true, operations };
174
+ }
175
+ }
176
+ /** 统计 needle 在 haystack 中出现的次数 */
177
+ function countOccurrences(haystack, needle) {
178
+ let count = 0;
179
+ let pos = 0;
180
+ while (true) {
181
+ pos = haystack.indexOf(needle, pos);
182
+ if (pos === -1)
183
+ break;
184
+ count++;
185
+ pos += needle.length;
186
+ }
187
+ return count;
188
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * tree-parser.ts — 大纲解析 + diff 算法
3
+ *
4
+ * CLI handlers 层的纯函数。解析 Markdown 大纲文本为树结构,
5
+ * 对比新旧两棵树生成增量操作列表。
6
+ *
7
+ * 不依赖 server / commands / daemon(强约束)。
8
+ */
9
+ /** 大纲中解析出的节点 */
10
+ export interface OutlineNode {
11
+ /** Rem ID,null = 新增行 */
12
+ remId: string | null;
13
+ /** 缩进级别(0 = 根节点) */
14
+ depth: number;
15
+ /** 去除缩进和行尾标记后的纯内容 */
16
+ rawContent: string;
17
+ /** 原始行文本 */
18
+ rawLine: string;
19
+ /** 子节点 */
20
+ children: OutlineNode[];
21
+ /** 是否为省略占位符行 */
22
+ isElided?: boolean;
23
+ }
24
+ /** diff 操作类型 */
25
+ export type TreeOp = {
26
+ type: 'create';
27
+ content: string;
28
+ parentId: string;
29
+ position: number;
30
+ parentIsMultiline?: boolean;
31
+ } | {
32
+ type: 'delete';
33
+ remId: string;
34
+ } | {
35
+ type: 'move';
36
+ remId: string;
37
+ fromParentId: string;
38
+ toParentId: string;
39
+ position: number;
40
+ fromParentIsMultiline?: boolean;
41
+ toParentIsMultiline?: boolean;
42
+ selfHasPracticeDirection?: boolean;
43
+ } | {
44
+ type: 'reorder';
45
+ parentId: string;
46
+ order: string[];
47
+ };
48
+ export interface TreeDiffResult {
49
+ operations: TreeOp[];
50
+ }
51
+ export interface TreeDiffError {
52
+ type: 'content_modified' | 'orphan_detected' | 'folded_delete' | 'root_modified' | 'indent_skip' | 'elided_modified';
53
+ message: string;
54
+ details: Record<string, unknown>;
55
+ }
56
+ /**
57
+ * 解析 Markdown 大纲文本为树结构。
58
+ *
59
+ * 使用栈追踪当前路径,根据缩进级别确定父子关系。
60
+ */
61
+ export declare function parseOutline(text: string): OutlineNode[];
62
+ export interface PowerupPrefixResult {
63
+ cleanContent: string;
64
+ powerups: Record<string, unknown>;
65
+ /** 箭头分隔符后的 backText(有 backText 时) */
66
+ backText?: string;
67
+ /** 箭头分隔符推导的 practiceDirection */
68
+ practiceDirection?: string;
69
+ /** 是否为 multiline(↓ ↑ ↕) */
70
+ isMultiline?: boolean;
71
+ }
72
+ /**
73
+ * 从新增行的 rawContent 中解析 Markdown 前缀和箭头分隔符,提取属性。
74
+ *
75
+ * 解析顺序与 read-tree 输出的嵌套顺序对称:Header → Todo → Code → 箭头分隔符。
76
+ *
77
+ * 箭头分隔符(v2 格式):
78
+ * 中间箭头(有 backText):` → ` ` ← ` ` ↔ ` ` ↓ ` ` ↑ ` ` ↕ `
79
+ * 尾部箭头(无 backText,multiline):` ↓` ` ↑` ` ↕`
80
+ *
81
+ * ⚠️ 已知限制:使用 indexOf 匹配第一个箭头,如果用户新增行的内容本身包含
82
+ * 箭头字符(如 `A → B → C`),会被误切割为 text + backText。
83
+ * 对于 read→edit 往返的已有行不受影响(序列化/反序列化对称)。
84
+ */
85
+ export declare function parsePowerupPrefix(rawContent: string): PowerupPrefixResult;
86
+ /** 从行内容判断是否为 multiline 父节点(内容包含 ↓↑↕ 箭头) */
87
+ export declare function isContentMultiline(rawContent: string): boolean;
88
+ /**
89
+ * 对比新旧大纲树,生成操作列表或报错。
90
+ *
91
+ * @param oldRoots 旧大纲解析结果
92
+ * @param newRoots 新大纲解析结果(str_replace 后)
93
+ * @returns 操作列表或错误
94
+ */
95
+ export declare function diffTrees(oldRoots: OutlineNode[], newRoots: OutlineNode[]): TreeDiffResult | TreeDiffError;