dsh-tree-task-flow 0.3.14 → 0.3.15
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 +119 -98
- package/client.js +590 -675
- package/config.example.yml +8 -7
- package/cordis.patch.yml +4 -4
- package/lib/commands.js +57 -25
- package/lib/http.js +181 -46
- package/lib/index.js +1 -1
- package/lib/notes.js +173 -0
- package/lib/pause.js +44 -0
- package/lib/prompt.js +20 -4
- package/lib/region.js +375 -58
- package/lib/render.js +311 -0
- package/lib/requirement.js +914 -0
- package/lib/store.js +50 -7
- package/lib/tools.js +588 -144
- package/package.json +2 -2
package/lib/region.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 折叠:把一段执行过程从上下文里收起来,换成一条说明收走了什么的短消息。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 规则只有一条,**只看表层,与计划树无关**(计划树只用来认出"这次完成的是不是一条需求"):
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* - `tree_task_start`:把**上一次折叠成功之后**到它之前的那一段收走;
|
|
7
|
+
* - `tree_task_done`:把**上一次 done / start / create 之后**到它之前的那一段收走;
|
|
8
|
+
* - 完成的是**需求**时折「全部」:一路退到这条需求开工的那次 `tree_task_create`。
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 那是模型看"计划是什么、下一步干什么"的地方。
|
|
10
|
+
* 边界调用(`create` / `start` / `done`)本身永远留在表层——那是模型看"计划是什么、
|
|
11
|
+
* 下一步干什么"的地方。写树的 `tree_task_plan` **不是边界**,它跟普通过程一样落在
|
|
12
|
+
* 区间里就被收走。
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* **为什么 `start` 认「上次折叠成功」而不是「上一个边界」**:折叠会被挡(用户的话
|
|
15
|
+
* 还没写进需求),挡掉的那一段如果只按边界算,下一条边界一出现它就永远落在区间外,
|
|
16
|
+
* 再也收不走。记一个"折到哪儿了"的游标,`start` 时从那里补折,被挡的过程才有第二次机会。
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* 靠"实参里有没有 `parentId`"去区分,也只是把认错起点的机会交给模型。
|
|
18
|
+
* **为什么 `done` 认「上一个边界」而不是「上次折叠成功」**:一次 done 收的就是它前面
|
|
19
|
+
* 这一小节过程,边界本身已经把范围划清了;用游标反而会把更早的、已经由 start 收过的
|
|
20
|
+
* 内容再卷一遍。
|
|
21
21
|
*
|
|
22
22
|
* 判定表见 `docs/06-折叠范围规则.md`。
|
|
23
23
|
*
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
*
|
|
31
31
|
* [节点 0] 系统提示词
|
|
32
32
|
* [用户消息]
|
|
33
|
-
* [tree_task_create 调用] [
|
|
34
|
-
* [
|
|
35
|
-
* [
|
|
33
|
+
* [tree_task_create 调用] [返回:草稿编号]
|
|
34
|
+
* [tree_task_start 调用] [返回:需求详情]
|
|
35
|
+
* [tree_task消息:隐藏了 4 条过程上下文。读取:… 写入:…]
|
|
36
|
+
* [tree_task_done 调用] [返回:接下来需要进行任务:「二」(t-4)。]
|
|
36
37
|
* [当前这一段执行过程] ← 下一次折叠的对象
|
|
37
38
|
*
|
|
38
39
|
* 这一段过程里还夹着一次 `tree_task_plan` 的调用与返回——它不是边界,
|
|
@@ -45,6 +46,8 @@
|
|
|
45
46
|
*/
|
|
46
47
|
|
|
47
48
|
import { activePath } from "./plan.js";
|
|
49
|
+
import { notesInRange, parseNoteLines, renderNoteLines } from "./notes.js";
|
|
50
|
+
import { REQUIREMENT_STATUS, listActive, listArchived, requirementOf } from "./requirement.js";
|
|
48
51
|
|
|
49
52
|
/** 本插件放下的消息都带这个身份。 */
|
|
50
53
|
export const PLUGIN = "dsh-tree-task-flow";
|
|
@@ -139,7 +142,7 @@ const LEVELS = ["goal", "task", "step"];
|
|
|
139
142
|
* 边界调用:`tree_task_create` 与 `tree_task_done`。它们的调用与返回不进替换区间,
|
|
140
143
|
* 是折叠的起止点。**`tree_task_plan` 不在其中**——它跟别的过程一样,落在区间里就被收走。
|
|
141
144
|
*/
|
|
142
|
-
const BOUNDARY_TOOLS = new Set(["tree_task_create", "tree_task_done"]);
|
|
145
|
+
const BOUNDARY_TOOLS = new Set(["tree_task_create", "tree_task_start", "tree_task_done"]);
|
|
143
146
|
|
|
144
147
|
/** 注入消息的抬头。 */
|
|
145
148
|
const NOTICE_PREFIX = "tree_task消息:";
|
|
@@ -181,6 +184,11 @@ function isDoneCall(event) {
|
|
|
181
184
|
return toolNamesOf(event).includes("tree_task_done");
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
/** 这条 assistant 消息里有没有 `tree_task_start`。 */
|
|
188
|
+
function isStartCall(event) {
|
|
189
|
+
return toolNamesOf(event).includes("tree_task_start");
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
/** 这条消息是不是真人发的——插件注入的消息 source 是 plugin,不算。 */
|
|
185
193
|
function isHumanMessage(event) {
|
|
186
194
|
return event?.type === "user/message" && event.data?.source?.kind === "user";
|
|
@@ -191,6 +199,80 @@ function isPluginNotice(event) {
|
|
|
191
199
|
return event?.type === "user/message" && isPluginSource(event.data?.source);
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
/**
|
|
203
|
+
* `tree_task_requirement` 里算得上「把用户的话写进需求」的那几个动作。
|
|
204
|
+
*
|
|
205
|
+
* `archive` / `prerequisites` / `question` 不算:归档与设前置改的是关联,不是需求正文;
|
|
206
|
+
* `question` 是"我还没看懂",正好相反——那一段更不该被收走。
|
|
207
|
+
*/
|
|
208
|
+
const REQUIREMENT_WRITE_ACTIONS = new Set(["confirm", "update", "confirmChange"]);
|
|
209
|
+
|
|
210
|
+
/** 解析一次工具调用的实参;坏 JSON 返回 null。 */
|
|
211
|
+
function argsOf(call) {
|
|
212
|
+
if (typeof call?.arguments !== "string") return null;
|
|
213
|
+
try {
|
|
214
|
+
const value = JSON.parse(call.arguments);
|
|
215
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 这条 assistant 消息里有没有「写需求」的调用。
|
|
223
|
+
*
|
|
224
|
+
* 这是 `docs/09` §7.2 那条前提的判据:区间里最晚的一条用户消息之后,必须有过一次写需求,
|
|
225
|
+
* 那段过程才可以收。**它挡的是"忘了写",挡不住"糊弄写"**——写进去的内容有没有真吸收,
|
|
226
|
+
* 只有模型自己知道。
|
|
227
|
+
*
|
|
228
|
+
* @param event - 表层事件。
|
|
229
|
+
* @returns 有写需求调用为 true。
|
|
230
|
+
*/
|
|
231
|
+
function isRequirementWriteCall(event) {
|
|
232
|
+
const content = event?.data?.message?.content;
|
|
233
|
+
if (!Array.isArray(content)) return false;
|
|
234
|
+
return content.some((part) => {
|
|
235
|
+
if (part?.type !== "tool-call") return false;
|
|
236
|
+
if (part.name === "tree_task_create") return true;
|
|
237
|
+
if (part.name !== "tree_task_requirement") return false;
|
|
238
|
+
const args = argsOf(part);
|
|
239
|
+
// 实参解析不出来就**不当**写入:宁可这次不折,也别把没吸收的过程收走。
|
|
240
|
+
return args !== null && REQUIREMENT_WRITE_ACTIONS.has(String(args.action ?? ""));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 一次 `tree_task_done` 调用指向的节点 id。
|
|
246
|
+
*
|
|
247
|
+
* @param event - 表层事件。
|
|
248
|
+
* @returns id;不是 done 调用或没给 id 时返回 null。
|
|
249
|
+
*/
|
|
250
|
+
function doneTargetId(event) {
|
|
251
|
+
const content = event?.data?.message?.content;
|
|
252
|
+
if (!Array.isArray(content)) return null;
|
|
253
|
+
const call = content.find((part) => part?.type === "tool-call" && part.name === "tree_task_done");
|
|
254
|
+
if (call === undefined) return null;
|
|
255
|
+
const args = argsOf(call);
|
|
256
|
+
const id = typeof args?.id === "string" ? args.id.trim() : "";
|
|
257
|
+
return id === "" ? null : id;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 这次 done 是不是在完成一条**需求**(而不是任务或步骤)。
|
|
262
|
+
*
|
|
263
|
+
* 需求完成要收走整条:从它开工那一刻起,连同中间那些任务级通告。
|
|
264
|
+
*
|
|
265
|
+
* @param event - 表层事件。
|
|
266
|
+
* @param plan - 计划树(`null` 时一律当作不是需求完成)。
|
|
267
|
+
* @returns 是需求完成返回它的编号,否则 null。
|
|
268
|
+
*/
|
|
269
|
+
function requirementDoneId(event, plan) {
|
|
270
|
+
if (plan === null) return null;
|
|
271
|
+
const id = doneTargetId(event);
|
|
272
|
+
if (id === null) return null;
|
|
273
|
+
return requirementOf(plan, id) === null ? null : id;
|
|
274
|
+
}
|
|
275
|
+
|
|
194
276
|
/** 从一次工具调用的参数里取它指向的文件路径;不是文件类调用就返回 null。 */
|
|
195
277
|
function filePathOf(call) {
|
|
196
278
|
if (typeof call?.arguments !== "string") return null;
|
|
@@ -250,20 +332,87 @@ export function summaryMessage(id, text, summary) {
|
|
|
250
332
|
};
|
|
251
333
|
}
|
|
252
334
|
|
|
335
|
+
/** 一条消息里的正文文本。 */
|
|
336
|
+
function textOfMessage(event) {
|
|
337
|
+
const content = event?.data?.content;
|
|
338
|
+
if (!Array.isArray(content)) return "";
|
|
339
|
+
return content.map((part) => (typeof part?.text === "string" ? part.text : "")).join("");
|
|
340
|
+
}
|
|
341
|
+
|
|
253
342
|
/**
|
|
254
|
-
*
|
|
343
|
+
* 把所有被收走的通告里带着的笔记捡回来。
|
|
344
|
+
*
|
|
345
|
+
* **为什么光按 seq 不够**:通告是"替换"插进来的,位置在原来那一段的开头,seq 却是新的、
|
|
346
|
+
* 比被它收走的内容都大。于是需求级折叠把起止点往前推时,新区间的起点 seq 反而**大于**
|
|
347
|
+
* 早先那些笔记的 seq——只按区间筛就会把它们漏掉。所以旧通告里的笔记必须显式捡回来,
|
|
348
|
+
* 这也是 `renderNoteLines` / `parseNoteLines` 要成对存在的原因。
|
|
349
|
+
*
|
|
350
|
+
* @param session - 当前会话。
|
|
351
|
+
* @param shadowed - 这次被收走的节点 seq。
|
|
352
|
+
* @returns 笔记数组(`{ key, rev, text }`)。
|
|
353
|
+
*/
|
|
354
|
+
function carriedNotes(session, shadowed) {
|
|
355
|
+
const rows = [];
|
|
356
|
+
for (const seq of shadowed) {
|
|
357
|
+
const event = session.eventAt(seq);
|
|
358
|
+
if (!isPluginNotice(event)) continue;
|
|
359
|
+
rows.push(...parseNoteLines(textOfMessage(event)));
|
|
360
|
+
}
|
|
361
|
+
return rows;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 合并两组笔记:同名取修订号大的;修订号相同则后出现的算新。
|
|
366
|
+
*
|
|
367
|
+
* @param carried - 从旧通告里捡回来的。
|
|
368
|
+
* @param fresh - 落在本区间里的(seq 命中)。
|
|
369
|
+
* @returns 去重后的笔记数组。
|
|
370
|
+
*/
|
|
371
|
+
function mergeNotes(carried, fresh) {
|
|
372
|
+
const byKey = new Map();
|
|
373
|
+
for (const note of [...carried, ...fresh]) {
|
|
374
|
+
const previous = byKey.get(note.key);
|
|
375
|
+
if (previous === undefined || Number(note.rev ?? 0) >= Number(previous.rev ?? 0)) {
|
|
376
|
+
byKey.set(note.key, note);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return [...byKey.values()];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 汇总消息的正文:这一次收走了多少条过程上下文、那一段里读写过哪些文件、
|
|
384
|
+
* 本区间动过的笔记,以及活跃需求的指针。
|
|
255
385
|
*
|
|
256
386
|
* 文件列表是给后面用的——过程收走了,但"这活碰过哪些文件"得留下,
|
|
257
387
|
* 否则模型要从头再找一遍。
|
|
258
388
|
*
|
|
389
|
+
* 笔记有两个来源:按 seq 落在本区间里的(新写的),以及从被收走的旧通告里捡回来的
|
|
390
|
+
* (见 `carriedNotes`)。两处加起来,**每条笔记在上下文里仍然只出现一次**。
|
|
391
|
+
*
|
|
392
|
+
* 末尾那句话是**指针**:列表不常驻上下文,而折叠正是模型刚丢失上下文的那一刻,
|
|
393
|
+
* 最容易忘了"这个会话里还有别的事没做"。
|
|
394
|
+
*
|
|
259
395
|
* @param count - 被这次替换收走的节点条数。
|
|
260
396
|
* @param files - `touchedFiles()` 的结果。
|
|
397
|
+
* @param notes - 本区间里的笔记。
|
|
398
|
+
* @param pointer - 活跃需求指针那句话;空串就不输出。
|
|
399
|
+
* @param requirementId - 需求级折叠时被收走的那条需求编号;普通折叠是 null。
|
|
261
400
|
* @returns 正文文本,可能多行。
|
|
262
401
|
*/
|
|
263
|
-
function renderSummary(count, files) {
|
|
264
|
-
const
|
|
402
|
+
function renderSummary(count, files, notes = [], pointer = "", requirementId = null) {
|
|
403
|
+
const head =
|
|
404
|
+
requirementId === null
|
|
405
|
+
? `${NOTICE_PREFIX}隐藏了${count}条过程上下文。`
|
|
406
|
+
: `${NOTICE_PREFIX}隐藏了${count}条过程上下文(${requirementId} 从开工到完成)。`;
|
|
407
|
+
const lines = [head];
|
|
265
408
|
if (files.read.length > 0) lines.push(`读取:${files.read.join("、")}`);
|
|
266
409
|
if (files.write.length > 0) lines.push(`写入:${files.write.join("、")}`);
|
|
410
|
+
const noteText = renderNoteLines(notes);
|
|
411
|
+
if (noteText !== "") {
|
|
412
|
+
lines.push("笔记:");
|
|
413
|
+
lines.push(noteText);
|
|
414
|
+
}
|
|
415
|
+
if (pointer !== "") lines.push(pointer);
|
|
267
416
|
return lines.join("\n");
|
|
268
417
|
}
|
|
269
418
|
|
|
@@ -272,14 +421,33 @@ function renderSummary(count, files) {
|
|
|
272
421
|
*
|
|
273
422
|
* @param count - 被这次替换收走的节点条数。
|
|
274
423
|
* @param files - `touchedFiles()` 的结果。
|
|
424
|
+
* @param requirementId - 需求级折叠时被收走的那条需求编号。
|
|
275
425
|
* @returns 一行摘要。
|
|
276
426
|
*/
|
|
277
|
-
function summaryLine(count, files) {
|
|
427
|
+
function summaryLine(count, files, requirementId = null) {
|
|
278
428
|
const touched = files.read.length + files.write.length;
|
|
279
|
-
const base =
|
|
429
|
+
const base =
|
|
430
|
+
requirementId === null
|
|
431
|
+
? `隐藏了${count}条过程上下文`
|
|
432
|
+
: `隐藏了${count}条过程上下文(${requirementId} 整条归档)`;
|
|
280
433
|
return touched === 0 ? base : `${base} · 涉及 ${touched} 个文件`;
|
|
281
434
|
}
|
|
282
435
|
|
|
436
|
+
/**
|
|
437
|
+
* 活跃需求指针:还剩几条、其中几条做完了没归档。
|
|
438
|
+
*
|
|
439
|
+
* @param plan - 计划树。
|
|
440
|
+
* @returns 一句话;没有需求时是空串。
|
|
441
|
+
*/
|
|
442
|
+
function activePointer(plan) {
|
|
443
|
+
if (plan === null) return "";
|
|
444
|
+
const active = listActive(plan);
|
|
445
|
+
if (active.length === 0) return "";
|
|
446
|
+
const done = active.filter((row) => row.status === REQUIREMENT_STATUS.DONE).length;
|
|
447
|
+
const tail = done > 0 ? `(其中 ${done} 条已完成未归档)` : "";
|
|
448
|
+
return `活跃需求 ${active.length} 条${tail},用 tree_task_status 查看。`;
|
|
449
|
+
}
|
|
450
|
+
|
|
283
451
|
/**
|
|
284
452
|
* 这条 assistant 消息里有没有**新建目标**的那次 `tree_task_create`。
|
|
285
453
|
*
|
|
@@ -299,50 +467,97 @@ function isCreateCall(event) {
|
|
|
299
467
|
/**
|
|
300
468
|
* 找这一次要折叠的区间。
|
|
301
469
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
470
|
+
* 终点是末尾最近的一次**会触发折叠的边界调用**:`tree_task_start` 或 `tree_task_done`
|
|
471
|
+
* (`tree_task_create` 只当起点用,它自己不触发折叠)。
|
|
472
|
+
*
|
|
473
|
+
* 起点分三种:
|
|
474
|
+
*
|
|
475
|
+
* - `start`:上一次**折叠成功**的位置(`cursors.foldedAt`),那里没有(或已经被
|
|
476
|
+
* 后一次折叠收走)就退回上一个边界调用;
|
|
477
|
+
* - `done` 完成的是任务 / 步骤:上一个边界调用(`done` / `start` / `create`);
|
|
478
|
+
* - `done` 完成的是需求:一路退到这条需求开工的那次 `create`(「折全部」)。
|
|
305
479
|
*
|
|
306
|
-
*
|
|
307
|
-
* 则是表层第一个节点之后)——那时区间多半会撞上真人消息,于是整段不折,这是安全的那一侧。
|
|
480
|
+
* 边界调用本身永远留在表层,区间从它的 `tool/result` 之后开始。
|
|
308
481
|
*
|
|
309
482
|
* @param session - 当前会话。
|
|
310
|
-
* @
|
|
483
|
+
* @param plan - 计划树,只用来认出「完成的是不是一条需求」。
|
|
484
|
+
* @param cursors - `store.readSurfaces()` 的结果,读 `foldedAt`;可以不给。
|
|
485
|
+
* @returns `{ from, to, shadowed, trigger, requirementId }`;这一轮不该折时返回 null。
|
|
311
486
|
*/
|
|
312
|
-
export function foldRange(session) {
|
|
487
|
+
export function foldRange(session, plan = null, cursors = null) {
|
|
313
488
|
const nodes = session.surface?.nodes ?? [];
|
|
314
489
|
if (typeof session.eventAt !== "function" || nodes.length === 0) return null;
|
|
315
490
|
|
|
316
|
-
//
|
|
491
|
+
// 终点:末尾最近的一次 start / done。两个都没有(比如刚 create 完)就不折。
|
|
317
492
|
let endIdx = -1;
|
|
493
|
+
let trigger = null;
|
|
318
494
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
495
|
+
const event = session.eventAt(nodes[index]);
|
|
496
|
+
if (isDoneCall(event)) {
|
|
497
|
+
endIdx = index;
|
|
498
|
+
trigger = "done";
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
if (isStartCall(event)) {
|
|
502
|
+
endIdx = index;
|
|
503
|
+
trigger = "start";
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
324
506
|
}
|
|
325
507
|
if (endIdx === -1) return null;
|
|
326
508
|
|
|
327
|
-
//
|
|
328
|
-
|
|
509
|
+
// 这次 done 是不是在完成一条需求?是的话,起点要一路退到它开工那一刻,
|
|
510
|
+
// 把中间那些任务级通告也一并收掉(`docs/09` §7.1)。
|
|
511
|
+
const requirementId =
|
|
512
|
+
trigger === "done" ? requirementDoneId(session.eventAt(nodes[endIdx]), plan) : null;
|
|
513
|
+
|
|
514
|
+
// 默认起点:这次触发之前最近的一次边界调用。
|
|
515
|
+
let startIdx = -1;
|
|
329
516
|
for (let index = endIdx - 1; index >= 0; index -= 1) {
|
|
330
|
-
if (
|
|
331
|
-
|
|
517
|
+
if (isBoundaryCall(session.eventAt(nodes[index]))) {
|
|
518
|
+
startIdx = index;
|
|
332
519
|
break;
|
|
333
520
|
}
|
|
334
521
|
}
|
|
335
522
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
523
|
+
// 需求级折叠:退到「上一次需求完成」之后最早的那次 create。
|
|
524
|
+
if (requirementId !== null) {
|
|
525
|
+
let anchorIdx = -1;
|
|
526
|
+
for (let index = endIdx - 1; index >= 0; index -= 1) {
|
|
527
|
+
if (requirementDoneId(session.eventAt(nodes[index]), plan) !== null) {
|
|
528
|
+
anchorIdx = index;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
startIdx = -1;
|
|
533
|
+
for (let index = anchorIdx + 1; index < endIdx; index += 1) {
|
|
534
|
+
if (isCreateCall(session.eventAt(nodes[index]))) {
|
|
535
|
+
startIdx = index;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (startIdx === -1) startIdx = anchorIdx;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// start 折叠:从**上一次折叠成功**的地方起;一次都没成功过,就退回这次开工的那次
|
|
543
|
+
// create。这里**不保护中间那些边界调用**——它们都在"上次折叠成功"之后就都属于
|
|
544
|
+
// 这条需求的过程,留着只是白占上下文(start 的返回里会把需求详情重新给一遍)。
|
|
545
|
+
if (trigger === "start") {
|
|
546
|
+
startIdx = -1;
|
|
547
|
+
const foldedAt = Number(cursors?.foldedAt ?? 0);
|
|
548
|
+
if (foldedAt > 0) {
|
|
549
|
+
const at = nodes.indexOf(foldedAt);
|
|
550
|
+
if (at !== -1) startIdx = at;
|
|
551
|
+
}
|
|
552
|
+
if (startIdx === -1) {
|
|
553
|
+
for (let index = endIdx - 1; index >= 0; index -= 1) {
|
|
554
|
+
if (isCreateCall(session.eventAt(nodes[index]))) {
|
|
555
|
+
startIdx = index;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
342
559
|
}
|
|
343
560
|
}
|
|
344
|
-
// 区间里没有 create:退回上一次 done 之后(更早没有 done 就是表层开头)。
|
|
345
|
-
if (startIdx === -1) startIdx = prevDoneIdx;
|
|
346
561
|
|
|
347
562
|
// 区间从起点调用的 `tool/result` 之后开始;找不到起点(-1)就退回表层第一个节点之后
|
|
348
563
|
// (节点 0 是系统提示词,受 DSH 的重写保护,不能进替换区间)。
|
|
@@ -352,7 +567,13 @@ export function foldRange(session) {
|
|
|
352
567
|
let from = startIdx === -1 ? 1 : startIdx + 1;
|
|
353
568
|
while (from < endIdx) {
|
|
354
569
|
const event = session.eventAt(nodes[from]);
|
|
355
|
-
if (event?.type === "tool/result"
|
|
570
|
+
if (event?.type === "tool/result") {
|
|
571
|
+
from += 1;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
// 上一次折叠落下的通告:普通折叠要跳过它(它已经是收过的结果,再收一次会一轮轮空折下去);
|
|
575
|
+
// **需求级折叠反过来要收它**——那些任务级通告正是这条需求的历史,留着就是层层堆叠。
|
|
576
|
+
if (requirementId === null && isPluginNotice(event)) {
|
|
356
577
|
from += 1;
|
|
357
578
|
continue;
|
|
358
579
|
}
|
|
@@ -361,7 +582,81 @@ export function foldRange(session) {
|
|
|
361
582
|
const to = endIdx - 1;
|
|
362
583
|
if (from > to) return null;
|
|
363
584
|
|
|
364
|
-
|
|
585
|
+
// 区间里只剩上一次折叠留下的通告(和那几次调用的 tool/result)时,什么都不做。
|
|
586
|
+
//
|
|
587
|
+
// 需求级折叠**故意不跳过通告**(那些任务级通告是这条需求的历史,第一次折全部时要一起收
|
|
588
|
+
// 走)。可是折完之后区间里就只剩刚插进去的那条通告了,`tree_task_done` 的调用还在表层
|
|
589
|
+
// 上,终点于是每次都指向它——再折一次等于拿一条新通告换掉一条旧通告,**每个 step 都插
|
|
590
|
+
// 一条**。2026-11-07 现场:R8 完成后连着插了几十条「隐藏了1条过程上下文」。
|
|
591
|
+
let hasContent = false;
|
|
592
|
+
for (let index = from; index <= to; index += 1) {
|
|
593
|
+
const event = session.eventAt(nodes[index]);
|
|
594
|
+
if (event?.type === "tool/result" || isPluginNotice(event)) continue;
|
|
595
|
+
hasContent = true;
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
if (!hasContent) return null;
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
from,
|
|
602
|
+
to,
|
|
603
|
+
endIdx,
|
|
604
|
+
requirementId,
|
|
605
|
+
trigger,
|
|
606
|
+
shadowed: nodes.slice(from, to + 1),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 这一次折叠会不会被「用户的话还没写进需求」挡住。
|
|
612
|
+
*
|
|
613
|
+
* 判据(`docs/09` §7.2):区间里**最晚的那条用户消息之后**必须有过一次写需求。
|
|
614
|
+
* 一次写需求盖住它之前的所有用户消息,所以只比最晚那一条就够。
|
|
615
|
+
*
|
|
616
|
+
* @param session - 当前会话。
|
|
617
|
+
* @param range - `foldRange()` 的结果。
|
|
618
|
+
* @returns `{ seq }`(被挡住时)或 null。
|
|
619
|
+
*/
|
|
620
|
+
export function foldBlockReason(session, range) {
|
|
621
|
+
const nodes = session.surface.nodes;
|
|
622
|
+
|
|
623
|
+
// 从后往前找区间里最晚的一条真人消息。
|
|
624
|
+
let humanSeq = null;
|
|
625
|
+
for (let index = range.to; index >= range.from; index -= 1) {
|
|
626
|
+
if (isHumanMessage(session.eventAt(nodes[index]))) {
|
|
627
|
+
humanSeq = nodes[index];
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (humanSeq === null) return null;
|
|
632
|
+
|
|
633
|
+
for (let index = range.from; index < range.endIdx; index += 1) {
|
|
634
|
+
if (nodes[index] <= humanSeq) continue;
|
|
635
|
+
if (isRequirementWriteCall(session.eventAt(nodes[index]))) return null;
|
|
636
|
+
}
|
|
637
|
+
return { seq: humanSeq };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 给调用方看的一眼预览:这一次会不会折、被挡的话是哪一条用户消息。
|
|
642
|
+
*
|
|
643
|
+
* `tree_task_done` 用它把「这段收不走、先去写需求」直接说在返回里——那一刻模型刚完成
|
|
644
|
+
* 一个节点,最容易顺手把用户的话落进需求。
|
|
645
|
+
*
|
|
646
|
+
* @param session - 当前会话。
|
|
647
|
+
* @param plan - 计划树。
|
|
648
|
+
* @param cursors - 游标(读 `foldedAt`);可以不给。
|
|
649
|
+
* @returns `{ folds, blocked, requirementId, trigger }`。
|
|
650
|
+
*/
|
|
651
|
+
export function previewFold(session, plan, cursors = null) {
|
|
652
|
+
const range = foldRange(session, plan, cursors);
|
|
653
|
+
if (range === null) return { folds: false, blocked: null, requirementId: null, trigger: null };
|
|
654
|
+
return {
|
|
655
|
+
folds: true,
|
|
656
|
+
blocked: foldBlockReason(session, range),
|
|
657
|
+
requirementId: range.requirementId,
|
|
658
|
+
trigger: range.trigger,
|
|
659
|
+
};
|
|
365
660
|
}
|
|
366
661
|
|
|
367
662
|
/**
|
|
@@ -371,30 +666,51 @@ export function foldRange(session) {
|
|
|
371
666
|
*
|
|
372
667
|
* @param ctx - 挂载本行的 Cordis 上下文,只用来记日志。
|
|
373
668
|
* @param session - 当前会话。
|
|
669
|
+
* @param plan - 计划树,用来认出「这次完成的是不是一条需求」并拼出活跃需求指针。
|
|
670
|
+
* @param store - 状态读写(笔记要跟着通告走)。
|
|
671
|
+
* @param cursors - 游标;给了就把这次折叠成功的位置记进 `foldedAt`。
|
|
374
672
|
* @returns 是否真的替换了内容。
|
|
375
673
|
*/
|
|
376
|
-
export function foldOnce(ctx, session) {
|
|
377
|
-
const range = foldRange(session);
|
|
674
|
+
export function foldOnce(ctx, session, plan = null, store = null, cursors = null) {
|
|
675
|
+
const range = foldRange(session, plan, cursors);
|
|
378
676
|
if (range === null) return false;
|
|
379
677
|
|
|
380
|
-
//
|
|
381
|
-
if (range
|
|
678
|
+
// 用户的话还没写进需求:整段不折(`docs/09` §7.2)。不说出来,模型就永远不知道该先写需求。
|
|
679
|
+
if (foldBlockReason(session, range) !== null) return false;
|
|
382
680
|
|
|
383
681
|
const nodes = session.surface.nodes;
|
|
384
682
|
const files = touchedFiles(session, range.shadowed);
|
|
683
|
+
const sessionId = session.id;
|
|
684
|
+
const fresh =
|
|
685
|
+
store !== null && sessionId !== undefined
|
|
686
|
+
? notesInRange(store.readNotes(sessionId), nodes[range.from], nodes[range.to])
|
|
687
|
+
: [];
|
|
688
|
+
const notes = mergeNotes(carriedNotes(session, range.shadowed), fresh);
|
|
385
689
|
try {
|
|
386
690
|
session.append(
|
|
387
691
|
"user/message",
|
|
388
692
|
summaryMessage(
|
|
389
693
|
foldId(),
|
|
390
|
-
renderSummary(
|
|
391
|
-
|
|
694
|
+
renderSummary(
|
|
695
|
+
range.shadowed.length,
|
|
696
|
+
files,
|
|
697
|
+
notes,
|
|
698
|
+
activePointer(plan),
|
|
699
|
+
range.requirementId,
|
|
700
|
+
),
|
|
701
|
+
summaryLine(range.shadowed.length, files, range.requirementId),
|
|
392
702
|
),
|
|
393
703
|
{
|
|
394
704
|
surfaceOp: { op: "replace", startSeq: nodes[range.from], endSeq: nodes[range.to] },
|
|
395
705
|
sourceEventSeqs: range.shadowed,
|
|
396
706
|
},
|
|
397
707
|
);
|
|
708
|
+
// 这次折叠成功的位置 = 刚插进去的那条通告。下一次 start 就从它之后补折
|
|
709
|
+
// (被挡过的那几段因此还有机会收走)。通告落在原区间起点那个下标上。
|
|
710
|
+
if (cursors !== null && typeof cursors === "object") {
|
|
711
|
+
const notice = session.surface?.nodes?.[range.from];
|
|
712
|
+
if (notice !== undefined) cursors.foldedAt = notice;
|
|
713
|
+
}
|
|
398
714
|
return true;
|
|
399
715
|
} catch (error) {
|
|
400
716
|
// 注入失败不能拖垮会话,原区间原样留着,下一轮还有机会。
|
|
@@ -466,11 +782,12 @@ export function registerRegion(ctx, { store }) {
|
|
|
466
782
|
const plan = store.readPlan(sessionId);
|
|
467
783
|
if (plan === null) return decision;
|
|
468
784
|
|
|
469
|
-
//
|
|
470
|
-
|
|
785
|
+
// 折叠只看表层:边界调用之间那一段过程。计划树只用来认出「这次完成的是不是一条需求」,
|
|
786
|
+
// 需求完成时起点要一路退到它开工那一刻。游标里的 `foldedAt` 是 start 折叠的起点。
|
|
787
|
+
const cursors = store.readSurfaces(sessionId);
|
|
788
|
+
let changed = foldOnce(ctx, session, plan, store, cursors);
|
|
471
789
|
|
|
472
790
|
// 三层游标照旧维护,它是给界面看的,不参与上面那次判断。
|
|
473
|
-
const cursors = store.readSurfaces(sessionId);
|
|
474
791
|
if (syncCursors(session, cursors, activePath(plan))) changed = true;
|
|
475
792
|
|
|
476
793
|
if (changed) {
|