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/LICENSE +21 -0
- package/README.md +250 -0
- package/client.js +921 -0
- package/cordis.patch.yml +20 -0
- package/enable.example.yml +4 -0
- package/lib/auto-continue.js +132 -0
- package/lib/commands.js +189 -0
- package/lib/http.js +243 -0
- package/lib/index.js +110 -0
- package/lib/plan.js +430 -0
- package/lib/prompt.js +56 -0
- package/lib/region.js +345 -0
- package/lib/store.js +126 -0
- package/lib/tools.js +308 -0
- package/package.json +68 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tree-task-flow —— 三级任务树:完成时提交结果,同时把执行过程折叠掉。
|
|
3
|
+
*
|
|
4
|
+
* 与 DSH 内置任务系统的差别在于**上下文的收放方式**:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* 一个节点的上下文 =
|
|
8
|
+
* ① 节点 0:会话级稳定前缀(身份 / 工具 / 静态说明) ← 永不变化
|
|
9
|
+
* + ② 这一层已有的汇总消息 ← 同级逐条累积,不被卷走
|
|
10
|
+
* + ③ 当前节点的执行过程 ← 完成时整段折叠成一条汇总
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* ② 与 ③ 的分工是「留给后面的」与「用完就扔的」。节点完成时一次
|
|
14
|
+
* `surfaceOp: replace` 把 `[当前节点的全部执行过程]` 换成 `[一条携带提交结果的汇总消息]`,
|
|
15
|
+
* 汇总落在同级上一条汇总之后,因此下一条汇总的折叠范围够不到它。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tree-task-flow
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { registerAutoContinue } from "./auto-continue.js";
|
|
21
|
+
import { registerCommands } from "./commands.js";
|
|
22
|
+
import { registerHttp } from "./http.js";
|
|
23
|
+
import { DEFAULT_ORDER, registerPromptSection } from "./prompt.js";
|
|
24
|
+
import { registerRegion } from "./region.js";
|
|
25
|
+
import { createStore, defaultRoot } from "./store.js";
|
|
26
|
+
import { registerTools } from "./tools.js";
|
|
27
|
+
|
|
28
|
+
/** Cordis 插件名。 */
|
|
29
|
+
export const name = "dsh-tree-task-flow";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 依赖的服务。
|
|
33
|
+
*
|
|
34
|
+
* `tools` **必须写进 inject**:cordis 在访问一个没有 inject 的服务属性时会直接抛错
|
|
35
|
+
* (`cannot get property "tools" without inject`),整个插件加载失败、dsh 起不来。
|
|
36
|
+
* 反过来,也只有 inject 了才拿得到 `ctx.tools`。
|
|
37
|
+
*/
|
|
38
|
+
export const inject = ["systemPrompt", "tools"];
|
|
39
|
+
|
|
40
|
+
/** 本行没给 config 时使用的默认值。 */
|
|
41
|
+
export const DEFAULT_CONFIG = {
|
|
42
|
+
/**
|
|
43
|
+
* 总开关。
|
|
44
|
+
*
|
|
45
|
+
* ★ **默认关闭**。装上插件 ≠ 启用插件:一启用,它就会往**每个会话**的系统提示词里
|
|
46
|
+
* 加一段说明、并在节点完成时改上下文。这种事不该在用户没点头的时候发生。
|
|
47
|
+
* 要启用见 README(在 profile 的 cordis.patch.yml 里打开)。
|
|
48
|
+
*/
|
|
49
|
+
enabled: false,
|
|
50
|
+
/**
|
|
51
|
+
* 是否允许在模型停下时自动续行。
|
|
52
|
+
*
|
|
53
|
+
* ★ **默认关闭**。开着它意味着:用户按下停止、agent 回到 idle,
|
|
54
|
+
* 插件立刻又把它拉起来——会话就停不下来了。任务自动执行必须是**受控**的,
|
|
55
|
+
* 要开也得由用户显式打开。
|
|
56
|
+
*/
|
|
57
|
+
autoContinue: false,
|
|
58
|
+
/** 连续自动续行的轮次上限,达到就停。 */
|
|
59
|
+
maxAutoRounds: 5,
|
|
60
|
+
/** 静态说明段落的排序。 */
|
|
61
|
+
promptOrder: DEFAULT_ORDER,
|
|
62
|
+
/** 状态目录;null 表示 $DSH_HOME/dsh-task-tree。 */
|
|
63
|
+
rootDir: null,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 挂载插件。
|
|
68
|
+
*
|
|
69
|
+
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
70
|
+
* @param config - 本行的 config;未提供或字段缺失时按默认值兜底。
|
|
71
|
+
*/
|
|
72
|
+
export function apply(ctx, config) {
|
|
73
|
+
const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) };
|
|
74
|
+
const root = cfg.rootDir ?? defaultRoot();
|
|
75
|
+
const store = createStore(root);
|
|
76
|
+
|
|
77
|
+
// 总开关默认**关**。未启用时一个字节都不碰会话:不注册工具、不加提示词段落、
|
|
78
|
+
// 不折叠上下文、不续行——只留一条命令,让人能确认"装了但没开"以及怎么开。
|
|
79
|
+
if (cfg.enabled !== true) {
|
|
80
|
+
registerCommands(ctx, { store, cfg, autoContinue: null, enabled: false });
|
|
81
|
+
// 面板接口照挂:设置页要能显示"装了但没启用"以及启用后的影响。
|
|
82
|
+
// 只读自己的状态目录,不碰任何会话。
|
|
83
|
+
registerHttp(ctx, { store, cfg, autoContinue: null });
|
|
84
|
+
ctx.logger?.warn?.(
|
|
85
|
+
"dsh-tree-task-flow: 已装载但**未启用**(enabled=false)。要启用请在 profile 的 cordis.patch.yml 里设为 true。",
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 静态说明段。必须包在 effect 里,否则热更新后会重复注册而抛错。
|
|
91
|
+
ctx.effect(registerPromptSection(ctx, cfg.promptOrder), "dsh-tree-task-flow.section");
|
|
92
|
+
|
|
93
|
+
// 六个工具,以及检查点上的工具闸门。tools 已在 inject 里声明,这里必定就绪。
|
|
94
|
+
registerTools(ctx, { store, cfg });
|
|
95
|
+
|
|
96
|
+
// 节点完成时把它的执行过程折叠成一条携带结果的汇总消息。
|
|
97
|
+
registerRegion(ctx, { store });
|
|
98
|
+
|
|
99
|
+
// 模型停下来时的续行兜底。返回的控制接口交给 /task-tree 命令,
|
|
100
|
+
// 让人随时能一句话停下来。
|
|
101
|
+
const autoContinue = registerAutoContinue(ctx, { store, cfg });
|
|
102
|
+
|
|
103
|
+
// /task-tree 命令:查看与管理任务树,也是"停止自动执行"的开关。
|
|
104
|
+
registerCommands(ctx, { store, cfg, autoContinue, enabled: true });
|
|
105
|
+
|
|
106
|
+
// 设置页与右侧任务树面板要用的 HTTP 接口。
|
|
107
|
+
registerHttp(ctx, { store, cfg, autoContinue });
|
|
108
|
+
|
|
109
|
+
ctx.logger?.info?.(`dsh-tree-task-flow: 已启用(状态目录 ${root})`);
|
|
110
|
+
}
|
package/lib/plan.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计划树:目标 → 任务 → 子任务,固定三级。
|
|
3
|
+
*
|
|
4
|
+
* 纯逻辑模块——不碰 DSH、不碰磁盘。状态由调用方从 store 读出、改完写回。
|
|
5
|
+
*
|
|
6
|
+
* 节点状态只有三种:`pending` / `done` / `dropped`。
|
|
7
|
+
* **谁是"当前节点"是推导出来的,不是存的**(`activePath`)——
|
|
8
|
+
* 少一个需要同步的状态,就少一类 bug。
|
|
9
|
+
*
|
|
10
|
+
* 一个节点完成时必须提交 `result`(这个节点产出了什么)。插件据此把
|
|
11
|
+
* 它的执行过程从上下文里折叠掉,换成一条携带该结果的汇总消息。
|
|
12
|
+
*
|
|
13
|
+
* **子节点全部结束时,父节点不自动完成。** 那一刻转入**检查点**:
|
|
14
|
+
* 模型要么继续给父节点拆新的子节点,要么提交父节点的结果并完成它。
|
|
15
|
+
* 这样"这一层到底做完没有"始终由模型判断,不会被一条计数规则抢先定死。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tree-task-flow/plan
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 节点状态取值。 */
|
|
21
|
+
export const STATUS = {
|
|
22
|
+
PENDING: "pending",
|
|
23
|
+
DONE: "done",
|
|
24
|
+
DROPPED: "dropped",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** 一个节点是否还"活着"(未完成、未丢弃)。 */
|
|
28
|
+
export function isOpen(node) {
|
|
29
|
+
return node !== undefined && node !== null && node.status === STATUS.PENDING;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 已结束(完成或丢弃)。 */
|
|
33
|
+
function isClosed(node) {
|
|
34
|
+
return node.status === STATUS.DONE || node.status === STATUS.DROPPED;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 进程内自增,配合时间戳保证 id 唯一且短。 */
|
|
38
|
+
let seq = 0;
|
|
39
|
+
|
|
40
|
+
function newId(prefix) {
|
|
41
|
+
seq += 1;
|
|
42
|
+
return `${prefix}-${Date.now().toString(36)}${seq.toString(36)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 标题归一化:去空白,空标题给个占位。 */
|
|
46
|
+
function normalizeTitle(value, fallback) {
|
|
47
|
+
return String(value ?? "").trim() || fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 子任务节点(第三级,没有下级)。 */
|
|
51
|
+
function makeStep(input, now) {
|
|
52
|
+
return {
|
|
53
|
+
id: newId("s"),
|
|
54
|
+
title: normalizeTitle(input?.title, "(未命名子任务)"),
|
|
55
|
+
detail: String(input?.detail ?? ""),
|
|
56
|
+
status: STATUS.PENDING,
|
|
57
|
+
createdAt: now,
|
|
58
|
+
completedAt: null,
|
|
59
|
+
result: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 任务节点(第二级)。 */
|
|
64
|
+
function makeTask(input, now) {
|
|
65
|
+
return {
|
|
66
|
+
id: newId("t"),
|
|
67
|
+
title: normalizeTitle(input?.title, "(未命名任务)"),
|
|
68
|
+
detail: String(input?.detail ?? ""),
|
|
69
|
+
status: STATUS.PENDING,
|
|
70
|
+
createdAt: now,
|
|
71
|
+
completedAt: null,
|
|
72
|
+
result: null,
|
|
73
|
+
steps: [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 建一棵全新的计划树。首版是单活动计划,所以这会替换掉原有的树。
|
|
79
|
+
*
|
|
80
|
+
* @param input - `{ title, detail?, tasks: [{ title, detail? }] }`。
|
|
81
|
+
* @param now - 注入的时间戳,便于测试。
|
|
82
|
+
* @returns 完整的计划树对象。
|
|
83
|
+
*/
|
|
84
|
+
export function createPlan(input, now = Date.now()) {
|
|
85
|
+
const tasks = Array.isArray(input?.tasks) ? input.tasks : [];
|
|
86
|
+
if (tasks.length === 0) {
|
|
87
|
+
throw new Error("至少要有一个任务;请在 tasks 里给出");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
version: 1,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
goal: {
|
|
94
|
+
id: newId("g"),
|
|
95
|
+
title: normalizeTitle(input?.title, "(未命名目标)"),
|
|
96
|
+
detail: String(input?.detail ?? ""),
|
|
97
|
+
status: STATUS.PENDING,
|
|
98
|
+
createdAt: now,
|
|
99
|
+
completedAt: null,
|
|
100
|
+
result: null,
|
|
101
|
+
tasks: tasks.map((task) => makeTask(task, now)),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 按 id 找节点,并给出它在树里的位置。
|
|
108
|
+
*
|
|
109
|
+
* @param plan - 计划树。
|
|
110
|
+
* @param id - 节点 id(目标 / 任务 / 子任务都可以)。
|
|
111
|
+
* @returns `{ level, goal, task, step, node }`,找不到返回 null。
|
|
112
|
+
*/
|
|
113
|
+
export function findNode(plan, id) {
|
|
114
|
+
const goal = plan?.goal;
|
|
115
|
+
if (!goal) return null;
|
|
116
|
+
if (goal.id === id) {
|
|
117
|
+
return { level: "goal", goal, task: null, step: null, node: goal };
|
|
118
|
+
}
|
|
119
|
+
for (const task of goal.tasks ?? []) {
|
|
120
|
+
if (task.id === id) {
|
|
121
|
+
return { level: "task", goal, task, step: null, node: task };
|
|
122
|
+
}
|
|
123
|
+
for (const step of task.steps ?? []) {
|
|
124
|
+
if (step.id === id) {
|
|
125
|
+
return { level: "step", goal, task, step, node: step };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 推导"现在应该做哪个节点"。
|
|
134
|
+
*
|
|
135
|
+
* @param plan - 计划树。
|
|
136
|
+
* @returns `null` 表示整棵树都结束了;否则 `{ goal, task, step }`,
|
|
137
|
+
* 其中 `task` 为 `null` 表示目标下已没有未结束的任务,
|
|
138
|
+
* `step` 为 `null` 表示当前任务还没拆子任务(需要先 `tree_task_plan`)。
|
|
139
|
+
*/
|
|
140
|
+
export function activePath(plan) {
|
|
141
|
+
const goal = plan?.goal;
|
|
142
|
+
if (!goal || !isOpen(goal)) return null;
|
|
143
|
+
const task = (goal.tasks ?? []).find(isOpen);
|
|
144
|
+
if (!task) return { goal, task: null, step: null };
|
|
145
|
+
const step = (task.steps ?? []).find(isOpen);
|
|
146
|
+
return { goal, task, step: step ?? null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 找出当前的检查点:一个**自己还没完成、但子节点已经全部结束**的节点。
|
|
151
|
+
*
|
|
152
|
+
* 只沿活动路径判断——模型一次推进一条路径,别的分支还没轮到。
|
|
153
|
+
* 一个从没拆过子节点的节点不算检查点:它处于"待拆分",不是"待拍板"。
|
|
154
|
+
*
|
|
155
|
+
* @param plan - 计划树。
|
|
156
|
+
* @returns `{ level, goal, task, step, node }`,没有检查点时返回 null。
|
|
157
|
+
*/
|
|
158
|
+
export function checkpointOf(plan) {
|
|
159
|
+
const goal = plan?.goal;
|
|
160
|
+
if (!goal || !isOpen(goal)) return null;
|
|
161
|
+
|
|
162
|
+
const tasks = goal.tasks ?? [];
|
|
163
|
+
const openTask = tasks.find(isOpen);
|
|
164
|
+
|
|
165
|
+
if (openTask === undefined) {
|
|
166
|
+
// 目标下已经没有未结束的任务,但目标自己还开着。
|
|
167
|
+
return tasks.length > 0 ? { level: "goal", goal, task: null, step: null, node: goal } : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const steps = openTask.steps ?? [];
|
|
171
|
+
if (steps.length === 0) return null;
|
|
172
|
+
if (steps.some(isOpen)) return null;
|
|
173
|
+
return { level: "task", goal, task: openTask, step: null, node: openTask };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 给一个节点追加子节点。
|
|
178
|
+
*
|
|
179
|
+
* 目标接受任务(每个任务自带空的子任务列表),任务接受子任务。
|
|
180
|
+
* 子任务是最低一级,不能再往下拆。
|
|
181
|
+
*
|
|
182
|
+
* @param plan - 计划树。
|
|
183
|
+
* @param parentId - 父节点 id(目标或任务)。
|
|
184
|
+
* @param items - `[{ title, detail? }]`。
|
|
185
|
+
* @param now - 时间戳。
|
|
186
|
+
* @returns `{ ok, reason?, added? }`。
|
|
187
|
+
*/
|
|
188
|
+
export function addChildren(plan, parentId, items, now = Date.now()) {
|
|
189
|
+
const found = findNode(plan, parentId);
|
|
190
|
+
if (!found) return { ok: false, reason: `找不到节点 ${parentId}` };
|
|
191
|
+
if (found.level === "step") {
|
|
192
|
+
return { ok: false, reason: `子任务 ${parentId} 是最低一级,不能再拆` };
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
195
|
+
return { ok: false, reason: "至少要给一个子节点" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const added =
|
|
199
|
+
found.level === "goal"
|
|
200
|
+
? items.map((item) => makeTask(item, now))
|
|
201
|
+
: items.map((item) => makeStep(item, now));
|
|
202
|
+
if (found.level === "goal") {
|
|
203
|
+
found.goal.tasks.push(...added);
|
|
204
|
+
} else {
|
|
205
|
+
found.task.steps.push(...added);
|
|
206
|
+
}
|
|
207
|
+
plan.updatedAt = now;
|
|
208
|
+
return { ok: true, added, level: found.level };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 把一个节点标为完成,并记录它提交的结果。
|
|
213
|
+
*
|
|
214
|
+
* 完成必须带结果——插件要用它替换掉这个节点的执行过程。
|
|
215
|
+
* 子节点全部结束不会让父节点自动完成,只会让它进入检查点(见 `checkpointOf`)。
|
|
216
|
+
*
|
|
217
|
+
* @param plan - 计划树。
|
|
218
|
+
* @param id - 要完成的节点 id。
|
|
219
|
+
* @param result - 这个节点产出了什么的说明文本。
|
|
220
|
+
* @param now - 时间戳。
|
|
221
|
+
* @returns `{ ok, reason?, events?, active?, checkpoint? }`。
|
|
222
|
+
*/
|
|
223
|
+
export function complete(plan, id, result, now = Date.now()) {
|
|
224
|
+
const found = findNode(plan, id);
|
|
225
|
+
if (!found) return { ok: false, reason: `找不到节点 ${id}` };
|
|
226
|
+
const { level, node } = found;
|
|
227
|
+
|
|
228
|
+
if (node.status === STATUS.DONE) {
|
|
229
|
+
return { ok: false, reason: `节点 ${id} 已经是完成状态` };
|
|
230
|
+
}
|
|
231
|
+
if (node.status === STATUS.DROPPED) {
|
|
232
|
+
return { ok: false, reason: `节点 ${id} 已被丢弃,不能标记完成` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const text = typeof result === "string" ? result.trim() : "";
|
|
236
|
+
if (text === "") {
|
|
237
|
+
return { ok: false, reason: `完成${levelLabel(level)}必须提交结果:请在 result 里写清它产出了什么` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
node.status = STATUS.DONE;
|
|
241
|
+
node.completedAt = now;
|
|
242
|
+
node.result = { text, at: now };
|
|
243
|
+
plan.updatedAt = now;
|
|
244
|
+
|
|
245
|
+
const events = [`${levelLabel(level)}「${node.title}」已完成`];
|
|
246
|
+
const checkpoint = checkpointOf(plan);
|
|
247
|
+
if (checkpoint !== null) {
|
|
248
|
+
events.push(checkpointNote(checkpoint));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
events,
|
|
254
|
+
active: activePath(plan),
|
|
255
|
+
checkpoint: checkpoint === null ? null : { id: checkpoint.node.id, level: checkpoint.level },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 丢弃一个节点。丢弃任务会连同它的子任务一起丢弃。
|
|
261
|
+
*
|
|
262
|
+
* 改需求就用这个丢弃 + `tree_task_plan` 追加新子节点,旧节点留在树里可查。
|
|
263
|
+
*
|
|
264
|
+
* @param plan - 计划树。
|
|
265
|
+
* @param id - 节点 id。
|
|
266
|
+
* @param now - 时间戳。
|
|
267
|
+
* @returns `{ ok, reason?, dropped?, active?, checkpoint? }`。
|
|
268
|
+
*/
|
|
269
|
+
export function dropNode(plan, id, now = Date.now()) {
|
|
270
|
+
const found = findNode(plan, id);
|
|
271
|
+
if (!found) return { ok: false, reason: `找不到节点 ${id}` };
|
|
272
|
+
if (found.level === "goal") {
|
|
273
|
+
return { ok: false, reason: "不能丢弃整个目标;请用 tree_task_create 重建计划" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const dropped = [`${levelLabel(found.level)}「${found.node.title}」`];
|
|
277
|
+
found.node.status = STATUS.DROPPED;
|
|
278
|
+
for (const step of found.node.steps ?? []) {
|
|
279
|
+
if (!isClosed(step)) {
|
|
280
|
+
step.status = STATUS.DROPPED;
|
|
281
|
+
dropped.push(`子任务「${step.title}」`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
plan.updatedAt = now;
|
|
285
|
+
|
|
286
|
+
const checkpoint = checkpointOf(plan);
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
dropped,
|
|
290
|
+
active: activePath(plan),
|
|
291
|
+
checkpoint: checkpoint === null ? null : { id: checkpoint.node.id, level: checkpoint.level },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 改一个节点的标题或说明。
|
|
297
|
+
*
|
|
298
|
+
* @param plan - 计划树。
|
|
299
|
+
* @param id - 节点 id。
|
|
300
|
+
* @param patch - `{ title?, detail? }` 的任意子集。
|
|
301
|
+
* @param now - 时间戳。
|
|
302
|
+
* @returns `{ ok, reason?, changed? }`。
|
|
303
|
+
*/
|
|
304
|
+
export function updateNode(plan, id, patch, now = Date.now()) {
|
|
305
|
+
const found = findNode(plan, id);
|
|
306
|
+
if (!found) return { ok: false, reason: `找不到节点 ${id}` };
|
|
307
|
+
|
|
308
|
+
const changed = [];
|
|
309
|
+
const { node } = found;
|
|
310
|
+
if (typeof patch?.title === "string" && patch.title.trim() !== "") {
|
|
311
|
+
node.title = patch.title.trim();
|
|
312
|
+
changed.push("title");
|
|
313
|
+
}
|
|
314
|
+
if (typeof patch?.detail === "string") {
|
|
315
|
+
node.detail = patch.detail;
|
|
316
|
+
changed.push("detail");
|
|
317
|
+
}
|
|
318
|
+
if (changed.length === 0) {
|
|
319
|
+
return { ok: false, reason: "没有给出任何要改的字段(title / detail)" };
|
|
320
|
+
}
|
|
321
|
+
plan.updatedAt = now;
|
|
322
|
+
return { ok: true, changed };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 层级的显示名。树渲染、检查点提示、闸门拒绝文案都用它,保证三处称呼一致。
|
|
327
|
+
*
|
|
328
|
+
* @param level - `goal` / `task` / `step`。
|
|
329
|
+
* @returns 中文称呼。
|
|
330
|
+
*/
|
|
331
|
+
export function levelLabel(level) {
|
|
332
|
+
if (level === "goal") return "目标";
|
|
333
|
+
if (level === "task") return "任务";
|
|
334
|
+
return "子任务";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 检查点那句话:只放行拆子节点与提交结果两条路。 */
|
|
338
|
+
function checkpointNote(checkpoint) {
|
|
339
|
+
const label = levelLabel(checkpoint.level);
|
|
340
|
+
return (
|
|
341
|
+
`${label}「${checkpoint.node.title}」的子节点已全部结束,转入检查点。` +
|
|
342
|
+
`此刻只有两条路:用 tree_task_plan 继续给 ${checkpoint.node.id} 拆子节点,` +
|
|
343
|
+
`或者用 tree_task_done(id="${checkpoint.node.id}", result="…") 提交这个${label}的结果并完成它。`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 节点状态的中文短标记。 */
|
|
348
|
+
function mark(node) {
|
|
349
|
+
if (node.status === STATUS.DONE) return "[x]";
|
|
350
|
+
if (node.status === STATUS.DROPPED) return "[-]";
|
|
351
|
+
return "[ ]";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 结果取首行,超长截断——树上只报"产出了什么",全文在上下文里的汇总消息里。 */
|
|
355
|
+
function firstLine(text, limit = 120) {
|
|
356
|
+
const line = String(text ?? "").split("\n")[0].trim();
|
|
357
|
+
return line.length > limit ? `${line.slice(0, limit)}…` : line;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 把整棵树渲染成给模型看的文本。
|
|
362
|
+
*
|
|
363
|
+
* @param plan - 计划树;null 表示还没有计划。
|
|
364
|
+
* @returns 多行文本。
|
|
365
|
+
*/
|
|
366
|
+
export function renderTree(plan) {
|
|
367
|
+
if (!plan?.goal) return "(还没有计划。先用 tree_task_create 建立目标与任务。)";
|
|
368
|
+
const active = activePath(plan);
|
|
369
|
+
const checkpoint = checkpointOf(plan);
|
|
370
|
+
const lines = [];
|
|
371
|
+
const goal = plan.goal;
|
|
372
|
+
|
|
373
|
+
lines.push(`${mark(goal)} 目标 ${goal.id} ${goal.title}`);
|
|
374
|
+
if (goal.detail) lines.push(` ${goal.detail}`);
|
|
375
|
+
if (goal.result) lines.push(` 结果: ${firstLine(goal.result.text)}`);
|
|
376
|
+
|
|
377
|
+
for (const task of goal.tasks ?? []) {
|
|
378
|
+
const taskHere = active?.task?.id === task.id || checkpoint?.node?.id === task.id;
|
|
379
|
+
lines.push(` ${mark(task)} 任务 ${task.id} ${task.title}${taskHere ? " ← 当前" : ""}`);
|
|
380
|
+
if (task.result) lines.push(` 结果: ${firstLine(task.result.text)}`);
|
|
381
|
+
const steps = task.steps ?? [];
|
|
382
|
+
if (steps.length === 0 && isOpen(task)) {
|
|
383
|
+
lines.push(" (还没有子节点 → 调用 tree_task_plan)");
|
|
384
|
+
}
|
|
385
|
+
for (const step of steps) {
|
|
386
|
+
lines.push(` ${mark(step)} 子任务 ${step.id} ${step.title}${active?.step?.id === step.id ? " ← 当前" : ""}`);
|
|
387
|
+
if (step.result) lines.push(` 结果: ${firstLine(step.result.text)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return lines.join("\n");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 生成"接下来做什么"的指令文本,给自动续行用。
|
|
395
|
+
*
|
|
396
|
+
* @param plan - 计划树。
|
|
397
|
+
* @returns 指令文本;整棵树已结束时说明已经做完。
|
|
398
|
+
*/
|
|
399
|
+
export function renderContinue(plan) {
|
|
400
|
+
const checkpoint = checkpointOf(plan);
|
|
401
|
+
if (checkpoint !== null) {
|
|
402
|
+
const label = levelLabel(checkpoint.level);
|
|
403
|
+
return [
|
|
404
|
+
`检查点:${label}「${checkpoint.node.title}」(${checkpoint.node.id}) 的子节点已全部结束。`,
|
|
405
|
+
`此刻只放行两个工具:tree_task_plan(继续给 ${checkpoint.node.id} 拆子节点)与 ` +
|
|
406
|
+
`tree_task_done(提交这个${label}的结果并完成它)。`,
|
|
407
|
+
`不要再调用别的工具。`,
|
|
408
|
+
].join("\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const active = activePath(plan);
|
|
412
|
+
if (active === null) {
|
|
413
|
+
return "计划树上的所有节点都已完成。请向用户汇报最终结果,不要再调用 tree_task_* 工具。";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (active.task !== null && active.step === null) {
|
|
417
|
+
return `当前任务「${active.task.title}」(${active.task.id}) 还没有子节点。请调用 tree_task_plan 为它列出子任务,然后开始执行第一个。`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (active.step !== null) {
|
|
421
|
+
const step = active.step;
|
|
422
|
+
return [
|
|
423
|
+
`请继续执行下一个子任务。`,
|
|
424
|
+
`当前子任务:${step.id}「${step.title}」${step.detail ? ` —— ${step.detail}` : ""}`,
|
|
425
|
+
`做完之后调用 tree_task_done(id="${step.id}", result="…"),在 result 里写清它产出了什么。`,
|
|
426
|
+
].join("\n");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return `请继续推进任务「${active.task?.title ?? "(未知)"}」。`;
|
|
430
|
+
}
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统提示词段落:**只有一段,静态,会话内逐字节不变**。
|
|
3
|
+
*
|
|
4
|
+
* 这里只说明"这组工具是什么、怎么配合用"。计划的当前状态由 `tree_task_status`
|
|
5
|
+
* 现取;已完成节点的产出一部分在树上(首行),全文在完成时注入的那条汇总消息里
|
|
6
|
+
* ——都不放进这里。
|
|
7
|
+
*
|
|
8
|
+
* 之所以坚持静态:提示词一变,DSH 就会往 surface 上追加一整份新副本
|
|
9
|
+
* (默认 in-history 路由);万一撞上归一化,还会重写节点 0,
|
|
10
|
+
* 让整个上下文从第一个 token 起失去缓存。所以这里一个字都不动。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-tree-task-flow/prompt
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 段落名。注册表要求全局唯一。 */
|
|
16
|
+
export const SECTION_NAME = "dsh-tree-task-flow";
|
|
17
|
+
|
|
18
|
+
/** 默认排序。放在部署人设(0)、计划策略(500)之后,所有内置工具指引之前。 */
|
|
19
|
+
export const DEFAULT_ORDER = 2000;
|
|
20
|
+
|
|
21
|
+
/** 段落正文。 */
|
|
22
|
+
export const SECTION_TEXT = `## 树形任务流(tree_task_* 工具组)
|
|
23
|
+
|
|
24
|
+
一件事需要多步才能做完时,用 tree_task_* 这组工具把它组织成一棵树,
|
|
25
|
+
而不是把计划写在回复正文里。层级固定三级:**目标 → 任务 → 子任务**;
|
|
26
|
+
子任务是最低一级,不能再往下拆。
|
|
27
|
+
|
|
28
|
+
- **tree_task_create** —— 建立一个计划:一个目标 + 若干任务。会替换掉现有计划,只在开始新目标时用。
|
|
29
|
+
- **tree_task_plan** —— 给一个节点追加子节点:给目标追加任务,给任务追加子任务。
|
|
30
|
+
- **tree_task_status** —— 查看整棵树、各节点状态与已提交的结果,以及现在该推进哪个节点。
|
|
31
|
+
- **tree_task_done** —— 完成一个节点并提交它的结果。**result 必填。**
|
|
32
|
+
- **tree_task_update** —— 修改一个节点的标题或说明。
|
|
33
|
+
- **tree_task_drop** —— 丢弃一个节点;丢弃任务会连同它的子任务一起丢弃。改需求用它,不要重建整棵树。
|
|
34
|
+
|
|
35
|
+
三条规则:
|
|
36
|
+
|
|
37
|
+
1. **完成必须写 result。** 一个节点完成时,插件会把它从开始到现在的执行过程
|
|
38
|
+
从上下文里折叠掉,换成一条只携带这段结果的汇总消息。所以 result 要写清它
|
|
39
|
+
究竟产出了什么,写成能独立看懂的样子——过程会被丢掉,别写在里面。
|
|
40
|
+
2. **子任务全部结束,不会让父节点自动完成**,而是转入**检查点**。那一刻只剩两条路:
|
|
41
|
+
用 tree_task_plan 继续给这个节点拆子节点,或者用 tree_task_done 提交它的结果。
|
|
42
|
+
检查点期间其它工具一律被拒绝——"这一层到底做完没有"要由你判断,不能被计数规则替你定死。
|
|
43
|
+
3. 折叠只发生在完成的那一刻:已完成节点的执行过程不再可见,只留下它提交的结果,
|
|
44
|
+
而同级节点各自的结果都会保留下来。需要看全貌——哪些做完了、各自产出了什么——
|
|
45
|
+
调用 tree_task_status,不要凭印象猜。`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 注册段落。调用方必须包在 `ctx.effect()` 里,否则热更新后会重复注册而抛错。
|
|
49
|
+
*
|
|
50
|
+
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
51
|
+
* @param order - 段落排序。
|
|
52
|
+
* @returns 注册返回的 disposer(由 ctx.effect 持有)。
|
|
53
|
+
*/
|
|
54
|
+
export function registerPromptSection(ctx, order) {
|
|
55
|
+
return () => ctx.systemPrompt.section({ name: SECTION_NAME, order, text: SECTION_TEXT });
|
|
56
|
+
}
|