dsh-tree-task-flow 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.
package/lib/region.js ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * 折叠:把一个节点的执行过程从上下文里收起来,换成一条携带它结果的汇总消息。
3
+ *
4
+ * 上下文按固定三级排成这样:
5
+ *
6
+ * [节点 0] 系统提示词
7
+ * [用户消息]
8
+ * [目标级汇总] ← goal.cursor
9
+ * [任务级汇总] ← task.cursor
10
+ * [子任务汇总] [子任务汇总] … ← step.cursor 逐条往前推
11
+ * [任务级汇总]
12
+ * [子任务汇总] …
13
+ *
14
+ * 每层一个游标,记着"这一层总结到哪儿了"。折叠时**从游标之后**替换到末尾:
15
+ *
16
+ * 子任务完成 → replace([上个游标之后, 末尾]) → 本子任务的汇总
17
+ * 任务完成 → replace([进任务时的末尾之后, 末尾]) → 本任务的汇总
18
+ * 目标完成 → replace([进目标时的末尾之后, 末尾]) → 目标的汇总
19
+ *
20
+ * 游标自己留在替换范围之外,所以**同级上一次的汇总消息不会被这一次折叠卷走**:
21
+ * 新一层开始时游标正好停在它上面。
22
+ *
23
+ * 游标有两个来源:新开一层时是"进入那一刻 surface 的末尾节点",折叠之后就变成
24
+ * 刚落下的那条汇总消息。前者可能指向别的生产者留下的节点,所以判定游标是否可用
25
+ * 只看"它还在不在 surface 上",不要求它是本插件放的。
26
+ *
27
+ * 游标一旦失效——被压缩遮掉了,或它之后本来就什么都没有——就没有可替换的区间。
28
+ * 这一次折叠**降级为纯追加**:执行过程收不起来,但提交的结果照样进上下文。
29
+ * 屏蔽是尽力而为,落下结果是必须做到的。
30
+ *
31
+ * 时机是 `agent/pre-step`:上一轮已结束、下一个请求还没构造。工具执行中途不能折叠
32
+ * ——那一刻 surface 还在增长,替换会把自己卷进去。
33
+ *
34
+ * @module dsh-tree-task-flow/region
35
+ */
36
+
37
+ import { activePath, checkpointOf, findNode, renderContinue } from "./plan.js";
38
+
39
+ /** 本插件放下的消息都带这个身份。 */
40
+ const PLUGIN = "dsh-tree-task-flow";
41
+
42
+ /** 三层固定的名字,顺序就是折叠必须遵守的顺序:外层的折叠会吃掉内层的一切。 */
43
+ const LEVELS = ["goal", "task", "step"];
44
+
45
+ /** 层级的显示名。 */
46
+ const LEVEL_LABEL = { goal: "目标", task: "任务", step: "子任务" };
47
+
48
+ /** 折叠消息的编号,保证同一毫秒内连折两次也不会撞 id。 */
49
+ let foldSeq = 0;
50
+
51
+ function foldId() {
52
+ foldSeq += 1;
53
+ return `tt-fold-${Date.now().toString(36)}-${foldSeq.toString(36)}`;
54
+ }
55
+
56
+ /** 一个节点是否已经结束(完成或丢弃)。两种都值得把执行过程收起来。 */
57
+ function isClosed(node) {
58
+ return node.status === "done" || node.status === "dropped";
59
+ }
60
+
61
+ /** 取当前 surface 的最后一个节点 seq;surface 为空时返回 null。 */
62
+ export function tailSeq(session) {
63
+ const nodes = session.surface?.nodes;
64
+ return nodes !== undefined && nodes.length > 0 ? nodes[nodes.length - 1] : null;
65
+ }
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
+ };
101
+ }
102
+
103
+ /**
104
+ * 把三层游标对齐到当前的节点路径。
105
+ *
106
+ * 一层的 key 变了(换节点了)就重建这个槽,游标落在**进入这一层那一刻的末尾节点**上;
107
+ * 那一刻的末尾后面就是这个节点将要产生的全部内容,正是下一次折叠要收走的东西。
108
+ *
109
+ * 上层重建会顺带清空下层:上层一折叠,下层的一切就已经不在上下文里了,
110
+ * 留着它的游标只会指向一个不存在的边界。
111
+ *
112
+ * @param session - 当前会话。
113
+ * @param cursors - `store.readSurfaces()` 读出来的游标对象,**就地修改**。
114
+ * @param active - `activePath(plan)` 的结果;`null` 表示整棵树已结束。
115
+ * @returns 是否有改动(调用方据此决定要不要写盘)。
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;
139
+ }
140
+ cursors[level] = { key, cursor: tail };
141
+ changed = true;
142
+ }
143
+
144
+ return changed;
145
+ }
146
+
147
+ /**
148
+ * 造一条汇总消息:它取代的是一段执行过程,携带的是那段过程提交的结果。
149
+ *
150
+ * 形态是 `notice` + `summary`:界面把它渲染成一行可读的说明,展开才是正文。
151
+ * 刻意**不用** `snapshot`——那个形态是"运行时上下文快照"的专属标签,
152
+ * 借用它会让任务汇总在界面上冒充系统提示词/运行环境快照。
153
+ */
154
+ export function summaryMessage(id, text, summary) {
155
+ return {
156
+ id,
157
+ role: "user",
158
+ content: [{ type: "text", text }],
159
+ source: {
160
+ kind: "plugin",
161
+ plugin: PLUGIN,
162
+ form: "notice",
163
+ summary,
164
+ },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * 这一层从 `fromKey` 起、连续已结束的节点。
170
+ *
171
+ * 一个 key 和下一个 key 之间可能挤着好几个节点——模型一轮里连着完成两个子任务,
172
+ * 或者中间那个被丢弃了。它们同属一段执行过程,收成一条汇总就够。
173
+ *
174
+ * 只在**同一父节点下按树里的顺序**往后数,遇到第一个还没结束的节点就停:
175
+ * 那正是这一层现在的活跃节点。
176
+ *
177
+ * @param plan - 计划树。
178
+ * @param level - `goal` / `task` / `step`。
179
+ * @param fromKey - 这一层上一次的活跃节点 id。
180
+ * @returns 已结束的节点,按树里的顺序;没有就返回空数组。
181
+ */
182
+ export function closedFrom(plan, level, fromKey) {
183
+ const goal = plan?.goal;
184
+ if (goal === undefined || goal === null) return [];
185
+
186
+ if (level === "goal") {
187
+ return goal.id === fromKey && isClosed(goal) ? [goal] : [];
188
+ }
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]);
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * 汇总消息的正文:这些节点各自提交了什么。
205
+ *
206
+ * 正处在检查点上时,把检查点提示一并附上——那一刻模型正需要知道"这一层只剩
207
+ * 拆子节点和提交结果两条路",而这条消息就是它接下来唯一会读到的说明。
208
+ */
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}) 已丢弃。`,
218
+ );
219
+ const result = node.result?.text;
220
+ if (typeof result === "string" && result !== "") parts.push(`结果:\n${result}`);
221
+ }
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
+ }
233
+
234
+ /**
235
+ * 把刚结束那一层折起来。
236
+ *
237
+ * 判定只有一条:某一层的 key 和 `activePath` 对不上,就说明这一层换节点了,
238
+ * 刚过去的那个节点(连同它后面连续结束的兄弟)已经做完。
239
+ *
240
+ * 从外往里走,**碰到第一层换了就停**:外层的折叠范围一直覆盖到末尾,
241
+ * 内层的一切本来就在里面,再折一次只会把外层刚落下的汇总又卷走。
242
+ * 剩下的交给 `syncCursors` 重建游标。
243
+ *
244
+ * 折叠失败或无从折叠时降级为追加,见 `foldRange` 的说明。
245
+ *
246
+ * 折叠必须用**换节点之前**的游标,所以这一步要跑在 `syncCursors` 前面。
247
+ *
248
+ * @param ctx - 挂载本行的 Cordis 上下文,只用来记日志。
249
+ * @param session - 当前会话。
250
+ * @param cursors - 游标状态,**就地修改**。
251
+ * @param plan - 计划树。
252
+ * @param active - `activePath(plan)`;`null` 表示整棵树已结束。
253
+ * @returns 是否有改动。
254
+ */
255
+ export function foldFinishedLevels(ctx, session, cursors, plan, active) {
256
+ let changed = false;
257
+
258
+ for (const level of LEVELS) {
259
+ const key = active?.[level]?.id ?? null;
260
+ const slot = cursors[level] ?? null;
261
+
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 };
295
+ changed = true;
296
+ } catch (error) {
297
+ // 注入失败不能拖垮会话,也不能把游标推走——推走了这段过程就再也折不掉。
298
+ ctx.logger?.warn?.(
299
+ `dsh-tree-task-flow: 折叠 ${level} 失败,保留原区间:${String(error?.message ?? error)}`,
300
+ );
301
+ }
302
+ }
303
+
304
+ // 外层换了,内层的边界已经并进这一层,剩下的交给 syncCursors。
305
+ break;
306
+ }
307
+
308
+ return changed;
309
+ }
310
+
311
+ /**
312
+ * 注册折叠。
313
+ *
314
+ * @param ctx - 挂载本行的 Cordis 上下文。
315
+ * @param options - `{ store }`。
316
+ */
317
+ export function registerRegion(ctx, { store }) {
318
+ ctx.on("agent/pre-step", async (payload, next) => {
319
+ // 先让其余监听器跑完,此时本步的一切决定都已落定。
320
+ const decision = await next();
321
+ if (decision?.kind === "reject" || payload?.signal?.aborted) return decision;
322
+
323
+ const session = payload?.agent?.session;
324
+ if (session === undefined) return decision;
325
+
326
+ const sessionId = session.id;
327
+ const plan = store.readPlan(sessionId);
328
+ if (plan === null) return decision;
329
+
330
+ const cursors = store.readSurfaces(sessionId);
331
+ const active = activePath(plan);
332
+
333
+ // 顺序不能颠倒:折叠要读**换节点之前**的游标,而 syncCursors 一跑就把游标
334
+ // 推到当前末尾,那时再也没有折叠的起点了。
335
+ let changed = foldFinishedLevels(ctx, session, cursors, plan, active);
336
+ if (syncCursors(session, cursors, active)) changed = true;
337
+
338
+ if (changed) {
339
+ cursors.sessionId = sessionId;
340
+ cursors.updatedAt = Date.now();
341
+ store.writeSurfaces(sessionId, cursors);
342
+ }
343
+ return decision;
344
+ });
345
+ }
package/lib/store.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 插件自有状态的读写。
3
+ *
4
+ * 两类状态,都**以 `sessionId` 为键**:
5
+ *
6
+ * - `plans/<sessionId>.json` 计划树
7
+ * - `surfaces/<sessionId>.json` 三层的折叠游标
8
+ *
9
+ * 计划属于建立它的那个会话。放到会话无关的位置,任何一个会话的计划都会被
10
+ * 所有会话的 `agent/pre-step` 读到——那等于把不相干的节点指派塞进别人的上下文,
11
+ * 还会让别的会话顺手改掉这棵树。
12
+ *
13
+ * 上下文里唯一需要跨轮记住的东西,是"每一层总结到哪儿了",
14
+ * 那就是游标。
15
+ *
16
+ * **绝不直接写会话日志**。会话日志是后端内部格式(多帧 zstd、世代命名、lease),
17
+ * 绕过去只会写坏它;会话里的东西一律走 `session.append()`。
18
+ *
19
+ * 写入一律走「临时文件 + rename」:同一文件系统内 rename 是原子的,
20
+ * 所以进程崩在写入中途也不会留下半截 JSON。
21
+ *
22
+ * @module dsh-tree-task-flow/store
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+
29
+ /**
30
+ * 没有显式配置时的状态根目录。
31
+ * @returns `$DSH_HOME/dsh-task-tree`,没有 DSH_HOME 时退回 `~/.dsh/dsh-task-tree`。
32
+ * 目录名沿用插件改名前的标识,为的是不让既有的计划文件失联。
33
+ */
34
+ export function defaultRoot() {
35
+ return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "dsh-task-tree");
36
+ }
37
+
38
+ /**
39
+ * 读一个 JSON 文件,任何异常都退回 fallback。
40
+ * 坏文件不能拖垮会话——当作空状态继续,下一次写入会覆盖它。
41
+ */
42
+ function readJson(file, fallback) {
43
+ try {
44
+ if (!existsSync(file)) return fallback;
45
+ return JSON.parse(readFileSync(file, "utf8"));
46
+ } catch {
47
+ return fallback;
48
+ }
49
+ }
50
+
51
+ /** 原子写:临时文件 + rename。 */
52
+ function writeJson(file, value) {
53
+ mkdirSync(dirname(file), { recursive: true });
54
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
55
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
56
+ renameSync(tmp, file);
57
+ }
58
+
59
+ /**
60
+ * 建一个 store。一个插件实例一个,所有路径都在 root 下。
61
+ *
62
+ * @param root - 状态根目录。
63
+ * @returns 读写两类状态的接口:计划树 / 折叠游标。
64
+ */
65
+ export function createStore(root) {
66
+ const file = (...parts) => join(root, ...parts);
67
+
68
+ return {
69
+ /** 状态根目录。 */
70
+ root,
71
+
72
+ // ---- 计划树:**按会话**,每个会话一棵 ----
73
+ // 按会话隔离是硬要求,理由见文件头注释。
74
+
75
+ /**
76
+ * @param sessionId - 会话 id。这个会话还没有计划时返回 null。
77
+ * @returns 计划树对象。
78
+ */
79
+ readPlan(sessionId) {
80
+ if (!sessionId) return null;
81
+ return readJson(file("plans", `${sessionId}.json`), null);
82
+ },
83
+
84
+ /**
85
+ * @param sessionId - 会话 id。
86
+ * @param plan - 完整计划树。
87
+ */
88
+ writePlan(sessionId, plan) {
89
+ if (!sessionId) return;
90
+ writeJson(file("plans", `${sessionId}.json`), plan);
91
+ },
92
+
93
+ // ---- 折叠游标:按会话分文件;记三层各自"总结到哪儿了" ----
94
+
95
+ /**
96
+ * 层级固定为 目标 → 任务 → 子任务,每层一个槽。槽的形状是 `{ key, cursor }`:
97
+ *
98
+ * - `key` 该层当前节点的 id。与上一轮不同就说明这一层换了节点。
99
+ * - `cursor` 该层折叠的起点:折叠时从它**之后**替换到末尾,它自己留在 surface 上。
100
+ * 初值是**进入这一层那一刻 surface 的末尾节点**,之后每折叠一次就
101
+ * 更新为刚落下的那条汇总消息。所以它指向的**不一定是本插件放下的
102
+ * 汇总消息**——第一次折叠之前,它可能指向别的生产者留下的节点。
103
+ * 也正因为它停在边界上,同级上一次的汇总消息才不会被这一次折叠卷走。
104
+ * 为 null 表示进入这一层时 surface 还是空的,没有可保留的边界。
105
+ *
106
+ * 槽本身为 null 表示这一层还没开始,或整棵树已经结束。
107
+ *
108
+ * @param sessionId - 会话 id。
109
+ * @returns `{ sessionId, goal, task, step, updatedAt }`。
110
+ */
111
+ readSurfaces(sessionId) {
112
+ return readJson(file("surfaces", `${sessionId}.json`), {
113
+ sessionId,
114
+ goal: null,
115
+ task: null,
116
+ step: null,
117
+ updatedAt: null,
118
+ });
119
+ },
120
+
121
+ /** @param sessionId - 会话 id。 @param value - 完整游标状态。 */
122
+ writeSurfaces(sessionId, value) {
123
+ writeJson(file("surfaces", `${sessionId}.json`), value);
124
+ },
125
+ };
126
+ }