dsh-tree-task-flow 0.3.12 → 0.3.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 +153 -30
- package/client.js +212 -10
- package/config.example.yml +17 -3
- package/cordis.patch.yml +11 -5
- package/lib/auto-continue.js +32 -1
- package/lib/commands.js +46 -5
- package/lib/compaction.js +2 -2
- package/lib/http.js +52 -6
- package/lib/index.js +45 -6
- package/lib/interrupt.js +559 -0
- package/lib/pause.js +40 -15
- package/lib/plan.js +144 -30
- package/lib/region.js +65 -15
- package/lib/settings.js +53 -0
- package/lib/store.js +89 -0
- package/lib/tools.js +102 -17
- package/package.json +1 -1
package/lib/plan.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* **谁是"当前节点"是推导出来的,不是存的**(`activePath`)——
|
|
8
8
|
* 少一个需要同步的状态,就少一类 bug。
|
|
9
9
|
*
|
|
10
|
-
* 一个节点完成时必须提交 `result
|
|
11
|
-
*
|
|
10
|
+
* 一个节点完成时必须提交 `result`(这个节点产出了什么)。它提交在这次
|
|
11
|
+
* `tree_task_done` 调用的参数里,而那次调用是折叠边界、不会被收走,所以
|
|
12
|
+
* 结果**留得住**;插件收走的是它前面那段执行过程。树渲染里也会把结果原样
|
|
13
|
+
* 列出来(**不截断**),让 `tree_task_status` 随时能把交付内容重新摆到眼前。
|
|
12
14
|
*
|
|
13
15
|
* **子节点全部结束时,父节点不自动完成。** 那一刻转入**检查点**:
|
|
14
16
|
* 模型要么继续给父节点拆新的子节点,要么提交父节点的结果并完成它。
|
|
@@ -353,6 +355,54 @@ export function dropNode(plan, id, now = Date.now()) {
|
|
|
353
355
|
};
|
|
354
356
|
}
|
|
355
357
|
|
|
358
|
+
/**
|
|
359
|
+
* 删掉一个节点。**删除和丢弃是两件事,别混着用:**
|
|
360
|
+
*
|
|
361
|
+
* - **丢弃**回答"这件事还要不要做":节点留在树里划掉,将来还查得到"曾经有这么一件事,
|
|
362
|
+
* 后来决定不做了"。它不改写历史。
|
|
363
|
+
* - **删除**是**清理**:内容本身就是错的(建重了、写歪了、结果记在了错的地方),
|
|
364
|
+
* 留着只会误导后面的人。所以它把这个节点连同它的全部子孙从树里凭空摘掉,
|
|
365
|
+
* **不看状态**——已完成的照样删得掉,那正是"这条记录不该留在这儿"的情形。
|
|
366
|
+
*
|
|
367
|
+
* 摘完树可能是空的(最后一个目标被删掉):那时 `plan.goals` 是**空数组**,而不是
|
|
368
|
+
* 没有计划——计划文件照旧在盘上,渲染会给出"还没有计划"的样子。
|
|
369
|
+
*
|
|
370
|
+
* 活动路径与检查点在删完**重新算**,调用方不要拿删除前的那一份去用。
|
|
371
|
+
*
|
|
372
|
+
* @param plan - 计划树。
|
|
373
|
+
* @param id - 节点 id。
|
|
374
|
+
* @param now - 时间戳。
|
|
375
|
+
* @returns `{ ok, reason?, removed?, active?, checkpoint? }`。
|
|
376
|
+
*/
|
|
377
|
+
export function deleteNode(plan, id, now = Date.now()) {
|
|
378
|
+
const found = findNode(plan, id);
|
|
379
|
+
if (!found) return { ok: false, reason: `找不到节点 ${id}` };
|
|
380
|
+
|
|
381
|
+
// 先把要摘掉的那几个记下来——摘掉之后就找不着了。
|
|
382
|
+
const removed = [`${levelLabel(found.level)}「${found.node.title}」`];
|
|
383
|
+
if (found.level === "goal") {
|
|
384
|
+
for (const task of found.node.tasks ?? []) {
|
|
385
|
+
removed.push(`任务「${task.title}」`);
|
|
386
|
+
for (const step of task.steps ?? []) removed.push(`子任务「${step.title}」`);
|
|
387
|
+
}
|
|
388
|
+
plan.goals = goalsOf(plan).filter((goal) => goal.id !== id);
|
|
389
|
+
} else if (found.level === "task") {
|
|
390
|
+
for (const step of found.node.steps ?? []) removed.push(`子任务「${step.title}」`);
|
|
391
|
+
found.goal.tasks = (found.goal.tasks ?? []).filter((task) => task.id !== id);
|
|
392
|
+
} else {
|
|
393
|
+
found.task.steps = (found.task.steps ?? []).filter((step) => step.id !== id);
|
|
394
|
+
}
|
|
395
|
+
plan.updatedAt = now;
|
|
396
|
+
|
|
397
|
+
const checkpoint = checkpointOf(plan);
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
removed,
|
|
401
|
+
active: activePath(plan),
|
|
402
|
+
checkpoint: checkpoint === null ? null : { id: checkpoint.node.id, level: checkpoint.level },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
356
406
|
/**
|
|
357
407
|
* 改一个节点的标题或说明。
|
|
358
408
|
*
|
|
@@ -460,54 +510,118 @@ function checkpointNote(checkpoint) {
|
|
|
460
510
|
);
|
|
461
511
|
}
|
|
462
512
|
|
|
463
|
-
/**
|
|
513
|
+
/**
|
|
514
|
+
* 节点状态的短标记。
|
|
515
|
+
*
|
|
516
|
+
* 写成 `[ ]` / `[x]` 是**故意的**:整棵树用 Markdown 的嵌套任务清单渲染,
|
|
517
|
+
* 这两个标记在界面上会render成可以勾选的方框。`[-]` 不是标准任务清单标记,
|
|
518
|
+
* 会原样显示成 `[-]`——丢弃的节点本来就该看着"不是个正常的框"。
|
|
519
|
+
*/
|
|
464
520
|
function mark(node) {
|
|
465
521
|
if (node.status === STATUS.DONE) return "[x]";
|
|
466
522
|
if (node.status === STATUS.DROPPED) return "[-]";
|
|
467
523
|
return "[ ]";
|
|
468
524
|
}
|
|
469
525
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
526
|
+
/** 每一层的缩进宽度:两个空格,Markdown 嵌套列表的常规写法。 */
|
|
527
|
+
const INDENT = " ";
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 一个节点的「说明 / 结果」字段,作为该列表项的下一层条目挂进去。
|
|
531
|
+
*
|
|
532
|
+
* **全文输出,一个字都不截。** `result` 是这个节点交付的全部文字,截掉半句
|
|
533
|
+
* 就等于没交付。多行文本逐行输出;空行也要带上缩进,否则 Markdown 会认为
|
|
534
|
+
* 列表项到此为止,后面的内容会被甩出列表。
|
|
535
|
+
*
|
|
536
|
+
* @param lines - 输出行数组,就地追加。
|
|
537
|
+
* @param ownerIndent - 所属节点那一行的缩进层数。
|
|
538
|
+
* @param label - 「说明」或「结果」。
|
|
539
|
+
* @param text - 正文;空值不输出。
|
|
540
|
+
*/
|
|
541
|
+
function pushField(lines, ownerIndent, label, text) {
|
|
542
|
+
const rows = String(text ?? "")
|
|
543
|
+
.replace(/\r\n?/gu, "\n")
|
|
544
|
+
.split("\n");
|
|
545
|
+
while (rows.length > 0 && rows[rows.length - 1].trim() === "") rows.pop();
|
|
546
|
+
if (rows.length === 0) return;
|
|
547
|
+
|
|
548
|
+
const pad = INDENT.repeat(ownerIndent + 1);
|
|
549
|
+
lines.push(`${pad}- **${label}**:${rows[0]}`);
|
|
550
|
+
for (const row of rows.slice(1)) {
|
|
551
|
+
lines.push(row === "" ? pad : `${pad}${INDENT}${row}`);
|
|
552
|
+
}
|
|
474
553
|
}
|
|
475
554
|
|
|
476
555
|
/**
|
|
477
|
-
*
|
|
556
|
+
* 渲染一个目标。
|
|
478
557
|
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
558
|
+
* 输出是**规范的 Markdown 嵌套任务清单**(`- [ ]` 一层两个空格),复制出去
|
|
559
|
+
* 就能正常预览——之前的写法靠纯空格缩进,四空格起就被 Markdown 当成代码块。
|
|
560
|
+
*
|
|
561
|
+
* @param goal - 目标节点。
|
|
562
|
+
* @param plan - 整棵计划树,用来标出"当前"落在哪。
|
|
563
|
+
* @param expand - `false` 时只渲染目标自己这一级(不含它下面的任务与子任务)。
|
|
564
|
+
* @returns 多行 Markdown 文本。
|
|
481
565
|
*/
|
|
482
|
-
export function
|
|
483
|
-
const goals = goalsOf(plan);
|
|
484
|
-
if (goals.length === 0) return "(还没有计划。先用 tree_task_create 建立目标与任务。)";
|
|
566
|
+
export function renderGoal(goal, plan, expand = true) {
|
|
485
567
|
const active = activePath(plan);
|
|
486
568
|
const checkpoint = checkpointOf(plan);
|
|
487
569
|
const lines = [];
|
|
488
570
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
571
|
+
const goalHere = active?.goal?.id === goal.id;
|
|
572
|
+
lines.push(`- ${mark(goal)} **目标** \`${goal.id}\` ${goal.title}${goalHere ? " ← 当前" : ""}`);
|
|
573
|
+
pushField(lines, 0, "说明", goal.detail);
|
|
574
|
+
pushField(lines, 0, "结果", goal.result?.text);
|
|
575
|
+
if (expand !== true) return lines.join("\n");
|
|
576
|
+
|
|
577
|
+
for (const task of goal.tasks ?? []) {
|
|
578
|
+
const taskHere = active?.task?.id === task.id || checkpoint?.node?.id === task.id;
|
|
579
|
+
lines.push(
|
|
580
|
+
`${INDENT}- ${mark(task)} **任务** \`${task.id}\` ${task.title}${taskHere ? " ← 当前" : ""}`,
|
|
581
|
+
);
|
|
582
|
+
pushField(lines, 1, "结果", task.result?.text);
|
|
583
|
+
|
|
584
|
+
const steps = task.steps ?? [];
|
|
585
|
+
if (steps.length === 0 && isOpen(task)) {
|
|
586
|
+
lines.push(`${INDENT.repeat(2)}- 还没有子节点,用 tree_task_plan 补上`);
|
|
587
|
+
}
|
|
588
|
+
for (const step of steps) {
|
|
589
|
+
const stepHere = active?.step?.id === step.id;
|
|
590
|
+
lines.push(
|
|
591
|
+
`${INDENT.repeat(2)}- ${mark(step)} **子任务** \`${step.id}\` ${step.title}${stepHere ? " ← 当前" : ""}`,
|
|
592
|
+
);
|
|
593
|
+
pushField(lines, 2, "结果", step.result?.text);
|
|
506
594
|
}
|
|
507
595
|
}
|
|
508
596
|
return lines.join("\n");
|
|
509
597
|
}
|
|
510
598
|
|
|
599
|
+
/**
|
|
600
|
+
* 渲染计划树。
|
|
601
|
+
*
|
|
602
|
+
* 默认把**已经结束的目标**收成目标那一级:一个会话跑久了会有好几个做完的目标,
|
|
603
|
+
* 每个都摊开会把树变成一大坨没人看的文字。要某个目标的完整树就指名它。
|
|
604
|
+
*
|
|
605
|
+
* @param plan - 计划树;null 表示还没有计划。
|
|
606
|
+
* @param only - 只渲染这个 id 所在的目标,并展开到底;不传就是全部目标。
|
|
607
|
+
* @returns 多行 Markdown 文本。
|
|
608
|
+
*/
|
|
609
|
+
export function renderTree(plan, only = null) {
|
|
610
|
+
const goals = goalsOf(plan);
|
|
611
|
+
if (goals.length === 0) return "(还没有计划。用 tree_task_create 建立目标与任务。)";
|
|
612
|
+
|
|
613
|
+
const wanted = typeof only === "string" && only.trim() !== "" ? only.trim() : null;
|
|
614
|
+
if (wanted !== null) {
|
|
615
|
+
const found = findNode(plan, wanted);
|
|
616
|
+
if (found === null) {
|
|
617
|
+
return `找不到节点 ${wanted}。不带 id 调用可以看到本会话的全部目标。`;
|
|
618
|
+
}
|
|
619
|
+
return renderGoal(found.goal, plan, true);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return goals.map((goal) => renderGoal(goal, plan, isOpen(goal))).join("\n");
|
|
623
|
+
}
|
|
624
|
+
|
|
511
625
|
/**
|
|
512
626
|
* 注入消息里的收尾提示。它接在完成通告后面,所以自带前置标点(`lead`)。
|
|
513
627
|
*
|
package/lib/region.js
CHANGED
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 规则只有一条,**只看表层,与计划树无关**:
|
|
5
5
|
*
|
|
6
|
-
* 从「上一次 `tree_task_done`
|
|
6
|
+
* 从「上一次 `tree_task_done` 之后的第一条 `tree_task_create` 调用」之后,
|
|
7
7
|
* 到本次 `tree_task_done` 之前,中间那一段过程收走。
|
|
8
8
|
*
|
|
9
|
-
* 更早没有 `tree_task_done`
|
|
9
|
+
* 更早没有 `tree_task_done` 时,起点取表层第一条 `tree_task_create`;区间里一次
|
|
10
10
|
* `create` 都没有时,回退到上一次 `tree_task_done` 之后(更早没有 `done` 则是
|
|
11
11
|
* 表层开头)。起点那次调用、以及本次 `done` 的调用与返回,都留在表层上——
|
|
12
12
|
* 那是模型看"计划是什么、下一步干什么"的地方。
|
|
13
13
|
*
|
|
14
|
-
* 边界只有 `create` 与 `done`
|
|
15
|
-
*
|
|
14
|
+
* 边界只有 `create` 与 `done` 两个:写树的 `tree_task_plan` **不是边界**,
|
|
15
|
+
* 它跟普通过程一样落在区间里就被收走。
|
|
16
|
+
*
|
|
17
|
+
* **为什么不能把 `create` 并进 `plan`**:起点取的是"上一次 done 之后最早的
|
|
18
|
+
* 那一次开工调用",而 `plan` 每补一次步骤就调一次;并成一个工具之后,
|
|
19
|
+
* 每一次补步骤都会把起点往后推,`done` 与它之间那段过程就永远收不走。
|
|
20
|
+
* 靠"实参里有没有 `parentId`"去区分,也只是把认错起点的机会交给模型。
|
|
16
21
|
*
|
|
17
22
|
* 判定表见 `docs/06-折叠范围规则.md`。
|
|
18
23
|
*
|
|
@@ -25,7 +30,7 @@
|
|
|
25
30
|
*
|
|
26
31
|
* [节点 0] 系统提示词
|
|
27
32
|
* [用户消息]
|
|
28
|
-
* [tree_task_create 调用] [返回:计划已建立 +
|
|
33
|
+
* [tree_task_create 调用] [返回:计划已建立 + 这个目标的树]
|
|
29
34
|
* [tree_task消息:隐藏了 12 条过程上下文。读取:… 写入:…]
|
|
30
35
|
* [tree_task_done 调用] [返回:接下来需要进行步骤:「二」(s-4)。]
|
|
31
36
|
* [当前这一段执行过程] ← 下一次折叠的对象
|
|
@@ -62,17 +67,52 @@ export const PLUGIN = "dsh-tree-task-flow";
|
|
|
62
67
|
*/
|
|
63
68
|
export const PLUGIN_SOURCE_KIND = `plugin:${PLUGIN}`;
|
|
64
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 界面上那一行摘要的统一前缀。
|
|
72
|
+
*
|
|
73
|
+
* 折叠、压缩、恢复续行、计划续行、中断拉起、中断重试——本插件往会话里放的通知不止一种,
|
|
74
|
+
* 界面上都是一行摘要。**前缀只在这里加一次**:调用点写自己的裸标签
|
|
75
|
+
* (「恢复续行」「中断拉起」),谁都别再手写它,否则迟早有一条漏掉或者写成两遍。
|
|
76
|
+
*/
|
|
77
|
+
export const SUMMARY_PREFIX = "树形任务流 · ";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 给一个裸标签配上统一前缀,得到界面上真正显示的那一行。
|
|
81
|
+
*
|
|
82
|
+
* @param label - 裸标签,例如「恢复续行」。
|
|
83
|
+
* @returns 带前缀的摘要文本。
|
|
84
|
+
*/
|
|
85
|
+
export function noticeSummary(label) {
|
|
86
|
+
return `${SUMMARY_PREFIX}${label}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 这条 source 的摘要是不是某个标签。
|
|
91
|
+
*
|
|
92
|
+
* **带不带前缀都认**:升级之前落在会话里的那几条摘要没有前缀,
|
|
93
|
+
* 而按摘要做匹配的地方(收走失败尝试时找那条拉起消息)得能认出它们。
|
|
94
|
+
*
|
|
95
|
+
* @param source - 消息的 source。
|
|
96
|
+
* @param label - 裸标签。
|
|
97
|
+
* @returns 命中为 true。
|
|
98
|
+
*/
|
|
99
|
+
export function hasSummary(source, label) {
|
|
100
|
+
if (typeof source?.summary !== "string") return false;
|
|
101
|
+
return source.summary === label || source.summary === noticeSummary(label);
|
|
102
|
+
}
|
|
103
|
+
|
|
65
104
|
/**
|
|
66
105
|
* 本插件一条 notice 消息的 source。
|
|
67
106
|
*
|
|
68
107
|
* 字段与老写法保持一致:`form: "notice"` 让界面渲染成可展开的一行说明,
|
|
69
|
-
* `summary`
|
|
108
|
+
* `summary` 是那一行的文字。**摘要一律走 {@link noticeSummary} 加前缀**——
|
|
109
|
+
* 界面上那一行必须自己说清是哪个插件发的,不能只写「恢复续行」。
|
|
70
110
|
*
|
|
71
|
-
* @param
|
|
111
|
+
* @param label - 界面上显示的那一行(不含前缀)。
|
|
72
112
|
* @returns 消息 source。
|
|
73
113
|
*/
|
|
74
|
-
export function pluginSource(
|
|
75
|
-
return { kind: PLUGIN_SOURCE_KIND, form: "notice", summary };
|
|
114
|
+
export function pluginSource(label) {
|
|
115
|
+
return { kind: PLUGIN_SOURCE_KIND, form: "notice", summary: noticeSummary(label) };
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
/**
|
|
@@ -96,7 +136,7 @@ export function isPluginSource(source) {
|
|
|
96
136
|
const LEVELS = ["goal", "task", "step"];
|
|
97
137
|
|
|
98
138
|
/**
|
|
99
|
-
*
|
|
139
|
+
* 边界调用:`tree_task_create` 与 `tree_task_done`。它们的调用与返回不进替换区间,
|
|
100
140
|
* 是折叠的起止点。**`tree_task_plan` 不在其中**——它跟别的过程一样,落在区间里就被收走。
|
|
101
141
|
*/
|
|
102
142
|
const BOUNDARY_TOOLS = new Set(["tree_task_create", "tree_task_done"]);
|
|
@@ -228,7 +268,7 @@ function renderSummary(count, files) {
|
|
|
228
268
|
}
|
|
229
269
|
|
|
230
270
|
/**
|
|
231
|
-
*
|
|
271
|
+
* 汇总消息的一行摘要(**不含统一前缀**——前缀由 `pluginSource` 加)。
|
|
232
272
|
*
|
|
233
273
|
* @param count - 被这次替换收走的节点条数。
|
|
234
274
|
* @param files - `touchedFiles()` 的结果。
|
|
@@ -236,11 +276,22 @@ function renderSummary(count, files) {
|
|
|
236
276
|
*/
|
|
237
277
|
function summaryLine(count, files) {
|
|
238
278
|
const touched = files.read.length + files.write.length;
|
|
239
|
-
const base =
|
|
279
|
+
const base = `隐藏了${count}条过程上下文`;
|
|
240
280
|
return touched === 0 ? base : `${base} · 涉及 ${touched} 个文件`;
|
|
241
281
|
}
|
|
242
282
|
|
|
243
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* 这条 assistant 消息里有没有**新建目标**的那次 `tree_task_create`。
|
|
285
|
+
*
|
|
286
|
+
* 折叠起点只认这一种调用。**不能拿 `tree_task_plan` 顶替**:`plan` 在任务中途
|
|
287
|
+
* 反复调用(每补一次步骤就调一次),而起点取的是"上一次 done 之后最早的那次",
|
|
288
|
+
* 于是每一次补步骤都会把起点往后推,`done` 与它之间那段过程永远收不走。
|
|
289
|
+
* 靠实参里有没有 `parentId` 去区分也只是把"认错起点"的机会交给了模型——
|
|
290
|
+
* 专门一个工具、按名字认,才是可靠的。
|
|
291
|
+
*
|
|
292
|
+
* @param event - 表层上的事件。
|
|
293
|
+
* @returns 是否新建了目标。
|
|
294
|
+
*/
|
|
244
295
|
function isCreateCall(event) {
|
|
245
296
|
return toolNamesOf(event).includes("tree_task_create");
|
|
246
297
|
}
|
|
@@ -250,8 +301,7 @@ function isCreateCall(event) {
|
|
|
250
301
|
*
|
|
251
302
|
* 从末尾往前找最近的一次边界调用,**它必须是 `tree_task_done`**(它就是这次完成的
|
|
252
303
|
* 那一下);再往前找上一次 `tree_task_done`,起点就是**它之后最早的那次
|
|
253
|
-
* `tree_task_create`**——更早没有 `done`
|
|
254
|
-
* `tree_task_plan` 不是边界:它跟过程、工具结果一样落在区间里被收走。
|
|
304
|
+
* `tree_task_create`**——更早没有 `done` 时,起点是表层第一条 `tree_task_create`。
|
|
255
305
|
*
|
|
256
306
|
* 区间里一次 `create` 都没有时,起点退回上一次 `done` 之后(更早没有 `done`
|
|
257
307
|
* 则是表层第一个节点之后)——那时区间多半会撞上真人消息,于是整段不折,这是安全的那一侧。
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 界面上的开关:**哪些键可以改**,以及怎么把它们并进配置。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件是为了让"可改的键"只有一份定义:`lib/index.js` 开机时用它把落盘的
|
|
5
|
+
* 选择并进配置,`lib/http.js` 收到面板的请求时用它校验键名。两处各写一份,
|
|
6
|
+
* 迟早会出现"界面能点、开机不认"或者反过来的错位。
|
|
7
|
+
*
|
|
8
|
+
* 优先级:**界面改过的(落盘)> profile 里的 config > DEFAULT_CONFIG**。
|
|
9
|
+
* 界面上改完还被 profile 顶回去,人会以为开关坏了。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-tree-task-flow/settings
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 面板可以改的键。
|
|
16
|
+
*
|
|
17
|
+
* 只收布尔开关:它们都是"要不要让插件自己动起来"这类事,改了立刻能看出效果,
|
|
18
|
+
* 也不需要额外解释。`maxAutoRounds` / `interruptDelaysMs` 这类带数值的旋钮
|
|
19
|
+
* 留在 profile 里——界面上给一个数字要配输入框与校验,不值当。
|
|
20
|
+
*/
|
|
21
|
+
export const SETTING_KEYS = ["autoContinue", "interruptRecovery"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从一个对象里挑出认识的开关,且值必须是布尔。
|
|
25
|
+
*
|
|
26
|
+
* 落盘的 `settings.json` 可能被人手改坏、也可能是旧版本留下的键,
|
|
27
|
+
* 所以这里**只认名单上的键**,其余一概忽略;值不是布尔的也忽略,
|
|
28
|
+
* 免得一个 `"false"` 字符串把 `cfg.autoContinue !== true` 那种判断带偏。
|
|
29
|
+
*
|
|
30
|
+
* @param value - 待挑选的对象,通常是落盘的设置。
|
|
31
|
+
* @returns 只含合法键值的新对象;没有合法的就是 `{}`。
|
|
32
|
+
*/
|
|
33
|
+
export function pickSettings(value) {
|
|
34
|
+
const picked = {};
|
|
35
|
+
if (value === null || typeof value !== "object") return picked;
|
|
36
|
+
for (const key of SETTING_KEYS) {
|
|
37
|
+
if (typeof value[key] === "boolean") picked[key] = value[key];
|
|
38
|
+
}
|
|
39
|
+
return picked;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把当前生效的开关压成面板要的形状。
|
|
44
|
+
*
|
|
45
|
+
* @param cfg - 合并后的配置。
|
|
46
|
+
* @returns `{ autoContinue, interruptRecovery }`,缺的按 false 报。
|
|
47
|
+
*/
|
|
48
|
+
export function settingsSnapshot(cfg) {
|
|
49
|
+
return {
|
|
50
|
+
autoContinue: cfg?.autoContinue === true,
|
|
51
|
+
interruptRecovery: cfg?.interruptRecovery === true,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - `plans/<sessionId>.json` 计划树
|
|
7
7
|
* - `surfaces/<sessionId>.json` 三层的折叠游标(只给界面看,不参与折叠)
|
|
8
8
|
* - `held/<sessionId>.json` 「被按住」:暂停 / 停止,**要跨重启保留**
|
|
9
|
+
* - `revive/<sessionId>.json` 意外中断的拉起重试链,**要跨重启保留**
|
|
10
|
+
* - `settings.json` 界面上的开关,**跟会话无关**,整个状态根一份
|
|
9
11
|
*
|
|
10
12
|
* 计划属于建立它的那个会话。放到会话无关的位置,任何一个会话的计划都会被
|
|
11
13
|
* 所有会话的 `agent/pre-step` 读到——那等于把不相干的节点指派塞进别人的上下文,
|
|
@@ -85,6 +87,12 @@ export function createStore(root) {
|
|
|
85
87
|
/** 「被按住」状态的文件路径。 */
|
|
86
88
|
const heldFile = (sessionId) => file("held", `${sessionId}.json`);
|
|
87
89
|
|
|
90
|
+
/** 意外中断重试链的文件路径。 */
|
|
91
|
+
const reviveFile = (sessionId) => file("revive", `${sessionId}.json`);
|
|
92
|
+
|
|
93
|
+
/** 设置文件的路径(与会话无关,整个状态根一份)。 */
|
|
94
|
+
const settingsFile = () => file("settings.json");
|
|
95
|
+
|
|
88
96
|
/** 读「被按住」状态;读不到或文件坏了,一律按"没被按住"处理。 */
|
|
89
97
|
function readHeldState(sessionId) {
|
|
90
98
|
if (!sessionId) return { paused: false, stopped: false, at: null };
|
|
@@ -172,6 +180,43 @@ export function createStore(root) {
|
|
|
172
180
|
writeJson(file("surfaces", `${sessionId}.json`), value);
|
|
173
181
|
},
|
|
174
182
|
|
|
183
|
+
// ---- 设置:**与会话无关**,整个状态根一份 ----
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 读界面上的开关。
|
|
187
|
+
*
|
|
188
|
+
* 为什么落盘:开关是"这台机器上的偏好",重启一次就忘掉等于没有这个开关。
|
|
189
|
+
* 它在 `apply()` 里盖在 profile 的 config 之上——**人在界面上的选择优先**,
|
|
190
|
+
* 否则界面改完、profile 一改又被顶回去,人会以为开关坏了。
|
|
191
|
+
*
|
|
192
|
+
* 只认自己认识的键:文件里多出别的字段一律忽略,坏文件当空。
|
|
193
|
+
*
|
|
194
|
+
* @returns `{ autoContinue?, interruptRecovery?, at? }`;没改过就是 `{}`。
|
|
195
|
+
*/
|
|
196
|
+
readSettings() {
|
|
197
|
+
const value = readJson(settingsFile(), null);
|
|
198
|
+
if (value === null || typeof value !== "object") return {};
|
|
199
|
+
return value;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 改几个开关,其余保持磁盘上的原样(读改写,与 `patchHeld` 同一个道理:
|
|
204
|
+
* 两个开关分属两条路径,谁都不能整份覆盖)。
|
|
205
|
+
*
|
|
206
|
+
* @param patch - `{ autoContinue?, interruptRecovery? }`;没给的键不动。
|
|
207
|
+
* @returns 写入后的完整设置对象。
|
|
208
|
+
*/
|
|
209
|
+
patchSettings(patch) {
|
|
210
|
+
const current = readJson(settingsFile(), null);
|
|
211
|
+
const next = current !== null && typeof current === "object" ? { ...current } : {};
|
|
212
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
213
|
+
if (typeof value === "boolean") next[key] = value;
|
|
214
|
+
}
|
|
215
|
+
next.at = Date.now();
|
|
216
|
+
writeJson(settingsFile(), next);
|
|
217
|
+
return next;
|
|
218
|
+
},
|
|
219
|
+
|
|
175
220
|
// ---- 「被按住」:暂停 / 停止。**要跨重启保留**,所以落盘 ----
|
|
176
221
|
|
|
177
222
|
/**
|
|
@@ -216,5 +261,49 @@ export function createStore(root) {
|
|
|
216
261
|
});
|
|
217
262
|
return readHeldState(sessionId);
|
|
218
263
|
},
|
|
264
|
+
|
|
265
|
+
// ---- 意外中断的拉起重试链:**要跨重启保留** ----
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 读这个会话的重试链状态。
|
|
269
|
+
*
|
|
270
|
+
* 为什么不放内存:网络类中断的时刻表横跨 30 分钟,而这台机器上重启 dsh 是常事。
|
|
271
|
+
* 只留内存的话,重启一次这条链就断了,正好落在最需要它的场景上。
|
|
272
|
+
*
|
|
273
|
+
* @param sessionId - 会话 id。
|
|
274
|
+
* @returns `{ sessionId, turn, class, code, pending, attempts, hidden, updatedAt }`,
|
|
275
|
+
* 没有链时为 null。`class` 是 `network` / `tool` / `malformed` 之一。
|
|
276
|
+
*/
|
|
277
|
+
readRevive(sessionId) {
|
|
278
|
+
if (!sessionId) return null;
|
|
279
|
+
const value = readJson(reviveFile(sessionId), null);
|
|
280
|
+
if (value === null || typeof value !== "object") return null;
|
|
281
|
+
return value;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 写重试链状态。
|
|
286
|
+
*
|
|
287
|
+
* @param sessionId - 会话 id。
|
|
288
|
+
* @param value - 完整状态;`null` 等同于 {@link clearRevive}。
|
|
289
|
+
*/
|
|
290
|
+
writeRevive(sessionId, value) {
|
|
291
|
+
if (!sessionId) return;
|
|
292
|
+
if (value === null || typeof value !== "object") {
|
|
293
|
+
rmSync(reviveFile(sessionId), { force: true });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
writeJson(reviveFile(sessionId), { sessionId, ...value, updatedAt: Date.now() });
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 丢掉这个会话的重试链。链正常结束(这一轮跑完了)或该停手时调用。
|
|
301
|
+
*
|
|
302
|
+
* @param sessionId - 会话 id。
|
|
303
|
+
*/
|
|
304
|
+
clearRevive(sessionId) {
|
|
305
|
+
if (!sessionId) return;
|
|
306
|
+
rmSync(reviveFile(sessionId), { force: true });
|
|
307
|
+
},
|
|
219
308
|
};
|
|
220
309
|
}
|