dsh-tree-task-flow 0.3.3 → 0.3.5

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/lib/region.js CHANGED
@@ -1,32 +1,28 @@
1
1
  /**
2
- * 折叠:把一个节点的执行过程从上下文里收起来,换成一条携带它结果的汇总消息。
2
+ * 折叠:把一段执行过程从上下文里收起来,换成一条说明收走了什么的短消息。
3
3
  *
4
- * 上下文按固定三级排成这样:
4
+ * 规则只有一条,**只看表层,与计划树无关**:
5
5
  *
6
- * [节点 0] 系统提示词
7
- * [用户消息]
8
- * [目标级汇总] ← goal.cursor
9
- * [任务级汇总] ← task.cursor
10
- * [子任务汇总] [子任务汇总] … ← step.cursor 逐条往前推
11
- * [任务级汇总]
12
- * [子任务汇总] …
6
+ * 从上一个边界调用之后,到下一个 `tree_task_done` 之前,中间那一段过程收走。
13
7
  *
14
- * 每层一个游标,记着"这一层总结到哪儿了"。折叠时**从游标之后**替换到末尾:
8
+ * 边界调用是 `tree_task_create` / `tree_task_plan` / `tree_task_done` 这三个工具。
9
+ * 它们的调用与返回都留在表层上——那是模型看"计划是什么、下一步干什么"的地方,
10
+ * 折叠只收它们**之间**的过程。
15
11
  *
16
- * 子任务完成 → replace([上个游标之后, 末尾]) → 本子任务的汇总
17
- * 任务完成 → replace([进任务时的末尾之后, 末尾]) → 本任务的汇总
18
- * 目标完成 → replace([进目标时的末尾之后, 末尾]) → 目标的汇总
12
+ * 两条硬约束:
19
13
  *
20
- * 游标自己留在替换范围之外,所以**同级上一次的汇总消息不会被这一次折叠卷走**:
21
- * 新一层开始时游标正好停在它上面。
14
+ * - 中间夹着真人消息就**整段不折**:往前折丢了需求,往后折丢了回应,两边都出事;
15
+ * - 中间没有过程(两次调用挨着)就**什么都不注入**,不追加、不留空消息。
22
16
  *
23
- * 游标有两个来源:新开一层时是"进入那一刻 surface 的末尾节点",折叠之后就变成
24
- * 刚落下的那条汇总消息。前者可能指向别的生产者留下的节点,所以判定游标是否可用
25
- * 只看"它还在不在 surface 上",不要求它是本插件放的。
17
+ * 于是上下文长成这样:
26
18
  *
27
- * 游标一旦失效——被压缩遮掉了,或它之后本来就什么都没有——就没有可替换的区间。
28
- * 这一次折叠**降级为纯追加**:执行过程收不起来,但提交的结果照样进上下文。
29
- * 屏蔽是尽力而为,落下结果是必须做到的。
19
+ * [节点 0] 系统提示词
20
+ * [用户消息]
21
+ * [tree_task_create 调用] [返回:计划已建立 + 整棵树]
22
+ * [tree_task_plan 调用] [返回:已添加 2 个子任务 + 整棵树]
23
+ * [tree_task消息:隐藏了 12 条过程上下文。读取:… 写入:…]
24
+ * [tree_task_done 调用] [返回:接下来需要进行步骤:「二」(s-4)。]
25
+ * [当前这一段执行过程] ← 下一次折叠的对象
30
26
  *
31
27
  * 时机是 `agent/pre-step`:上一轮已结束、下一个请求还没构造。工具执行中途不能折叠
32
28
  * ——那一刻 surface 还在增长,替换会把自己卷进去。
@@ -34,16 +30,25 @@
34
30
  * @module dsh-tree-task-flow/region
35
31
  */
36
32
 
37
- import { activePath, checkpointOf, findNode, renderContinue } from "./plan.js";
33
+ import { activePath } from "./plan.js";
38
34
 
39
35
  /** 本插件放下的消息都带这个身份。 */
40
36
  const PLUGIN = "dsh-tree-task-flow";
41
37
 
42
- /** 三层固定的名字,顺序就是折叠必须遵守的顺序:外层的折叠会吃掉内层的一切。 */
38
+ /** 三层固定的名字。只给界面用的游标同步按这个顺序走。 */
43
39
  const LEVELS = ["goal", "task", "step"];
44
40
 
45
- /** 层级的显示名。 */
46
- const LEVEL_LABEL = { goal: "目标", task: "任务", step: "子任务" };
41
+ /** 边界调用:这三个工具的调用与返回都不进替换区间,它们是折叠的起止点。 */
42
+ const BOUNDARY_TOOLS = new Set(["tree_task_create", "tree_task_plan", "tree_task_done"]);
43
+
44
+ /** 注入消息的抬头。 */
45
+ const NOTICE_PREFIX = "tree_task消息:";
46
+
47
+ /** 会改动文件的工具:它们的调用参数里带路径,算进"写入"列表。 */
48
+ const WRITE_TOOLS = new Set(["write", "edit", "str_replace_editor"]);
49
+
50
+ /** 只读取文件的工具:算进"读取"列表。 */
51
+ const READ_TOOLS = new Set(["read", "read_image"]);
47
52
 
48
53
  /** 折叠消息的编号,保证同一毫秒内连折两次也不会撞 id。 */
49
54
  let foldSeq = 0;
@@ -53,99 +58,81 @@ function foldId() {
53
58
  return `tt-fold-${Date.now().toString(36)}-${foldSeq.toString(36)}`;
54
59
  }
55
60
 
56
- /** 一个节点是否已经结束(完成或丢弃)。两种都值得把执行过程收起来。 */
57
- function isClosed(node) {
58
- return node.status === "done" || node.status === "dropped";
59
- }
60
-
61
61
  /** 取当前 surface 的最后一个节点 seq;surface 为空时返回 null。 */
62
62
  export function tailSeq(session) {
63
63
  const nodes = session.surface?.nodes;
64
64
  return nodes !== undefined && nodes.length > 0 ? nodes[nodes.length - 1] : null;
65
65
  }
66
66
 
67
- /**
68
- * 定位「游标之后到末尾」这一段,也就是这一步要折叠掉的东西。
69
- *
70
- * surface 是**位置顺序**、不是 seq 顺序:`replace` 会把新节点放回被替换的位置
71
- * (`nodes.splice(startIdx, count, seq)`),所以节点的 seq 并不递增——系统提示词
72
- * 被重写过之后,它的 seq 可能比它后面的用户消息大得多。
73
- *
74
- * 因此起止都必须按**位置索引**取,与 DSH 自己算 `nodes.slice(startIdx, endIdx + 1)`
75
- * 的方式一致。按 seq 区间筛选会在这种乱序下漏掉节点,而 `sourceEventSeqs`
76
- * 少列一个就会被 `session.append` 直接拒绝。
77
- *
78
- * 起点落在游标之后(`cursorIdx + 1`),游标本身因此永远留在 surface 上。
79
- *
80
- * @param session - 当前会话。
81
- * @param cursorSeq - 该层的游标 seq。
82
- * @returns `{ startIdx, endIdx, startSeq, endSeq, shadowed }`;
83
- * 游标不在 surface 上、或它已经是末尾(这一层还没产生任何可折叠的东西)时返回 null,
84
- * 调用方据此降级为追加。
85
- */
86
- export function foldRange(session, cursorSeq) {
87
- const nodes = session.surface?.nodes ?? [];
88
- if (!Number.isInteger(cursorSeq)) return null;
89
- const cursorIdx = nodes.indexOf(cursorSeq);
90
- if (cursorIdx === -1) return null;
91
- const startIdx = cursorIdx + 1;
92
- if (startIdx >= nodes.length) return null;
93
- const endIdx = nodes.length - 1;
94
- return {
95
- startIdx,
96
- endIdx,
97
- startSeq: nodes[startIdx],
98
- endSeq: nodes[endIdx],
99
- shadowed: nodes.slice(startIdx),
100
- };
67
+ /** 这条 assistant 消息里调用了哪些工具。 */
68
+ function toolNamesOf(event) {
69
+ const content = event?.data?.message?.content;
70
+ if (!Array.isArray(content)) return [];
71
+ return content.filter((part) => part?.type === "tool-call").map((part) => part.name);
72
+ }
73
+
74
+ /** 这条 assistant 消息里有没有边界调用。 */
75
+ function isBoundaryCall(event) {
76
+ return toolNamesOf(event).some((name) => BOUNDARY_TOOLS.has(name));
77
+ }
78
+
79
+ /** 这条 assistant 消息里有没有 `tree_task_done`。 */
80
+ function isDoneCall(event) {
81
+ return toolNamesOf(event).includes("tree_task_done");
82
+ }
83
+
84
+ /** 这条消息是不是真人发的——插件注入的消息 source 是 plugin,不算。 */
85
+ function isHumanMessage(event) {
86
+ return event?.type === "user/message" && event.data?.source?.kind === "user";
87
+ }
88
+
89
+ /** 这条消息是不是本插件自己落下的折叠通告。 */
90
+ function isPluginNotice(event) {
91
+ return event?.type === "user/message" && event.data?.source?.plugin === PLUGIN;
92
+ }
93
+
94
+ /** 从一次工具调用的参数里取它指向的文件路径;不是文件类调用就返回 null。 */
95
+ function filePathOf(call) {
96
+ if (typeof call?.arguments !== "string") return null;
97
+ let args;
98
+ try {
99
+ args = JSON.parse(call.arguments);
100
+ } catch {
101
+ return null;
102
+ }
103
+ const value = args?.file_path ?? args?.path;
104
+ return typeof value === "string" && value.trim() !== "" ? value : null;
101
105
  }
102
106
 
103
107
  /**
104
- * 把三层游标对齐到当前的节点路径。
108
+ * 被这次替换收走的那一段里,读过和写过哪些文件。
105
109
  *
106
- * 一层的 key 变了(换节点了)就重建这个槽,游标落在**进入这一层那一刻的末尾节点**上;
107
- * 那一刻的末尾后面就是这个节点将要产生的全部内容,正是下一次折叠要收走的东西。
108
- *
109
- * 上层重建会顺带清空下层:上层一折叠,下层的一切就已经不在上下文里了,
110
- * 留着它的游标只会指向一个不存在的边界。
110
+ * 路径取自工具调用的 `file_path` / `path` 参数——界面列出文件变更用的是同一个来源。
111
+ * 只认读写文件的工具:`pwsh` / `bash` 这类命令里碰过什么文件看不出来,不计入。
111
112
  *
112
113
  * @param session - 当前会话。
113
- * @param cursors - `store.readSurfaces()` 读出来的游标对象,**就地修改**。
114
- * @param active - `activePath(plan)` 的结果;`null` 表示整棵树已结束。
115
- * @returns 是否有改动(调用方据此决定要不要写盘)。
114
+ * @param shadowed - 被收走的节点 seq,按位置顺序。
115
+ * @returns `{ read, write }`,各是去重后的路径数组,按出现顺序。
116
116
  */
117
- export function syncCursors(session, cursors, active) {
118
- const tail = tailSeq(session);
119
- let changed = false;
120
- let outerRebuilt = false;
121
-
122
- for (const level of LEVELS) {
123
- const key = active?.[level]?.id ?? null;
124
- const slot = cursors[level] ?? null;
125
- // `Object.hasOwn` 同时挡住旧格式留下的槽:那种槽没有 cursor 字段,
126
- // 直接当成没对齐、重建一次就好,不必迁移磁盘上的文件。
127
- const aligned =
128
- slot !== null && key !== null && slot.key === key && Object.hasOwn(slot, "cursor");
129
- if (aligned && !outerRebuilt) continue;
130
-
131
- // 外层一旦重建(或清空),内层的边界就已经并进外层了,无论 key 是否相同都要重来。
132
- outerRebuilt = true;
133
- if (key === null) {
134
- if (slot !== null) {
135
- cursors[level] = null;
136
- changed = true;
137
- }
138
- continue;
117
+ export function touchedFiles(session, shadowed) {
118
+ const read = new Set();
119
+ const write = new Set();
120
+ for (const seq of shadowed) {
121
+ const event = session.eventAt(seq);
122
+ if (event?.type !== "assistant/message") continue;
123
+ for (const part of event.data?.message?.content ?? []) {
124
+ if (part?.type !== "tool-call") continue;
125
+ const path = filePathOf(part);
126
+ if (path === null) continue;
127
+ if (WRITE_TOOLS.has(part.name)) write.add(path);
128
+ else if (READ_TOOLS.has(part.name)) read.add(path);
139
129
  }
140
- cursors[level] = { key, cursor: tail };
141
- changed = true;
142
130
  }
143
-
144
- return changed;
131
+ return { read: [...read], write: [...write] };
145
132
  }
146
133
 
147
134
  /**
148
- * 造一条汇总消息:它取代的是一段执行过程,携带的是那段过程提交的结果。
135
+ * 造一条汇总消息:它取代的是一段执行过程,携带的是"收走了什么"。
149
136
  *
150
137
  * 形态是 `notice` + `summary`:界面把它渲染成一行可读的说明,展开才是正文。
151
138
  * 刻意**不用** `snapshot`——那个形态是"运行时上下文快照"的专属标签,
@@ -166,143 +153,165 @@ export function summaryMessage(id, text, summary) {
166
153
  }
167
154
 
168
155
  /**
169
- * 这一层从 `fromKey` 起、连续已结束的节点。
156
+ * 汇总消息的正文:这一次收走了多少条过程上下文,以及那一段里读写过哪些文件。
170
157
  *
171
- * 一个 key 和下一个 key 之间可能挤着好几个节点——模型一轮里连着完成两个子任务,
172
- * 或者中间那个被丢弃了。它们同属一段执行过程,收成一条汇总就够。
158
+ * 文件列表是给后面用的——过程收走了,但"这活碰过哪些文件"得留下,
159
+ * 否则模型要从头再找一遍。
173
160
  *
174
- * 只在**同一父节点下按树里的顺序**往后数,遇到第一个还没结束的节点就停:
175
- * 那正是这一层现在的活跃节点。
161
+ * @param count - 被这次替换收走的节点条数。
162
+ * @param files - `touchedFiles()` 的结果。
163
+ * @returns 正文文本,可能多行。
164
+ */
165
+ function renderSummary(count, files) {
166
+ const lines = [`${NOTICE_PREFIX}隐藏了${count}条过程上下文。`];
167
+ if (files.read.length > 0) lines.push(`读取:${files.read.join("、")}`);
168
+ if (files.write.length > 0) lines.push(`写入:${files.write.join("、")}`);
169
+ return lines.join("\n");
170
+ }
171
+
172
+ /**
173
+ * 汇总消息的一行摘要,给界面折叠标题用。
176
174
  *
177
- * @param plan - 计划树。
178
- * @param level - `goal` / `task` / `step`。
179
- * @param fromKey - 这一层上一次的活跃节点 id。
180
- * @returns 已结束的节点,按树里的顺序;没有就返回空数组。
175
+ * @param count - 被这次替换收走的节点条数。
176
+ * @param files - `touchedFiles()` 的结果。
177
+ * @returns 一行摘要。
181
178
  */
182
- export function closedFrom(plan, level, fromKey) {
183
- const goal = plan?.goal;
184
- if (goal === undefined || goal === null) return [];
179
+ function summaryLine(count, files) {
180
+ const touched = files.read.length + files.write.length;
181
+ const base = `树形任务流 · 隐藏了${count}条过程上下文`;
182
+ return touched === 0 ? base : `${base} · 涉及 ${touched} 个文件`;
183
+ }
185
184
 
186
- if (level === "goal") {
187
- return goal.id === fromKey && isClosed(goal) ? [goal] : [];
185
+ /**
186
+ * 找这一次要折叠的区间。
187
+ *
188
+ * 从末尾往前找两次边界调用:**最近的那次必须是 `tree_task_done`**(它就是这次完成的
189
+ * 那一下),再往前那次(create / plan / done 都算)是起点。两次调用之间的过程才是要收的。
190
+ * 起点那次调用自己的 `tool/result` 也算这次调用的一部分,一并跳过。
191
+ *
192
+ * 表层上找不到更早的边界调用时(比如它已经被压缩遮掉),起点退回表层第一个节点之后
193
+ * ——那时区间多半会撞上真人消息,于是整段不折,这是安全的那一侧。
194
+ *
195
+ * @param session - 当前会话。
196
+ * @returns `{ from, to, shadowed }`;这一轮不该折时返回 null。
197
+ */
198
+ export function foldRange(session) {
199
+ const nodes = session.surface?.nodes ?? [];
200
+ if (typeof session.eventAt !== "function" || nodes.length === 0) return null;
201
+
202
+ let endIdx = -1;
203
+ let startIdx = -1;
204
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
205
+ if (!isBoundaryCall(session.eventAt(nodes[index]))) continue;
206
+ if (endIdx === -1) {
207
+ // 末尾最近的那次边界调用不是 done:说明还没走到"完成"这一步,这一轮不折。
208
+ if (!isDoneCall(session.eventAt(nodes[index]))) return null;
209
+ endIdx = index;
210
+ continue;
211
+ }
212
+ startIdx = index;
213
+ break;
188
214
  }
189
-
190
- const list =
191
- level === "task" ? (goal.tasks ?? []) : (findNode(plan, fromKey)?.task?.steps ?? []);
192
- const start = list.findIndex((node) => node.id === fromKey);
193
- if (start === -1) return [];
194
-
195
- const out = [];
196
- for (let index = start; index < list.length; index += 1) {
197
- if (!isClosed(list[index])) break;
198
- out.push(list[index]);
215
+ if (endIdx === -1) return null;
216
+
217
+ // 起点:上一次边界调用之后。找不到就退回表层第一个节点之后(节点 0 是系统提示词,
218
+ // 受 DSH 的重写保护,不能进替换区间)。
219
+ //
220
+ // 还要跳过紧邻的两样东西:那次调用自己的 `tool/result`,以及**上一次折叠落下的通告**
221
+ // ——后者虽然夹在两次边界调用之间,却已经是收过的结果,再收一次就会一轮轮空折下去。
222
+ let from = startIdx === -1 ? 1 : startIdx + 1;
223
+ while (from < endIdx) {
224
+ const event = session.eventAt(nodes[from]);
225
+ if (event?.type === "tool/result" || isPluginNotice(event)) {
226
+ from += 1;
227
+ continue;
228
+ }
229
+ break;
199
230
  }
200
- return out;
231
+ const to = endIdx - 1;
232
+ if (from > to) return null;
233
+
234
+ return { from, to, shadowed: nodes.slice(from, to + 1) };
201
235
  }
202
236
 
203
237
  /**
204
- * 汇总消息的正文:这些节点各自提交了什么。
238
+ * 折一次:把边界调用之间那一段过程收走,换成一条说明收走了什么的短消息。
205
239
  *
206
- * 正处在检查点上时,把检查点提示一并附上——那一刻模型正需要知道"这一层只剩
207
- * 拆子节点和提交结果两条路",而这条消息就是它接下来唯一会读到的说明。
240
+ * 一轮最多折一次——折完这一段就已经不在上下文里了。
241
+ *
242
+ * @param ctx - 挂载本行的 Cordis 上下文,只用来记日志。
243
+ * @param session - 当前会话。
244
+ * @returns 是否真的替换了内容。
208
245
  */
209
- function renderSummary(level, nodes, plan) {
210
- const label = LEVEL_LABEL[level];
211
- const parts = [];
212
-
213
- for (const node of nodes) {
214
- parts.push(
215
- node.status === "done"
216
- ? `${label}「${node.title}」(${node.id}) 已完成,它的执行过程已从上下文里收起。`
217
- : `${label}「${node.title}」(${node.id}) 已丢弃。`,
246
+ export function foldOnce(ctx, session) {
247
+ const range = foldRange(session);
248
+ if (range === null) return false;
249
+
250
+ // 中间夹着真人消息:整段不折。往前折丢了需求,往后折丢了回应。
251
+ if (range.shadowed.some((seq) => isHumanMessage(session.eventAt(seq)))) return false;
252
+
253
+ const nodes = session.surface.nodes;
254
+ const files = touchedFiles(session, range.shadowed);
255
+ try {
256
+ session.append(
257
+ "user/message",
258
+ summaryMessage(
259
+ foldId(),
260
+ renderSummary(range.shadowed.length, files),
261
+ summaryLine(range.shadowed.length, files),
262
+ ),
263
+ {
264
+ surfaceOp: { op: "replace", startSeq: nodes[range.from], endSeq: nodes[range.to] },
265
+ sourceEventSeqs: range.shadowed,
266
+ },
218
267
  );
219
- const result = node.result?.text;
220
- if (typeof result === "string" && result !== "") parts.push(`结果:\n${result}`);
268
+ return true;
269
+ } catch (error) {
270
+ // 注入失败不能拖垮会话,原区间原样留着,下一轮还有机会。
271
+ ctx.logger?.warn?.(`dsh-tree-task-flow: 折叠失败,保留原区间:${String(error?.message ?? error)}`);
272
+ return false;
221
273
  }
222
-
223
- if (checkpointOf(plan) !== null) parts.push(renderContinue(plan));
224
- return parts.join("\n\n");
225
- }
226
-
227
- /** 汇总消息的一行摘要,给界面折叠标题用。 */
228
- function summaryLine(level, nodes) {
229
- const label = LEVEL_LABEL[level];
230
- if (nodes.length === 1) return `树形任务流 · ${label}「${nodes[0].title}」已完成`;
231
- return `树形任务流 · ${nodes.length} 个${label}已完成`;
232
274
  }
233
275
 
234
276
  /**
235
- * 把刚结束那一层折起来。
236
- *
237
- * 判定只有一条:某一层的 key 和 `activePath` 对不上,就说明这一层换节点了,
238
- * 刚过去的那个节点(连同它后面连续结束的兄弟)已经做完。
239
- *
240
- * 从外往里走,**碰到第一层换了就停**:外层的折叠范围一直覆盖到末尾,
241
- * 内层的一切本来就在里面,再折一次只会把外层刚落下的汇总又卷走。
242
- * 剩下的交给 `syncCursors` 重建游标。
277
+ * 把三层游标对齐到当前的节点路径。
243
278
  *
244
- * 折叠失败或无从折叠时降级为追加,见 `foldRange` 的说明。
279
+ * 游标**不参与折叠区间的计算**(那由 `foldRange` 在表层上现算),它只记"这一层走到
280
+ * 哪儿了",给界面和排查用。一层的 key 变了(换节点了)就重建这个槽,游标落在
281
+ * **进入这一层那一刻的末尾节点**上。
245
282
  *
246
- * 折叠必须用**换节点之前**的游标,所以这一步要跑在 `syncCursors` 前面。
283
+ * 上层重建会顺带重建下层:上层一换,下层的边界就已经并进上层了。
247
284
  *
248
- * @param ctx - 挂载本行的 Cordis 上下文,只用来记日志。
249
285
  * @param session - 当前会话。
250
- * @param cursors - 游标状态,**就地修改**。
251
- * @param plan - 计划树。
252
- * @param active - `activePath(plan)`;`null` 表示整棵树已结束。
253
- * @returns 是否有改动。
286
+ * @param cursors - `store.readSurfaces()` 读出来的游标对象,**就地修改**。
287
+ * @param active - `activePath(plan)` 的结果;`null` 表示整棵树已结束。
288
+ * @returns 是否有改动(调用方据此决定要不要写盘)。
254
289
  */
255
- export function foldFinishedLevels(ctx, session, cursors, plan, active) {
290
+ export function syncCursors(session, cursors, active) {
291
+ const tail = tailSeq(session);
256
292
  let changed = false;
293
+ let outerRebuilt = false;
257
294
 
258
295
  for (const level of LEVELS) {
259
296
  const key = active?.[level]?.id ?? null;
260
297
  const slot = cursors[level] ?? null;
298
+ // `Object.hasOwn` 同时挡住旧格式留下的槽:那种槽没有 cursor 字段,
299
+ // 直接当成没对齐、重建一次就好,不必迁移磁盘上的文件。
300
+ const aligned =
301
+ slot !== null && key !== null && slot.key === key && Object.hasOwn(slot, "cursor");
302
+ if (aligned && !outerRebuilt) continue;
261
303
 
262
- // 还没有游标的层没有历史可折:第一次进入这一层由 syncCursors 立游标。
263
- if (slot === null || !Object.hasOwn(slot, "cursor")) continue;
264
- if (slot.key === key) continue;
265
-
266
- const finished = closedFrom(plan, level, slot.key);
267
- if (finished.length > 0) {
268
- const range = foldRange(session, slot.cursor);
269
-
270
- // 折得动就替换:执行过程从上下文里消失,换成一条携带结果的汇总。
271
- // 折不动(游标已被压缩遮掉,或游标之后本就没有节点)就**只追加**:
272
- // 那时没有可替换的区间,但提交的结果必须进上下文——否则模型再也看不到
273
- // 这个节点产出了什么。收不起来可以,丢掉结果不行。
274
- const intent =
275
- range === null
276
- ? { surfaceOp: "append" }
277
- : {
278
- surfaceOp: { op: "replace", startSeq: range.startSeq, endSeq: range.endSeq },
279
- sourceEventSeqs: range.shadowed,
280
- };
281
-
282
- try {
283
- const node = session.append(
284
- "user/message",
285
- summaryMessage(
286
- foldId(),
287
- renderSummary(level, finished, plan),
288
- summaryLine(level, finished),
289
- ),
290
- intent,
291
- );
292
- // 游标停在刚落下的汇总上:下一次同级折叠从它**之后**开始,
293
- // 所以这一条汇总不会被那一次卷走。
294
- cursors[level] = key === null ? null : { key, cursor: node.seq };
304
+ // 外层一旦重建(或清空),内层的边界就已经并进外层了,无论 key 是否相同都要重来。
305
+ outerRebuilt = true;
306
+ if (key === null) {
307
+ if (slot !== null) {
308
+ cursors[level] = null;
295
309
  changed = true;
296
- } catch (error) {
297
- // 注入失败不能拖垮会话,也不能把游标推走——推走了这段过程就再也折不掉。
298
- ctx.logger?.warn?.(
299
- `dsh-tree-task-flow: 折叠 ${level} 失败,保留原区间:${String(error?.message ?? error)}`,
300
- );
301
310
  }
311
+ continue;
302
312
  }
303
-
304
- // 外层换了,内层的边界已经并进这一层,剩下的交给 syncCursors。
305
- break;
313
+ cursors[level] = { key, cursor: tail };
314
+ changed = true;
306
315
  }
307
316
 
308
317
  return changed;
@@ -327,13 +336,12 @@ export function registerRegion(ctx, { store }) {
327
336
  const plan = store.readPlan(sessionId);
328
337
  if (plan === null) return decision;
329
338
 
330
- const cursors = store.readSurfaces(sessionId);
331
- const active = activePath(plan);
339
+ // 折叠只看表层:边界调用之间那一段过程。
340
+ let changed = foldOnce(ctx, session);
332
341
 
333
- // 顺序不能颠倒:折叠要读**换节点之前**的游标,而 syncCursors 一跑就把游标
334
- // 推到当前末尾,那时再也没有折叠的起点了。
335
- let changed = foldFinishedLevels(ctx, session, cursors, plan, active);
336
- if (syncCursors(session, cursors, active)) changed = true;
342
+ // 三层游标照旧维护,它是给界面看的,不参与上面那次判断。
343
+ const cursors = store.readSurfaces(sessionId);
344
+ if (syncCursors(session, cursors, activePath(plan))) changed = true;
337
345
 
338
346
  if (changed) {
339
347
  cursors.sessionId = sessionId;
package/lib/store.js CHANGED
@@ -56,6 +56,22 @@ function writeJson(file, value) {
56
56
  renameSync(tmp, file);
57
57
  }
58
58
 
59
+ /**
60
+ * 老结构(整棵树只有一个 `goal` 字段)读进来时升级成 `goals` 数组。
61
+ *
62
+ * 计划文件是插件自己的持久状态,换个版本不该让既有的树失联。
63
+ *
64
+ * @param plan - 磁盘上读到的对象,可能是 null。
65
+ * @returns 归一化后的计划树。
66
+ */
67
+ function normalizePlan(plan) {
68
+ if (plan === null || typeof plan !== "object") return plan;
69
+ if (Array.isArray(plan.goals)) return plan;
70
+ if (plan.goal === undefined || plan.goal === null) return { ...plan, goals: [] };
71
+ const { goal, ...rest } = plan;
72
+ return { ...rest, version: 2, goals: [goal] };
73
+ }
74
+
59
75
  /**
60
76
  * 建一个 store。一个插件实例一个,所有路径都在 root 下。
61
77
  *
@@ -78,7 +94,7 @@ export function createStore(root) {
78
94
  */
79
95
  readPlan(sessionId) {
80
96
  if (!sessionId) return null;
81
- return readJson(file("plans", `${sessionId}.json`), null);
97
+ return normalizePlan(readJson(file("plans", `${sessionId}.json`), null));
82
98
  },
83
99
 
84
100
  /**
@@ -96,15 +112,13 @@ export function createStore(root) {
96
112
  * 层级固定为 目标 → 任务 → 子任务,每层一个槽。槽的形状是 `{ key, cursor }`:
97
113
  *
98
114
  * - `key` 该层当前节点的 id。与上一轮不同就说明这一层换了节点。
99
- * - `cursor` 该层折叠的起点:折叠时从它**之后**替换到末尾,它自己留在 surface 上。
100
- * 初值是**进入这一层那一刻 surface 的末尾节点**,之后每折叠一次就
101
- * 更新为刚落下的那条汇总消息。所以它指向的**不一定是本插件放下的
102
- * 汇总消息**——第一次折叠之前,它可能指向别的生产者留下的节点。
103
- * 也正因为它停在边界上,同级上一次的汇总消息才不会被这一次折叠卷走。
104
- * 为 null 表示进入这一层时 surface 还是空的,没有可保留的边界。
115
+ * - `cursor` 进入这一层那一刻 surface 的末尾节点。
105
116
  *
106
117
  * 槽本身为 null 表示这一层还没开始,或整棵树已经结束。
107
118
  *
119
+ * 它**不参与折叠**:折叠区间由 `region.foldRange()` 在表层上现算(边界调用之间那
120
+ * 一段过程)。这里记的只是"这一层走到哪儿了",给界面和排查用。
121
+ *
108
122
  * @param sessionId - 会话 id。
109
123
  * @returns `{ sessionId, goal, task, step, updatedAt }`。
110
124
  */