dsh-plugin-taskflow 0.0.0-stage → 0.1.1
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 +246 -2
- package/client/client.js +1481 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.js +1421 -0
- package/package.json +52 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskFlow — host half (DeepSeek Harness plugin).
|
|
3
|
+
*
|
|
4
|
+
* Two jobs:
|
|
5
|
+
* 1. Keep a per-session timeline of the agent's todo list, so the browser can
|
|
6
|
+
* show how long every task has been running / took to finish.
|
|
7
|
+
* 2. When a turn ends, condense that turn (tool calls, files, commands, the
|
|
8
|
+
* assistant's own closing prose) into a short "segment" summary with one
|
|
9
|
+
* lightweight model call, then serve it to the browser over HTTP.
|
|
10
|
+
*
|
|
11
|
+
* Zero package dependencies on purpose: the plugin only touches Node builtins
|
|
12
|
+
* and the cordis services the host already provides (`session` events, `llm`,
|
|
13
|
+
* `webServer`, `commands`, `sessions`). Nothing here imports a @deepseek-ai
|
|
14
|
+
* package, so the plugin keeps working when the profile's module links move.
|
|
15
|
+
*
|
|
16
|
+
* Data lives in `$DSH_HOME/taskflow/<session>.json` (DSH_HOME defaults to
|
|
17
|
+
* ~/.dsh). It is display-only state: losing it costs timings and summaries,
|
|
18
|
+
* never session correctness.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { randomUUID } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
export const name = 'taskflow'
|
|
27
|
+
|
|
28
|
+
const VERSION = '0.1.0'
|
|
29
|
+
const MAX_SEGMENTS = 60
|
|
30
|
+
const MAX_HISTORY = 40
|
|
31
|
+
const MAX_DIGEST_CHARS = 14_000
|
|
32
|
+
const SAVE_DEBOUNCE_MS = 400
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CONFIG = {
|
|
35
|
+
/** Master switch: false keeps the strip but never calls a model. */
|
|
36
|
+
enabled: true,
|
|
37
|
+
/** Summarize turns with at least this many tool calls ... */
|
|
38
|
+
minToolCalls: 1,
|
|
39
|
+
/** ... or with at least this much assistant prose (whitespace-trimmed). */
|
|
40
|
+
minAssistantChars: 240,
|
|
41
|
+
/** Hard cap for the digest handed to the summarizer. */
|
|
42
|
+
maxDigestChars: MAX_DIGEST_CHARS,
|
|
43
|
+
/** Output budget of the summarization call. */
|
|
44
|
+
maxOutputTokens: 600,
|
|
45
|
+
/** Per-call watchdog. */
|
|
46
|
+
timeoutMs: 45_000,
|
|
47
|
+
/** Concurrent summarization calls across all sessions. */
|
|
48
|
+
maxConcurrent: 2,
|
|
49
|
+
/** Optional explicit route; empty means "whatever the session last used". */
|
|
50
|
+
provider: '',
|
|
51
|
+
model: '',
|
|
52
|
+
/** Retain at most this many segments per session. */
|
|
53
|
+
maxSegments: MAX_SEGMENTS,
|
|
54
|
+
/** Delegated child sessions are skipped by default: their turns are not the user's. */
|
|
55
|
+
includeSubagents: false,
|
|
56
|
+
/** Add the task-list policy to the model's system prompt. */
|
|
57
|
+
taskListPolicy: true,
|
|
58
|
+
/** Add the parallel-decomposition policy (absorbed from the SDK parallel-agents SOP). */
|
|
59
|
+
parallelPolicy: true,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TASK_PROMPT = [
|
|
63
|
+
'你是一名工程进度记录员。输入是 agent 完成某个任务期间的真实动作记录(工具调用、文件、耗时)。',
|
|
64
|
+
'请把「这个任务到底做了什么」压缩成一句话中文小结,规则:',
|
|
65
|
+
'- 一句话,不超过 60 个字,动词开头;',
|
|
66
|
+
'- 只写做了什么、改了什么、结论是什么;保留关键文件名/命令/错误码;',
|
|
67
|
+
'- 不要写「已完成」「成功完成」这类状态词,不要评价,不要分点,不要换行;',
|
|
68
|
+
'- 如果记录里几乎没有有效动作,只输出「无有效动作」。',
|
|
69
|
+
].join('\n')
|
|
70
|
+
|
|
71
|
+
const SYSTEM_PROMPT = [
|
|
72
|
+
'你是一名严谨的工程进度记录员。输入是一段 agent 会话回合的原始记录,包含用户请求、工具调用、文件改动与助手回复。',
|
|
73
|
+
'请把这一段压缩成中文小结,严格遵循下列格式,不要输出任何额外解释:',
|
|
74
|
+
'第一行:不超过 24 个字的标题,动词开头,不要句号、不要引号。',
|
|
75
|
+
'随后 1-3 行,每行以「- 」开头,写关键动作、产物或结论;保留具体的文件路径、命令、错误要点;',
|
|
76
|
+
'不要复述用户原话,不要写「本次」「我们」这类空话,不要评价工作质量。',
|
|
77
|
+
'如果这段没有实质产出(被中断、只有时间戳/元信息、没有动作也没有结论),只输出一行「(无实质产出)」,不要编造过程。',
|
|
78
|
+
'只输出小结本身。',
|
|
79
|
+
].join('\n')
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the on-disk state root once, so a later DSH_HOME change cannot split
|
|
83
|
+
* one session's records across two directories.
|
|
84
|
+
*/
|
|
85
|
+
function stateRoot() {
|
|
86
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME.length > 0 ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
87
|
+
return join(home, 'taskflow')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Encode a session id into a portable file name. */
|
|
91
|
+
function stateFile(sessionId) {
|
|
92
|
+
return join(stateRoot(), `${encodeURIComponent(sessionId)}.json`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function log(ctx, level, message) {
|
|
96
|
+
try {
|
|
97
|
+
const logger = typeof ctx.logger === 'function' ? ctx.logger('taskflow') : ctx.logger
|
|
98
|
+
const fn = logger?.[level]
|
|
99
|
+
if (typeof fn === 'function') fn.call(logger, message)
|
|
100
|
+
} catch {
|
|
101
|
+
// Logging must never take the plugin down.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Concatenate the text blocks of one message payload. */
|
|
106
|
+
function textOf(message) {
|
|
107
|
+
const content = message?.content
|
|
108
|
+
if (!Array.isArray(content)) return ''
|
|
109
|
+
const parts = []
|
|
110
|
+
for (const block of content) {
|
|
111
|
+
if (block && block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) parts.push(block.text)
|
|
112
|
+
}
|
|
113
|
+
return parts.join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One-line, length-bounded rendering of arbitrary tool arguments. */
|
|
117
|
+
function describeArgs(name, args) {
|
|
118
|
+
// Tool-call arguments arrive either as an object or as the raw JSON string.
|
|
119
|
+
let value = args
|
|
120
|
+
if (typeof value === 'string') {
|
|
121
|
+
try {
|
|
122
|
+
value = JSON.parse(value)
|
|
123
|
+
} catch {
|
|
124
|
+
return clip(value.replace(/\s+/g, ' ').trim(), 160)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
args = value
|
|
128
|
+
if (!args || typeof args !== 'object') return ''
|
|
129
|
+
const first = (...keys) => {
|
|
130
|
+
for (const key of keys) {
|
|
131
|
+
const value = args[key]
|
|
132
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
133
|
+
}
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
const direct = first('command', 'file_path', 'filePath', 'path', 'pattern', 'query', 'url', 'name', 'prompt')
|
|
137
|
+
if (direct.length > 0) return clip(direct.replace(/\s+/g, ' ').trim(), 160)
|
|
138
|
+
try {
|
|
139
|
+
return clip(JSON.stringify(args), 160)
|
|
140
|
+
} catch {
|
|
141
|
+
return ''
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clip(text, limit) {
|
|
146
|
+
const value = String(text)
|
|
147
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Collect the file paths a tool call touched, when it names any. */
|
|
151
|
+
function pathsOf(args) {
|
|
152
|
+
const out = []
|
|
153
|
+
if (typeof args === 'string') {
|
|
154
|
+
try {
|
|
155
|
+
args = JSON.parse(args)
|
|
156
|
+
} catch {
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!args || typeof args !== 'object') return out
|
|
161
|
+
for (const key of ['file_path', 'filePath', 'path', 'paths', 'target', 'destination']) {
|
|
162
|
+
const value = args[key]
|
|
163
|
+
if (typeof value === 'string' && value.length > 0) out.push(value)
|
|
164
|
+
else if (Array.isArray(value)) for (const item of value) if (typeof item === 'string' && item.length > 0) out.push(item)
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fold one turn's events into the compact text a summarizer can read.
|
|
171
|
+
* @returns {{ digest: string, toolCalls: number, assistantChars: number, files: string[], commands: string[] }}
|
|
172
|
+
*/
|
|
173
|
+
function buildDigest(events, cwd) {
|
|
174
|
+
const lines = []
|
|
175
|
+
const files = new Set()
|
|
176
|
+
const commands = []
|
|
177
|
+
let toolCalls = 0
|
|
178
|
+
let assistantChars = 0
|
|
179
|
+
let userText = ''
|
|
180
|
+
let assistantText = ''
|
|
181
|
+
|
|
182
|
+
for (const event of events) {
|
|
183
|
+
switch (event.type) {
|
|
184
|
+
case 'user/message': {
|
|
185
|
+
const text = textOf(event.data)
|
|
186
|
+
if (text.length > 0) userText = text
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
case 'assistant/message': {
|
|
190
|
+
const text = textOf(event.data?.message)
|
|
191
|
+
if (text.length > 0) {
|
|
192
|
+
assistantText = assistantText.length > 0 ? `${assistantText}\n${text}` : text
|
|
193
|
+
assistantChars += text.trim().length
|
|
194
|
+
}
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
case 'tool/call': {
|
|
198
|
+
toolCalls += 1
|
|
199
|
+
const toolName = String(event.data?.name ?? 'tool')
|
|
200
|
+
const args = event.data?.arguments
|
|
201
|
+
for (const path of pathsOf(args)) files.add(path)
|
|
202
|
+
if (toolName === 'bash' || toolName === 'pwsh') {
|
|
203
|
+
const command = typeof args?.command === 'string' ? args.command.trim() : ''
|
|
204
|
+
if (command.length > 0) commands.push(clip(command.split('\n')[0], 140))
|
|
205
|
+
}
|
|
206
|
+
lines.push(`- ${toolName}: ${describeArgs(toolName, args)}`)
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
case 'tool/result': {
|
|
210
|
+
const error = event.data?.error
|
|
211
|
+
if (error && typeof error.message === 'string') lines.push(` ! 失败: ${clip(error.message.replace(/\s+/g, ' '), 160)}`)
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
case 'todo/write': {
|
|
215
|
+
const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
|
|
216
|
+
if (todos.length > 0) {
|
|
217
|
+
const done = todos.filter((item) => item.status === 'completed').length
|
|
218
|
+
const active = todos.find((item) => item.status === 'in_progress')
|
|
219
|
+
lines.push(`- 任务清单: ${done}/${todos.length} 完成${active ? `,进行中「${active.content}」` : ''}`)
|
|
220
|
+
}
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tail = assistantText.trim()
|
|
229
|
+
const head = [
|
|
230
|
+
userText.trim().length > 0 ? `用户请求:${clip(userText.trim(), 600)}` : '',
|
|
231
|
+
cwd ? `工作目录:${cwd}` : '',
|
|
232
|
+
lines.length > 0 ? `执行过程:\n${lines.slice(0, 60).join('\n')}` : '',
|
|
233
|
+
tail.length > 0 ? `助手收尾:${clip(tail, 900)}` : '',
|
|
234
|
+
].filter((part) => part.length > 0)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
digest: head.join('\n\n'),
|
|
238
|
+
toolCalls,
|
|
239
|
+
assistantChars,
|
|
240
|
+
files: [...files].slice(0, 16),
|
|
241
|
+
commands: commands.slice(0, 8),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Split the model's answer into a title and bullet lines. */
|
|
246
|
+
function parseSummary(raw) {
|
|
247
|
+
const lines = String(raw)
|
|
248
|
+
.split(/\r?\n/)
|
|
249
|
+
.map((line) => line.trim())
|
|
250
|
+
.filter((line) => line.length > 0)
|
|
251
|
+
if (lines.length === 0) return null
|
|
252
|
+
let title = lines[0].replace(/^#+\s*/, '').replace(/^["'「『]|["'」』]$/g, '').trim()
|
|
253
|
+
title = clip(title, 60)
|
|
254
|
+
const bullets = []
|
|
255
|
+
for (const line of lines.slice(1)) {
|
|
256
|
+
if (/^[-*·•]/.test(line)) bullets.push(clip(line.replace(/^[-*·•]\s*/, ''), 220))
|
|
257
|
+
}
|
|
258
|
+
if (bullets.length === 0 && lines.length > 1) bullets.push(clip(lines.slice(1).join(' '), 220))
|
|
259
|
+
return { title, bullets: bullets.slice(0, 4) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Deterministic summary used when no model route exists or the call fails. */
|
|
263
|
+
function mechanicalSummary(input) {
|
|
264
|
+
const { digestParts } = input
|
|
265
|
+
const titleSource = digestParts.userText
|
|
266
|
+
|| (digestParts.files && digestParts.files[0])
|
|
267
|
+
|| (digestParts.commands && digestParts.commands[0])
|
|
268
|
+
|| `第 ${input.turn} 段`
|
|
269
|
+
const bullets = []
|
|
270
|
+
if (digestParts.toolCalls > 0) bullets.push(`${digestParts.toolCalls} 次工具调用:${digestParts.toolNames.slice(0, 5).join('、')}`)
|
|
271
|
+
if (digestParts.files.length > 0) bullets.push(`改动文件:${digestParts.files.slice(0, 4).join('、')}`)
|
|
272
|
+
if (digestParts.commands.length > 0) bullets.push(`命令:${digestParts.commands.slice(0, 3).join(';')}`)
|
|
273
|
+
if (bullets.length === 0 && digestParts.assistantTail) bullets.push(clip(digestParts.assistantTail, 180))
|
|
274
|
+
return { title: clip(titleSource, 40), bullets: bullets.slice(0, 3) }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function apply(ctx, rawConfig) {
|
|
278
|
+
const config = { ...DEFAULT_CONFIG, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) }
|
|
279
|
+
/** @type {Map<string, object>} */
|
|
280
|
+
const states = new Map()
|
|
281
|
+
/** @type {Array<() => Promise<void>>} */
|
|
282
|
+
const queue = []
|
|
283
|
+
/** @type {Set<string>} */
|
|
284
|
+
const pending = new Set()
|
|
285
|
+
/** sessionId → Set<ServerResponse> of live SSE listeners. */
|
|
286
|
+
const streams = new Map()
|
|
287
|
+
/** Debounced per-session broadcast, so a burst of events costs one frame. */
|
|
288
|
+
const streamTimers = new Map()
|
|
289
|
+
|
|
290
|
+
function streamSubscribers(sessionId) {
|
|
291
|
+
const set = streams.get(sessionId)
|
|
292
|
+
return set === undefined ? [] : [...set]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function pushFrame(response, state) {
|
|
296
|
+
try {
|
|
297
|
+
response.write(`event: state\ndata: ${JSON.stringify(payload(state))}\n\n`)
|
|
298
|
+
} catch {
|
|
299
|
+
// A dead socket is cleaned up by its own close handler.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Send the current state to every listener of one session, at most every 80 ms. */
|
|
304
|
+
function broadcast(sessionId) {
|
|
305
|
+
if (!streams.has(sessionId)) return
|
|
306
|
+
const existing = streamTimers.get(sessionId)
|
|
307
|
+
if (existing) return
|
|
308
|
+
streamTimers.set(sessionId, setTimeout(() => {
|
|
309
|
+
streamTimers.delete(sessionId)
|
|
310
|
+
const state = states.get(sessionId)
|
|
311
|
+
if (!state) return
|
|
312
|
+
for (const response of streamSubscribers(sessionId)) pushFrame(response, state)
|
|
313
|
+
}, 80))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** childSessionId → { parentId, inTurn } for delegated subagents that are running. */
|
|
317
|
+
const childSessions = new Map()
|
|
318
|
+
/** Every session id we have seen an event for, with its meta origin (diagnostics). */
|
|
319
|
+
const seenSessions = new Map()
|
|
320
|
+
const saveTimers = new Map()
|
|
321
|
+
/** Contents completed by the current todo/write call, drained by recordTodos. */
|
|
322
|
+
const pendingTaskSummaries = []
|
|
323
|
+
/** Queue for per-task summaries, independent from the per-turn segment queue. */
|
|
324
|
+
const taskQueue = []
|
|
325
|
+
/** @type {Set<string>} */
|
|
326
|
+
const taskPending = new Set()
|
|
327
|
+
let taskRunning = 0
|
|
328
|
+
let running = 0
|
|
329
|
+
let llm = null
|
|
330
|
+
// Liveness counters: they answer "did the browser half actually run and is it
|
|
331
|
+
// talking to me?" from /taskflow/health, without a screenshot.
|
|
332
|
+
let stateHits = 0
|
|
333
|
+
let lastStateAt = 0
|
|
334
|
+
let summaryRequests = 0
|
|
335
|
+
let clientHello = null
|
|
336
|
+
let policyRegistered = false
|
|
337
|
+
let parallelPolicyRegistered = false
|
|
338
|
+
/** Last measured geometry per rendered surface, reported by the browser half. */
|
|
339
|
+
const renders = {}
|
|
340
|
+
|
|
341
|
+
/** Lazily load (or create) one session's display state. */
|
|
342
|
+
function stateOf(sessionId) {
|
|
343
|
+
const cached = states.get(sessionId)
|
|
344
|
+
if (cached) return cached
|
|
345
|
+
let loaded = null
|
|
346
|
+
try {
|
|
347
|
+
loaded = JSON.parse(readFileSync(stateFile(sessionId), 'utf8'))
|
|
348
|
+
} catch {
|
|
349
|
+
loaded = null
|
|
350
|
+
}
|
|
351
|
+
const state = {
|
|
352
|
+
version: VERSION,
|
|
353
|
+
sessionId,
|
|
354
|
+
updatedAt: 0,
|
|
355
|
+
open: null,
|
|
356
|
+
lastTurn: typeof loaded?.lastTurn === 'number' ? loaded.lastTurn : 0,
|
|
357
|
+
skipped: Array.isArray(loaded?.skipped) ? loaded.skipped : [],
|
|
358
|
+
turns: {},
|
|
359
|
+
segments: Array.isArray(loaded?.segments) ? loaded.segments : [],
|
|
360
|
+
tasks: {
|
|
361
|
+
items: loaded?.tasks && typeof loaded.tasks.items === 'object' && loaded.tasks.items !== null ? loaded.tasks.items : {},
|
|
362
|
+
history: Array.isArray(loaded?.tasks?.history) ? loaded.tasks.history : [],
|
|
363
|
+
updatedAt: loaded?.tasks?.updatedAt ?? 0,
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
states.set(sessionId, state)
|
|
367
|
+
return state
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Debounced atomic write of one session's display state. */
|
|
371
|
+
function scheduleSave(state) {
|
|
372
|
+
state.updatedAt = Date.now()
|
|
373
|
+
const existing = saveTimers.get(state.sessionId)
|
|
374
|
+
if (existing) clearTimeout(existing)
|
|
375
|
+
saveTimers.set(state.sessionId, setTimeout(() => {
|
|
376
|
+
saveTimers.delete(state.sessionId)
|
|
377
|
+
try {
|
|
378
|
+
mkdirSync(stateRoot(), { recursive: true })
|
|
379
|
+
const target = stateFile(state.sessionId)
|
|
380
|
+
const temp = `${target}.${process.pid}.tmp`
|
|
381
|
+
writeFileSync(temp, JSON.stringify(state))
|
|
382
|
+
renameSync(temp, target)
|
|
383
|
+
} catch (error) {
|
|
384
|
+
log(ctx, 'warn', `[taskflow] failed to persist ${state.sessionId}: ${error?.message ?? error}`)
|
|
385
|
+
}
|
|
386
|
+
}, SAVE_DEBOUNCE_MS))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Track todo transitions so the strip can show per-task timing. */
|
|
390
|
+
function recordTodos(session, state, event, turn) {
|
|
391
|
+
const todos = Array.isArray(event.data?.todos) ? event.data.todos : []
|
|
392
|
+
pendingTaskSummaries.length = 0
|
|
393
|
+
const now = typeof event.time === 'number' ? event.time : Date.now()
|
|
394
|
+
const seen = new Set()
|
|
395
|
+
for (const todo of todos) {
|
|
396
|
+
if (!todo || typeof todo.content !== 'string') continue
|
|
397
|
+
const content = todo.content
|
|
398
|
+
seen.add(content)
|
|
399
|
+
const item = state.tasks.items[content] ?? (state.tasks.items[content] = { content, firstSeen: now })
|
|
400
|
+
item.status = todo.status
|
|
401
|
+
item.lastSeen = now
|
|
402
|
+
if (todo.status === 'in_progress' && !item.startedAt) item.startedAt = now
|
|
403
|
+
if (todo.status === 'completed' && !item.completedAt) {
|
|
404
|
+
item.completedAt = now
|
|
405
|
+
item.summaryPending = true
|
|
406
|
+
pendingTaskSummaries.push(content)
|
|
407
|
+
}
|
|
408
|
+
if (todo.status !== 'completed') item.completedAt = null
|
|
409
|
+
delete item.dropped
|
|
410
|
+
}
|
|
411
|
+
for (const [content, item] of Object.entries(state.tasks.items)) {
|
|
412
|
+
if (!seen.has(content) && item.status !== 'completed') item.dropped = true
|
|
413
|
+
}
|
|
414
|
+
const running = todos.find((todo) => todo && todo.status === 'in_progress')
|
|
415
|
+
state.currentTask = running ? running.content : null
|
|
416
|
+
state.tasks.history.push({ at: now, turn: turn ?? null, todos })
|
|
417
|
+
if (state.tasks.history.length > MAX_HISTORY) state.tasks.history.splice(0, state.tasks.history.length - MAX_HISTORY)
|
|
418
|
+
state.tasks.updatedAt = now
|
|
419
|
+
// Authoritative current list, scoped to the turn it belongs to: the client
|
|
420
|
+
// uses it only when the `todos` projection is unavailable.
|
|
421
|
+
state.currentList = todos.map((todo) => ({ content: todo.content, status: todo.status }))
|
|
422
|
+
state.currentListTurn = typeof turn === 'number' ? turn : (typeof state.openTurn === 'number' ? state.openTurn : null)
|
|
423
|
+
for (const content of pendingTaskSummaries) queueTaskSummary(session, state, content)
|
|
424
|
+
scheduleSave(state)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Route used for one summarization call: the session's own last request wins. */
|
|
428
|
+
function resolveRoute(session) {
|
|
429
|
+
if (config.provider && config.model) return { provider: config.provider, model: config.model }
|
|
430
|
+
try {
|
|
431
|
+
const context = session.requestContext?.()
|
|
432
|
+
if (context?.provider && context?.model) return { provider: context.provider, model: context.model }
|
|
433
|
+
} catch {
|
|
434
|
+
// fall through
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const headerConfig = session.requestHeader?.()?.config
|
|
438
|
+
if (headerConfig?.provider && headerConfig?.model) return { provider: headerConfig.provider, model: headerConfig.model }
|
|
439
|
+
} catch {
|
|
440
|
+
// fall through
|
|
441
|
+
}
|
|
442
|
+
return null
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Stream one auxiliary completion and return its text plus terminal facts. */
|
|
446
|
+
async function streamCompletion(route, system, userText, sessionId) {
|
|
447
|
+
if (!llm) throw new Error('llm service is not available')
|
|
448
|
+
const controller = new AbortController()
|
|
449
|
+
const timer = setTimeout(() => controller.abort(new Error('taskflow: summarization timed out')), config.timeoutMs)
|
|
450
|
+
let text = ''
|
|
451
|
+
let finish = null
|
|
452
|
+
let usage
|
|
453
|
+
try {
|
|
454
|
+
const options = {
|
|
455
|
+
provider: route.provider,
|
|
456
|
+
model: route.model,
|
|
457
|
+
system,
|
|
458
|
+
messages: [{
|
|
459
|
+
id: randomUUID(),
|
|
460
|
+
role: 'user',
|
|
461
|
+
source: { kind: 'plugin', plugin: 'dsh-plugin-taskflow' },
|
|
462
|
+
content: [{ type: 'text', text: userText }],
|
|
463
|
+
}],
|
|
464
|
+
maxTokens: config.maxOutputTokens,
|
|
465
|
+
sessionId,
|
|
466
|
+
signal: controller.signal,
|
|
467
|
+
}
|
|
468
|
+
for await (const chunk of llm.stream(options)) {
|
|
469
|
+
if (!chunk || typeof chunk !== 'object') continue
|
|
470
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
471
|
+
else if (chunk.type === 'finish') finish = chunk.reason ?? chunk
|
|
472
|
+
else if (chunk.type === 'usage') usage = chunk.usage
|
|
473
|
+
}
|
|
474
|
+
} finally {
|
|
475
|
+
clearTimeout(timer)
|
|
476
|
+
}
|
|
477
|
+
// A truncated answer is still an answer: reasoning tokens or a long opening
|
|
478
|
+
// line can eat the budget before the model writes its last bullet. Only an
|
|
479
|
+
// empty body counts as a failure.
|
|
480
|
+
if (finish && finish.kind !== 'stop' && finish.kind !== 'max-tokens') {
|
|
481
|
+
const reason = finish.kind === 'error' || finish.kind === 'aborted' ? (finish.failure?.message ?? finish.kind) : finish.kind
|
|
482
|
+
throw new Error(`taskflow: summarization finished with ${reason}`)
|
|
483
|
+
}
|
|
484
|
+
const trimmed = text.trim()
|
|
485
|
+
if (trimmed.length === 0) throw new Error(`taskflow: summarization produced no text (${finish?.kind ?? 'unknown finish'})`)
|
|
486
|
+
return { text: trimmed, usage, truncated: finish?.kind === 'max-tokens' }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function pump() {
|
|
490
|
+
while (running < Math.max(1, config.maxConcurrent) && queue.length > 0) {
|
|
491
|
+
const task = queue.shift()
|
|
492
|
+
running += 1
|
|
493
|
+
Promise.resolve()
|
|
494
|
+
.then(task)
|
|
495
|
+
.catch((error) => log(ctx, 'warn', `[taskflow] summarization failed: ${error?.message ?? error}`))
|
|
496
|
+
.finally(() => {
|
|
497
|
+
running -= 1
|
|
498
|
+
pump()
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Summarize one completed turn and publish the segment. */
|
|
504
|
+
function summarizeTurn(session, state, input) {
|
|
505
|
+
const key = `${session.id}:${input.turn}`
|
|
506
|
+
if (pending.has(key)) return Promise.resolve({ queued: false, reason: 'already running' })
|
|
507
|
+
pending.add(key)
|
|
508
|
+
let settle
|
|
509
|
+
const done = new Promise((resolve) => {
|
|
510
|
+
settle = resolve
|
|
511
|
+
})
|
|
512
|
+
queue.push(async () => {
|
|
513
|
+
const outcome = { queued: true, turn: input.turn }
|
|
514
|
+
try {
|
|
515
|
+
const route = resolveRoute(session)
|
|
516
|
+
let parsed = null
|
|
517
|
+
let usedRoute = null
|
|
518
|
+
let usage
|
|
519
|
+
let summaryMs = 0
|
|
520
|
+
let truncated = false
|
|
521
|
+
let failure = null
|
|
522
|
+
if (!config.enabled) failure = 'summarization disabled by config'
|
|
523
|
+
else if (!route) failure = 'no provider/model route: the session has no request context and no config override'
|
|
524
|
+
else {
|
|
525
|
+
try {
|
|
526
|
+
const callStarted = Date.now()
|
|
527
|
+
const result = await streamCompletion(route, SYSTEM_PROMPT, input.digest, session.id)
|
|
528
|
+
summaryMs = Date.now() - callStarted
|
|
529
|
+
truncated = result.truncated === true
|
|
530
|
+
parsed = parseSummary(result.text)
|
|
531
|
+
if (!parsed) failure = 'model returned no usable text'
|
|
532
|
+
else {
|
|
533
|
+
usedRoute = route
|
|
534
|
+
usage = result.usage
|
|
535
|
+
}
|
|
536
|
+
} catch (error) {
|
|
537
|
+
failure = String(error?.message ?? error)
|
|
538
|
+
log(ctx, 'warn', `[taskflow] model summary fell back to mechanical text: ${failure}`)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
outcome.route = route
|
|
542
|
+
outcome.failure = failure
|
|
543
|
+
if (parsed && parsed.title.includes('无实质产出')) {
|
|
544
|
+
markSkipped(state, input.turn)
|
|
545
|
+
scheduleSave(state)
|
|
546
|
+
outcome.skipped = true
|
|
547
|
+
settle(outcome)
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
if (!parsed) parsed = mechanicalSummary(input)
|
|
551
|
+
const segment = {
|
|
552
|
+
turn: input.turn,
|
|
553
|
+
at: input.endedAt,
|
|
554
|
+
startedAt: input.startedAt,
|
|
555
|
+
durationMs: Math.max(0, input.endedAt - input.startedAt),
|
|
556
|
+
title: parsed.title,
|
|
557
|
+
bullets: parsed.bullets,
|
|
558
|
+
toolCalls: input.toolCalls,
|
|
559
|
+
files: input.files,
|
|
560
|
+
commands: input.commands,
|
|
561
|
+
provider: usedRoute?.provider ?? null,
|
|
562
|
+
model: usedRoute?.model ?? null,
|
|
563
|
+
usage: usage ?? null,
|
|
564
|
+
summaryMs,
|
|
565
|
+
truncated: truncated || undefined,
|
|
566
|
+
mechanical: usedRoute === null,
|
|
567
|
+
failure,
|
|
568
|
+
}
|
|
569
|
+
state.skipped = state.skipped.filter((turn) => turn !== input.turn)
|
|
570
|
+
state.segments = state.segments.filter((item) => item.turn !== input.turn)
|
|
571
|
+
state.segments.push(segment)
|
|
572
|
+
state.segments.sort((a, b) => a.turn - b.turn)
|
|
573
|
+
if (state.segments.length > Math.max(1, config.maxSegments)) state.segments.splice(0, state.segments.length - config.maxSegments)
|
|
574
|
+
state.lastSummary = { turn: input.turn, at: Date.now(), route: route ?? null, failure, mechanical: segment.mechanical }
|
|
575
|
+
scheduleSave(state)
|
|
576
|
+
broadcast(state.sessionId)
|
|
577
|
+
outcome.segment = segment
|
|
578
|
+
settle(outcome)
|
|
579
|
+
} catch (error) {
|
|
580
|
+
outcome.failure = String(error?.message ?? error)
|
|
581
|
+
settle(outcome)
|
|
582
|
+
} finally {
|
|
583
|
+
pending.delete(key)
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
pump()
|
|
587
|
+
return done
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Generate one task's own one-line summary from what happened while it ran. */
|
|
591
|
+
function queueTaskSummary(session, state, content) {
|
|
592
|
+
const key = `${session.id}:${content}`
|
|
593
|
+
if (taskPending.has(key)) return
|
|
594
|
+
const item = state.tasks.items[content]
|
|
595
|
+
if (!item || item.summary) return
|
|
596
|
+
taskPending.add(key)
|
|
597
|
+
item.summaryPending = true
|
|
598
|
+
taskQueue.push(async () => {
|
|
599
|
+
try {
|
|
600
|
+
const current = state.tasks.items[content]
|
|
601
|
+
if (!current || current.summary) return
|
|
602
|
+
const route = resolveRoute(session)
|
|
603
|
+
const steps = (current.steps || []).slice(-10)
|
|
604
|
+
const digest = [
|
|
605
|
+
`任务:${content}`,
|
|
606
|
+
current.startedAt && current.completedAt ? `耗时:${Math.round((current.completedAt - current.startedAt) / 1000)} 秒` : '',
|
|
607
|
+
steps.length > 0 ? `动作:\n${steps.map((step) => `- ${step.n}: ${step.a || ''}`).join('\n')}` : '动作:(无工具调用记录)',
|
|
608
|
+
current.files && current.files.length > 0 ? `文件:${current.files.join('、')}` : '',
|
|
609
|
+
].filter((line) => line.length > 0).join('\n')
|
|
610
|
+
let text = ''
|
|
611
|
+
if (config.enabled && route) {
|
|
612
|
+
try {
|
|
613
|
+
const result = await streamCompletion(route, TASK_PROMPT, clip(digest, 4000), session.id)
|
|
614
|
+
text = result.text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0)[0] || ''
|
|
615
|
+
} catch (error) {
|
|
616
|
+
log(ctx, 'warn', `[taskflow] task summary fell back to mechanical: ${error?.message ?? error}`)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (text.length === 0 || text.includes('无有效动作')) {
|
|
620
|
+
// Nothing worth calling a summary: leave it without one, so the UI keeps
|
|
621
|
+
// the row non-clickable instead of opening an empty panel.
|
|
622
|
+
delete current.summaryPending
|
|
623
|
+
scheduleSave(state)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
current.summary = clip(text, 160)
|
|
627
|
+
current.summaryAt = Date.now()
|
|
628
|
+
delete current.summaryPending
|
|
629
|
+
scheduleSave(state)
|
|
630
|
+
broadcast(state.sessionId)
|
|
631
|
+
} finally {
|
|
632
|
+
taskPending.delete(key)
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
pumpTaskQueue()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function pumpTaskQueue() {
|
|
639
|
+
if (taskRunning >= 1 || taskQueue.length === 0) return
|
|
640
|
+
const run = taskQueue.shift()
|
|
641
|
+
taskRunning += 1
|
|
642
|
+
Promise.resolve()
|
|
643
|
+
.then(run)
|
|
644
|
+
.catch((error) => log(ctx, 'warn', `[taskflow] task summary failed: ${error?.message ?? error}`))
|
|
645
|
+
.finally(() => {
|
|
646
|
+
taskRunning -= 1
|
|
647
|
+
pumpTaskQueue()
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Recover the open turn after a mid-turn mount or restart: the last boundary wins. */
|
|
652
|
+
function deriveOpenTurn(session) {
|
|
653
|
+
try {
|
|
654
|
+
const events = session.snapshotEvents ? session.snapshotEvents() : []
|
|
655
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
656
|
+
const event = events[index]
|
|
657
|
+
if (event.type === 'turn/end') return null
|
|
658
|
+
if (event.type === 'turn/start') return typeof event.data?.turn === 'number' ? event.data.turn : null
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
// treat as unknown
|
|
662
|
+
}
|
|
663
|
+
return undefined
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Whether a session is a delegated child whose turns should not be summarized. */
|
|
667
|
+
function isSubagentSession(session) {
|
|
668
|
+
const meta = session.meta
|
|
669
|
+
if (!meta || typeof meta !== 'object') return false
|
|
670
|
+
return meta.origin === 'subagent' || (typeof meta.delegationDepth === 'number' && meta.delegationDepth > 0)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Remember which subagent sessions exist and whether each is working right now. */
|
|
674
|
+
function noteChildSession(session, event) {
|
|
675
|
+
const header = session?.header ?? {}
|
|
676
|
+
const meta = session?.meta ?? {}
|
|
677
|
+
// The spawn metadata lands on the session header, not on `meta`.
|
|
678
|
+
const origin = meta.origin ?? header.origin
|
|
679
|
+
const parentId = meta.parentSession ?? header.parentSession
|
|
680
|
+
if (session && typeof session.id === 'string') {
|
|
681
|
+
const known = seenSessions.get(session.id)
|
|
682
|
+
const current = {
|
|
683
|
+
origin: origin ?? null,
|
|
684
|
+
parent: parentId ?? null,
|
|
685
|
+
turn: known?.turn ?? false,
|
|
686
|
+
}
|
|
687
|
+
if (event.type === 'turn/start') current.turn = true
|
|
688
|
+
else if (event.type === 'turn/end') current.turn = false
|
|
689
|
+
seenSessions.set(session.id, current)
|
|
690
|
+
}
|
|
691
|
+
if (origin !== 'subagent' || typeof parentId !== 'string' || parentId.length === 0) return
|
|
692
|
+
let entry = childSessions.get(session.id)
|
|
693
|
+
if (!entry) {
|
|
694
|
+
entry = { parentId, inTurn: false }
|
|
695
|
+
childSessions.set(session.id, entry)
|
|
696
|
+
}
|
|
697
|
+
entry.parentId = parentId
|
|
698
|
+
if (event.type === 'turn/start') entry.inTurn = true
|
|
699
|
+
else if (event.type === 'turn/end') entry.inTurn = false
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function handleEvent(session, event) {
|
|
703
|
+
noteChildSession(session, event)
|
|
704
|
+
const state = stateOf(session.id)
|
|
705
|
+
const turn = typeof event.data?.turn === 'number' ? event.data.turn : null
|
|
706
|
+
switch (event.type) {
|
|
707
|
+
case 'turn/start': {
|
|
708
|
+
state.open = { turn, seq: event.seq, time: event.time }
|
|
709
|
+
state.openTurn = turn
|
|
710
|
+
// Work resumes: clear any "waiting for the human" stamp.
|
|
711
|
+
for (const item of Object.values(state.tasks.items)) {
|
|
712
|
+
if (item.pausedAt && item.status === 'in_progress') delete item.pausedAt
|
|
713
|
+
}
|
|
714
|
+
if (turn !== null) state.turns[turn] = { startSeq: event.seq, endedAt: null }
|
|
715
|
+
break
|
|
716
|
+
}
|
|
717
|
+
case 'turn/end': {
|
|
718
|
+
const open = state.open && (turn === null || state.open.turn === turn) ? state.open : null
|
|
719
|
+
state.open = null
|
|
720
|
+
state.openTurn = null
|
|
721
|
+
// The turn ended while something was still in progress: nobody is working
|
|
722
|
+
// on it now, so freeze it as "waiting for confirmation".
|
|
723
|
+
for (const item of Object.values(state.tasks.items)) {
|
|
724
|
+
if (item.status === 'in_progress' && !item.pausedAt) item.pausedAt = event.time
|
|
725
|
+
}
|
|
726
|
+
const opening = locateTurnStart(session, state, turn, open)
|
|
727
|
+
if (turn !== null) {
|
|
728
|
+
state.turns[turn] = { startSeq: opening.seq ?? state.turns[turn]?.startSeq ?? event.seq, endedAt: event.time }
|
|
729
|
+
state.lastTurn = Math.max(state.lastTurn, turn)
|
|
730
|
+
}
|
|
731
|
+
// An interrupted or failed turn has no story worth spending a model call
|
|
732
|
+
// on: it usually contains half a tool call and no conclusion.
|
|
733
|
+
const reasonKind = event.data?.reason?.kind
|
|
734
|
+
if (reasonKind === 'aborted' || reasonKind === 'error') {
|
|
735
|
+
markSkipped(state, turn)
|
|
736
|
+
scheduleSave(state)
|
|
737
|
+
break
|
|
738
|
+
}
|
|
739
|
+
let events = []
|
|
740
|
+
try {
|
|
741
|
+
events = session.snapshotEvents(opening.seq ?? Math.max(0, event.seq - 400), event.seq + 1)
|
|
742
|
+
} catch {
|
|
743
|
+
events = []
|
|
744
|
+
}
|
|
745
|
+
const digest = buildDigest(events, session.meta?.cwd)
|
|
746
|
+
if (turn === null || digest.digest.length === 0) break
|
|
747
|
+
const substantial = digest.toolCalls >= Math.max(0, config.minToolCalls)
|
|
748
|
+
|| digest.assistantChars >= Math.max(0, config.minAssistantChars)
|
|
749
|
+
if (!substantial) {
|
|
750
|
+
markSkipped(state, turn)
|
|
751
|
+
scheduleSave(state)
|
|
752
|
+
break
|
|
753
|
+
}
|
|
754
|
+
const existing = state.segments.find((item) => item.turn === turn)
|
|
755
|
+
if (existing) break
|
|
756
|
+
if (!config.includeSubagents && isSubagentSession(session)) {
|
|
757
|
+
markSkipped(state, turn)
|
|
758
|
+
scheduleSave(state)
|
|
759
|
+
break
|
|
760
|
+
}
|
|
761
|
+
pruneTurns(state)
|
|
762
|
+
summarizeTurn(session, state, {
|
|
763
|
+
turn,
|
|
764
|
+
startedAt: opening.time ?? event.time,
|
|
765
|
+
endedAt: event.time,
|
|
766
|
+
digest: clip(digest.digest, Math.max(1000, config.maxDigestChars)),
|
|
767
|
+
toolCalls: digest.toolCalls,
|
|
768
|
+
files: digest.files,
|
|
769
|
+
commands: digest.commands,
|
|
770
|
+
digestParts: {
|
|
771
|
+
userText: firstUserText(events),
|
|
772
|
+
firstTool: digest.toolCalls > 0 ? '工具调用' : '',
|
|
773
|
+
toolCalls: digest.toolCalls,
|
|
774
|
+
toolNames: toolNamesOf(events),
|
|
775
|
+
files: digest.files,
|
|
776
|
+
commands: digest.commands,
|
|
777
|
+
assistantTail: lastAssistantText(events),
|
|
778
|
+
},
|
|
779
|
+
})
|
|
780
|
+
break
|
|
781
|
+
}
|
|
782
|
+
case 'tool/call': {
|
|
783
|
+
// Attribute the work to whatever task was in progress at that moment, so
|
|
784
|
+
// the detail view can say what a task actually involved — not just its
|
|
785
|
+
// status and duration.
|
|
786
|
+
const active = state.currentTask
|
|
787
|
+
const item = active ? state.tasks.items[active] : undefined
|
|
788
|
+
const bookkeeping = event.data?.name === 'todo_write' || event.data?.name === 'present'
|
|
789
|
+
if (item && !bookkeeping) {
|
|
790
|
+
const toolName = String(event.data?.name ?? 'tool')
|
|
791
|
+
const args = event.data?.arguments
|
|
792
|
+
item.steps = [...(item.steps || []), {
|
|
793
|
+
n: toolName,
|
|
794
|
+
a: describeArgs(toolName, args),
|
|
795
|
+
at: event.time,
|
|
796
|
+
}].slice(-12)
|
|
797
|
+
const touched = pathsOf(args)
|
|
798
|
+
if (touched.length > 0) item.files = [...new Set([...(item.files || []), ...touched])].slice(0, 10)
|
|
799
|
+
scheduleSave(state)
|
|
800
|
+
}
|
|
801
|
+
break
|
|
802
|
+
}
|
|
803
|
+
case 'todo/write': {
|
|
804
|
+
recordTodos(session, state, event, turn ?? state.open?.turn ?? null)
|
|
805
|
+
break
|
|
806
|
+
}
|
|
807
|
+
default:
|
|
808
|
+
break
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
ctx.on('session/event', (session, event) => {
|
|
813
|
+
try {
|
|
814
|
+
handleEvent(session, event)
|
|
815
|
+
if (event.type === 'todo/write' || event.type === 'turn/start' || event.type === 'turn/end') broadcast(session.id)
|
|
816
|
+
const child = childSessions.get(session.id)
|
|
817
|
+
if (child && (event.type === 'turn/start' || event.type === 'turn/end')) broadcast(child.parentId)
|
|
818
|
+
} catch (error) {
|
|
819
|
+
log(ctx, 'warn', `[taskflow] event handler failed on ${event?.type}: ${error?.message ?? error}`)
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
// The model route is optional: without it the plugin still records turns and
|
|
824
|
+
// publishes mechanical summaries.
|
|
825
|
+
ctx.inject(['llm'], (host) => {
|
|
826
|
+
llm = host.llm
|
|
827
|
+
return () => {
|
|
828
|
+
llm = null
|
|
829
|
+
}
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
/** Build the JSON payload the browser polls. */
|
|
833
|
+
/** Tasks without their heavy step/file history: fetched on demand instead. */
|
|
834
|
+
function lightTasks(state) {
|
|
835
|
+
const items = {}
|
|
836
|
+
for (const [content, item] of Object.entries(state.tasks.items)) {
|
|
837
|
+
const { steps, files, ...rest } = item
|
|
838
|
+
items[content] = { ...rest, stepCount: Array.isArray(steps) ? steps.length : 0, fileCount: Array.isArray(files) ? files.length : 0 }
|
|
839
|
+
}
|
|
840
|
+
return { items, updatedAt: state.tasks.updatedAt }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Compact state frame. History and full segment/step bodies are omitted: the
|
|
845
|
+
* client asks for them only when a surface actually shows them, which keeps
|
|
846
|
+
* the push frame small enough to send on every change.
|
|
847
|
+
*/
|
|
848
|
+
function payload(state) {
|
|
849
|
+
const latest = state.segments.length > 0 ? state.segments[state.segments.length - 1] : null
|
|
850
|
+
return {
|
|
851
|
+
ok: true,
|
|
852
|
+
version: VERSION,
|
|
853
|
+
sessionId: state.sessionId,
|
|
854
|
+
enabled: config.enabled,
|
|
855
|
+
latestSegment: latest,
|
|
856
|
+
segmentCount: state.segments.length,
|
|
857
|
+
tasks: lightTasks(state),
|
|
858
|
+
currentList: Array.isArray(state.currentList) ? state.currentList : null,
|
|
859
|
+
currentListTurn: typeof state.currentListTurn === 'number' ? state.currentListTurn : null,
|
|
860
|
+
lastTurn: state.lastTurn,
|
|
861
|
+
openTurn: typeof state.openTurn === 'number' ? state.openTurn : null,
|
|
862
|
+
delegates: [...childSessions.values()].filter((child) => child.parentId === state.sessionId && child.inTurn).length,
|
|
863
|
+
inTurn: state.openTurn === undefined ? null : state.openTurn !== null,
|
|
864
|
+
skipped: state.skipped,
|
|
865
|
+
pending: [...pending].some((key) => key.startsWith(`${state.sessionId}:`)),
|
|
866
|
+
lastSummary: state.lastSummary ?? null,
|
|
867
|
+
updatedAt: state.updatedAt,
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function sendJson(response, status, body) {
|
|
872
|
+
const text = JSON.stringify(body)
|
|
873
|
+
response.writeHead(status, {
|
|
874
|
+
'content-type': 'application/json; charset=utf-8',
|
|
875
|
+
'cache-control': 'no-store',
|
|
876
|
+
'content-length': Buffer.byteLength(text),
|
|
877
|
+
})
|
|
878
|
+
response.end(text)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function readBody(request) {
|
|
882
|
+
return new Promise((resolve, reject) => {
|
|
883
|
+
let size = 0
|
|
884
|
+
const chunks = []
|
|
885
|
+
request.on('data', (chunk) => {
|
|
886
|
+
size += chunk.length
|
|
887
|
+
if (size > 64 * 1024) {
|
|
888
|
+
reject(new Error('body too large'))
|
|
889
|
+
request.destroy()
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
chunks.push(chunk)
|
|
893
|
+
})
|
|
894
|
+
request.on('end', () => {
|
|
895
|
+
try {
|
|
896
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
897
|
+
} catch (error) {
|
|
898
|
+
reject(error)
|
|
899
|
+
}
|
|
900
|
+
})
|
|
901
|
+
request.on('error', reject)
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
ctx.inject(['webServer'], (host) => {
|
|
906
|
+
host.effect(() => {
|
|
907
|
+
const disposers = [
|
|
908
|
+
host.webServer.register({
|
|
909
|
+
kind: 'exact',
|
|
910
|
+
path: '/taskflow/state',
|
|
911
|
+
handler: (request, response) => {
|
|
912
|
+
stateHits += 1
|
|
913
|
+
lastStateAt = Date.now()
|
|
914
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
915
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
916
|
+
if (sessionId.length === 0) {
|
|
917
|
+
sendJson(response, 400, { ok: false, error: 'missing session' })
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
const state = stateOf(sessionId)
|
|
921
|
+
// Always re-derive from the live log: a missed turn/end (plugin
|
|
922
|
+
// reload mid-turn) must not leave a finished task looking active.
|
|
923
|
+
const liveSession = typeof ctx.get === 'function' ? ctx.get('sessions')?.get?.(sessionId) : undefined
|
|
924
|
+
if (liveSession) {
|
|
925
|
+
const derived = deriveOpenTurn(liveSession)
|
|
926
|
+
if (derived !== undefined) state.openTurn = derived
|
|
927
|
+
}
|
|
928
|
+
sendJson(response, 200, payload(state))
|
|
929
|
+
},
|
|
930
|
+
}),
|
|
931
|
+
host.webServer.register({
|
|
932
|
+
kind: 'exact',
|
|
933
|
+
path: '/taskflow/summarize',
|
|
934
|
+
handler: async (request, response) => {
|
|
935
|
+
if (request.method !== 'POST') {
|
|
936
|
+
sendJson(response, 405, { ok: false, error: 'method not allowed' })
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
summaryRequests += 1
|
|
940
|
+
let body = {}
|
|
941
|
+
try {
|
|
942
|
+
body = await readBody(request)
|
|
943
|
+
} catch (error) {
|
|
944
|
+
sendJson(response, 400, { ok: false, error: String(error?.message ?? error) })
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
const sessionId = typeof body?.session === 'string' ? body.session : ''
|
|
948
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
949
|
+
const session = sessionId.length > 0 ? sessions?.get?.(sessionId) : undefined
|
|
950
|
+
if (!session) {
|
|
951
|
+
sendJson(response, 404, { ok: false, error: 'session is not live' })
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
const state = stateOf(sessionId)
|
|
956
|
+
const force = body?.force === true
|
|
957
|
+
const run = summarizeLatestTurn(session, state, body?.turn, force)
|
|
958
|
+
if (!run) {
|
|
959
|
+
sendJson(response, 200, { ok: true, queued: false, reason: 'nothing to summarize (already summarized or no completed turn)' })
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
const outcome = await run
|
|
963
|
+
sendJson(response, 200, { ok: true, queued: true, ...outcome })
|
|
964
|
+
} catch (error) {
|
|
965
|
+
sendJson(response, 500, { ok: false, error: String(error?.message ?? error) })
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
}),
|
|
969
|
+
host.webServer.register({
|
|
970
|
+
kind: 'exact',
|
|
971
|
+
path: '/taskflow/health',
|
|
972
|
+
handler: (_request, response) => {
|
|
973
|
+
sendJson(response, 200, {
|
|
974
|
+
ok: true,
|
|
975
|
+
version: VERSION,
|
|
976
|
+
sessions: states.size,
|
|
977
|
+
queue: queue.length,
|
|
978
|
+
client: clientHello,
|
|
979
|
+
stateHits,
|
|
980
|
+
lastStateAgoMs: lastStateAt === 0 ? null : Date.now() - lastStateAt,
|
|
981
|
+
summaryRequests,
|
|
982
|
+
renders,
|
|
983
|
+
streams: streams.size,
|
|
984
|
+
streamSessions: [...streams.keys()].map((id) => id.slice(-8)),
|
|
985
|
+
policy: policyRegistered,
|
|
986
|
+
seen: [...seenSessions.entries()].slice(-6).map(([id, info]) => ({ id: id.slice(-8), ...info })),
|
|
987
|
+
parallelPolicy: parallelPolicyRegistered,
|
|
988
|
+
})
|
|
989
|
+
},
|
|
990
|
+
}),
|
|
991
|
+
// Full segment list, fetched only when the sidebar is open.
|
|
992
|
+
host.webServer.register({
|
|
993
|
+
kind: 'exact',
|
|
994
|
+
path: '/taskflow/segments',
|
|
995
|
+
handler: (request, response) => {
|
|
996
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
997
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
998
|
+
if (!sessionId) {
|
|
999
|
+
sendJson(response, 400, { ok: false, error: 'missing session' })
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
const state = stateOf(sessionId)
|
|
1003
|
+
sendJson(response, 200, { ok: true, sessionId, segments: state.segments })
|
|
1004
|
+
},
|
|
1005
|
+
}),
|
|
1006
|
+
// One task's detail (steps, files, summary), fetched when a row is clicked.
|
|
1007
|
+
host.webServer.register({
|
|
1008
|
+
kind: 'exact',
|
|
1009
|
+
path: '/taskflow/task',
|
|
1010
|
+
handler: (request, response) => {
|
|
1011
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
1012
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
1013
|
+
const content = url.searchParams.get('content') ?? ''
|
|
1014
|
+
const item = stateOf(sessionId).tasks.items[content]
|
|
1015
|
+
if (!item) {
|
|
1016
|
+
sendJson(response, 404, { ok: false, error: 'task not found' })
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
sendJson(response, 200, { ok: true, item })
|
|
1020
|
+
},
|
|
1021
|
+
}),
|
|
1022
|
+
// Live push: one long-lived connection per open session. Falls back to
|
|
1023
|
+
// polling on the client whenever this stream cannot be established.
|
|
1024
|
+
host.webServer.register({
|
|
1025
|
+
kind: 'exact',
|
|
1026
|
+
path: '/taskflow/events',
|
|
1027
|
+
handler: (request, response) => {
|
|
1028
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
1029
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
1030
|
+
if (sessionId.length === 0) {
|
|
1031
|
+
sendJson(response, 400, { ok: false, error: 'missing session' })
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
response.writeHead(200, {
|
|
1035
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1036
|
+
'cache-control': 'no-cache, no-transform',
|
|
1037
|
+
connection: 'keep-alive',
|
|
1038
|
+
'x-accel-buffering': 'no',
|
|
1039
|
+
})
|
|
1040
|
+
response.write(': connected\n\n')
|
|
1041
|
+
const state = stateOf(sessionId)
|
|
1042
|
+
const live = typeof ctx.get === 'function' ? ctx.get('sessions')?.get?.(sessionId) : undefined
|
|
1043
|
+
if (live) {
|
|
1044
|
+
const derived = deriveOpenTurn(live)
|
|
1045
|
+
if (derived !== undefined) state.openTurn = derived
|
|
1046
|
+
}
|
|
1047
|
+
pushFrame(response, state)
|
|
1048
|
+
let set = streams.get(sessionId)
|
|
1049
|
+
if (!set) {
|
|
1050
|
+
set = new Set()
|
|
1051
|
+
streams.set(sessionId, set)
|
|
1052
|
+
}
|
|
1053
|
+
if (set.size >= 4) {
|
|
1054
|
+
response.write('event: error\ndata: {"reason":"too many listeners"}\n\n')
|
|
1055
|
+
response.end()
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
set.add(response)
|
|
1059
|
+
const heartbeat = setInterval(() => {
|
|
1060
|
+
try {
|
|
1061
|
+
response.write(': ping\n\n')
|
|
1062
|
+
} catch {
|
|
1063
|
+
// closed socket; the close handler cleans up
|
|
1064
|
+
}
|
|
1065
|
+
}, 20000)
|
|
1066
|
+
const cleanup = () => {
|
|
1067
|
+
clearInterval(heartbeat)
|
|
1068
|
+
set.delete(response)
|
|
1069
|
+
if (set.size === 0) streams.delete(sessionId)
|
|
1070
|
+
}
|
|
1071
|
+
request.on('close', cleanup)
|
|
1072
|
+
response.on('close', cleanup)
|
|
1073
|
+
},
|
|
1074
|
+
}),
|
|
1075
|
+
// Handshake from the browser half: proves the bundle executed in the
|
|
1076
|
+
// client and tells us which build is live.
|
|
1077
|
+
host.webServer.register({
|
|
1078
|
+
kind: 'exact',
|
|
1079
|
+
path: '/taskflow/client-ready',
|
|
1080
|
+
handler: async (request, response) => {
|
|
1081
|
+
if (request.method !== 'POST') {
|
|
1082
|
+
sendJson(response, 405, { ok: false, error: 'method not allowed' })
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
let body = {}
|
|
1086
|
+
try {
|
|
1087
|
+
body = await readBody(request)
|
|
1088
|
+
} catch {
|
|
1089
|
+
body = {}
|
|
1090
|
+
}
|
|
1091
|
+
const build = typeof body?.build === 'string' ? body.build : 'unknown'
|
|
1092
|
+
// A new browser build invalidates every geometry report from the old one.
|
|
1093
|
+
if (!clientHello || clientHello.build !== build) {
|
|
1094
|
+
for (const key of Object.keys(renders)) delete renders[key]
|
|
1095
|
+
}
|
|
1096
|
+
clientHello = {
|
|
1097
|
+
at: Date.now(),
|
|
1098
|
+
build,
|
|
1099
|
+
href: typeof body?.href === 'string' ? clip(body.href, 200) : '',
|
|
1100
|
+
ua: typeof body?.ua === 'string' ? clip(body.ua, 200) : '',
|
|
1101
|
+
}
|
|
1102
|
+
log(ctx, 'info', `[taskflow] client half connected (build=${clientHello.build})`)
|
|
1103
|
+
sendJson(response, 200, { ok: true, version: VERSION })
|
|
1104
|
+
},
|
|
1105
|
+
}),
|
|
1106
|
+
// Geometry handshake: the browser half measures what it actually
|
|
1107
|
+
// painted, so "is the UI there?" has an answer without a screenshot.
|
|
1108
|
+
host.webServer.register({
|
|
1109
|
+
kind: 'exact',
|
|
1110
|
+
path: '/taskflow/client-report',
|
|
1111
|
+
handler: async (request, response) => {
|
|
1112
|
+
if (request.method !== 'POST') {
|
|
1113
|
+
sendJson(response, 405, { ok: false, error: 'method not allowed' })
|
|
1114
|
+
return
|
|
1115
|
+
}
|
|
1116
|
+
let body = {}
|
|
1117
|
+
try {
|
|
1118
|
+
body = await readBody(request)
|
|
1119
|
+
} catch {
|
|
1120
|
+
body = {}
|
|
1121
|
+
}
|
|
1122
|
+
const part = typeof body?.part === 'string' ? clip(body.part, 40) : 'unknown'
|
|
1123
|
+
renders[part] = {
|
|
1124
|
+
at: Date.now(),
|
|
1125
|
+
build: typeof body?.build === 'string' ? body.build : 'unknown',
|
|
1126
|
+
mounted: body?.mounted === true,
|
|
1127
|
+
visible: body?.visible === true,
|
|
1128
|
+
width: typeof body?.width === 'number' ? Math.round(body.width) : 0,
|
|
1129
|
+
height: typeof body?.height === 'number' ? Math.round(body.height) : 0,
|
|
1130
|
+
detail: typeof body?.detail === 'string' ? clip(body.detail, 2000) : '',
|
|
1131
|
+
}
|
|
1132
|
+
sendJson(response, 200, { ok: true })
|
|
1133
|
+
},
|
|
1134
|
+
}),
|
|
1135
|
+
// End-to-end prompt check: does the assembled system prompt really carry
|
|
1136
|
+
// our policy? Reads the session log instead of guessing.
|
|
1137
|
+
host.webServer.register({
|
|
1138
|
+
kind: 'exact',
|
|
1139
|
+
path: '/taskflow/prompt-check',
|
|
1140
|
+
handler: (request, response) => {
|
|
1141
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
1142
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
1143
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
1144
|
+
const session = sessionId.length > 0 ? sessions?.get?.(sessionId) : undefined
|
|
1145
|
+
if (!session) {
|
|
1146
|
+
sendJson(response, 404, { ok: false, error: 'session is not live' })
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
let last = ''
|
|
1150
|
+
let count = 0
|
|
1151
|
+
try {
|
|
1152
|
+
for (const event of session.snapshotEvents()) {
|
|
1153
|
+
if (event.type !== 'system/message') continue
|
|
1154
|
+
const text = textOf(event.data?.message) || textOf(event.data)
|
|
1155
|
+
if (text.length > 0) {
|
|
1156
|
+
last = text
|
|
1157
|
+
count += 1
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
sendJson(response, 500, { ok: false, error: String(error?.message ?? error) })
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
const marker = '任务清单规范'
|
|
1165
|
+
const parallel = '复杂任务:并行拆解'
|
|
1166
|
+
const at = last.indexOf(marker)
|
|
1167
|
+
sendJson(response, 200, {
|
|
1168
|
+
ok: true,
|
|
1169
|
+
systemMessages: count,
|
|
1170
|
+
promptChars: last.length,
|
|
1171
|
+
hasTaskPolicy: at >= 0,
|
|
1172
|
+
hasParallelPolicy: last.includes(parallel),
|
|
1173
|
+
excerpt: at >= 0 ? clip(last.slice(at, at + 120), 200) : clip(last.slice(-200), 200),
|
|
1174
|
+
})
|
|
1175
|
+
},
|
|
1176
|
+
}),
|
|
1177
|
+
// Deployment self-check: did the browser half get discovered, and which
|
|
1178
|
+
// revisioned bundle URL is the shell expected to load?
|
|
1179
|
+
host.webServer.register({
|
|
1180
|
+
kind: 'exact',
|
|
1181
|
+
path: '/taskflow/diagnostics',
|
|
1182
|
+
handler: async (_request, response) => {
|
|
1183
|
+
const registry = typeof ctx.get === 'function' ? ctx.get('clientModules') : undefined
|
|
1184
|
+
let clientPath = null
|
|
1185
|
+
let entry = null
|
|
1186
|
+
try {
|
|
1187
|
+
clientPath = registry?.clientPath?.('dsh-plugin-taskflow') ?? null
|
|
1188
|
+
} catch {
|
|
1189
|
+
clientPath = null
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
const graph = registry?.graph?.()
|
|
1193
|
+
entry = graph?.entries?.find((row) => row.id === 'dsh-plugin-taskflow') ?? null
|
|
1194
|
+
} catch {
|
|
1195
|
+
entry = null
|
|
1196
|
+
}
|
|
1197
|
+
const url = new URL(_request.url ?? '/', 'http://localhost')
|
|
1198
|
+
if (url.searchParams.get('llm') !== '1') {
|
|
1199
|
+
sendJson(response, 200, { ok: true, version: VERSION, clientPath, entry })
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
// Opt-in live check: one tiny call proves the profile can summarize.
|
|
1203
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
1204
|
+
const live = sessions?.list?.() ?? []
|
|
1205
|
+
const probe = live.find((candidate) => !isSubagentSession(candidate)) ?? live[0]
|
|
1206
|
+
const route = probe ? resolveRoute(probe) : null
|
|
1207
|
+
if (!llm || !route) {
|
|
1208
|
+
sendJson(response, 200, { ok: false, version: VERSION, reason: !llm ? 'llm service is not available' : 'no provider/model route', clientPath, entry })
|
|
1209
|
+
return
|
|
1210
|
+
}
|
|
1211
|
+
const started = Date.now()
|
|
1212
|
+
try {
|
|
1213
|
+
const result = await streamCompletion(route, '你只回复四个字:链路正常', '请回复链路正常四字', probe?.id)
|
|
1214
|
+
sendJson(response, 200, { ok: true, version: VERSION, route, text: result.text, ms: Date.now() - started, clientPath, entry })
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
sendJson(response, 200, { ok: false, version: VERSION, route, error: String(error?.message ?? error), ms: Date.now() - started, clientPath, entry })
|
|
1217
|
+
}
|
|
1218
|
+
},
|
|
1219
|
+
}),
|
|
1220
|
+
]
|
|
1221
|
+
return () => {
|
|
1222
|
+
for (const dispose of disposers) {
|
|
1223
|
+
try {
|
|
1224
|
+
dispose()
|
|
1225
|
+
} catch {
|
|
1226
|
+
// ignore
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}, 'taskflow: http routes')
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
/** Manual trigger: summarize the newest turn that has no segment yet. */
|
|
1234
|
+
function summarizeLatestTurn(session, state, requestedTurn, force = false) {
|
|
1235
|
+
const all = session.snapshotEvents?.() ?? []
|
|
1236
|
+
let startIndex = -1
|
|
1237
|
+
let endIndex = -1
|
|
1238
|
+
for (let index = all.length - 1; index >= 0; index -= 1) {
|
|
1239
|
+
const event = all[index]
|
|
1240
|
+
if (event.type === 'turn/end' && (requestedTurn === undefined || requestedTurn === null || event.data?.turn === requestedTurn)) {
|
|
1241
|
+
endIndex = index
|
|
1242
|
+
for (let start = index - 1; start >= 0; start -= 1) {
|
|
1243
|
+
if (all[start].type === 'turn/start' && all[start].data?.turn === event.data?.turn) {
|
|
1244
|
+
startIndex = start
|
|
1245
|
+
break
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
break
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (endIndex < 0 || startIndex < 0) return null
|
|
1252
|
+
const turn = all[endIndex].data?.turn
|
|
1253
|
+
if (state.segments.some((item) => item.turn === turn)) {
|
|
1254
|
+
if (!force) return null
|
|
1255
|
+
state.segments = state.segments.filter((item) => item.turn !== turn)
|
|
1256
|
+
}
|
|
1257
|
+
const events = all.slice(startIndex, endIndex + 1)
|
|
1258
|
+
const digest = buildDigest(events, session.meta?.cwd)
|
|
1259
|
+
if (digest.digest.length === 0) return null
|
|
1260
|
+
return summarizeTurn(session, state, {
|
|
1261
|
+
turn,
|
|
1262
|
+
startedAt: all[startIndex].time,
|
|
1263
|
+
endedAt: all[endIndex].time,
|
|
1264
|
+
digest: clip(digest.digest, Math.max(1000, config.maxDigestChars)),
|
|
1265
|
+
toolCalls: digest.toolCalls,
|
|
1266
|
+
files: digest.files,
|
|
1267
|
+
commands: digest.commands,
|
|
1268
|
+
digestParts: {
|
|
1269
|
+
userText: firstUserText(events),
|
|
1270
|
+
toolCalls: digest.toolCalls,
|
|
1271
|
+
toolNames: toolNamesOf(events),
|
|
1272
|
+
files: digest.files,
|
|
1273
|
+
commands: digest.commands,
|
|
1274
|
+
assistantTail: lastAssistantText(events),
|
|
1275
|
+
},
|
|
1276
|
+
})
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Complex work decomposes into parallel tracks; absorbed from the SDK
|
|
1280
|
+
// workflows/parallel-agents SOP, compressed to what the model can act on.
|
|
1281
|
+
if (config.parallelPolicy) {
|
|
1282
|
+
ctx.inject(['systemPrompt'], (host) => {
|
|
1283
|
+
host.effect(() => host.systemPrompt.section({
|
|
1284
|
+
name: 'taskflow:parallel-policy',
|
|
1285
|
+
order: 9010,
|
|
1286
|
+
text: [
|
|
1287
|
+
'## 复杂任务:并行拆解',
|
|
1288
|
+
'- 触发:出现 2 个以上可独立回答的未知,或 2 个以上可隔离的产物/模块。单文件单步不硬拆。',
|
|
1289
|
+
'- 粒度:按「可独立消费的产物」切,每条写清产物、消费者与停止条件;不按文件数量机械切分。',
|
|
1290
|
+
'- 执行:独立项用 subagent 或 workflow 并发派出(数量按宿主槽位减一),主代理同时推进不依赖它们的关键路径;有依赖的串行交同一 agent 续作。',
|
|
1291
|
+
'- 清单:并行项同时标 in_progress 并在文本前加「∥」;被依赖项保持 pending,依赖解除后再置 in_progress。',
|
|
1292
|
+
'- 诚实:以真实工具调用作为派发证据;派发失败要保留该条并记录原因,继续推进可串行的工作。',
|
|
1293
|
+
].join('\n'),
|
|
1294
|
+
}), 'taskflow: parallel policy')
|
|
1295
|
+
parallelPolicyRegistered = true
|
|
1296
|
+
log(ctx, 'info', '[taskflow] parallel policy registered')
|
|
1297
|
+
})
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Always decompose, in Chinese, short items — enforced where the model reads it.
|
|
1301
|
+
if (config.taskListPolicy) {
|
|
1302
|
+
ctx.inject(['systemPrompt'], (host) => {
|
|
1303
|
+
host.effect(() => host.systemPrompt.section({
|
|
1304
|
+
name: 'taskflow:task-list-policy',
|
|
1305
|
+
order: 9000,
|
|
1306
|
+
text: [
|
|
1307
|
+
'## 任务清单规范',
|
|
1308
|
+
'- 每次收到用户请求,第一个动作必须是 todo_write:先拆成可执行条目再动手,哪怕只有一条,也要写。',
|
|
1309
|
+
'- 禁止先干活再补清单:不允许把已经做完的事批量写成 completed 列表(那等于事后编造计划)。',
|
|
1310
|
+
'- 每条用中文书写,动词开头,最多 35 个词(中文约 60 字);一条只做一件事,但要把对象和判据写全。',
|
|
1311
|
+
'- 写清对象(文件、命令、结论),不要用「处理一下」「继续优化」这类空话。',
|
|
1312
|
+
'- 同一时间只保留一个 in_progress;做完一项立刻勾掉,不要攒到最后批量改。',
|
|
1313
|
+
].join('\n'),
|
|
1314
|
+
}), 'taskflow: task list policy')
|
|
1315
|
+
|
|
1316
|
+
// Runtime context is appended after the sections on every request, so the
|
|
1317
|
+
// reminder stays adjacent to the user turn instead of scrolling away.
|
|
1318
|
+
host.effect(() => host.systemPrompt.context({
|
|
1319
|
+
name: 'taskflow:plan-first',
|
|
1320
|
+
order: 20000,
|
|
1321
|
+
text: '执行前先建任务清单:本轮若还没调用过 todo_write,先调用它列出要做的事(哪怕只有一条),再开始动手。',
|
|
1322
|
+
}), 'taskflow: plan-first reminder')
|
|
1323
|
+
policyRegistered = true
|
|
1324
|
+
log(ctx, 'info', '[taskflow] task-list policy registered')
|
|
1325
|
+
})
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
ctx.inject(['commands'], (host) => {
|
|
1329
|
+
host.effect(() => host.commands.register({
|
|
1330
|
+
name: 'taskflow',
|
|
1331
|
+
description: '把刚结束的一段工作整理成小结(TaskFlow)',
|
|
1332
|
+
input: { hint: '[now]', attachments: false },
|
|
1333
|
+
handler: (invocation) => {
|
|
1334
|
+
const session = invocation?.agent?.session
|
|
1335
|
+
if (!session) return { kind: 'error', text: 'taskflow: 没有可用的会话' }
|
|
1336
|
+
const queued = summarizeLatestTurn(session, stateOf(session.id))
|
|
1337
|
+
return queued
|
|
1338
|
+
? { kind: 'success', text: 'taskflow: 已开始整理本段小结,稍后出现在对话与侧栏。' }
|
|
1339
|
+
: { kind: 'success', text: 'taskflow: 最近一段已有小结,或内容太少无需总结。' }
|
|
1340
|
+
},
|
|
1341
|
+
}), 'taskflow: /taskflow command')
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
ctx.effect(() => {
|
|
1345
|
+
log(ctx, 'info', `[taskflow] v${VERSION} ready (enabled=${config.enabled})`)
|
|
1346
|
+
return () => {
|
|
1347
|
+
for (const timer of saveTimers.values()) clearTimeout(timer)
|
|
1348
|
+
saveTimers.clear()
|
|
1349
|
+
}
|
|
1350
|
+
}, 'taskflow: lifecycle')
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Where the turn actually began. The in-memory marker is authoritative; when the
|
|
1355
|
+
* plugin mounted mid-turn (or after a reload) fall back to the log, scanning for
|
|
1356
|
+
* the matching `turn/start` instead of guessing a fixed event window — a long
|
|
1357
|
+
* turn would otherwise lose its user request and produce a useless summary.
|
|
1358
|
+
* @returns {{seq: number|undefined, time: number|undefined}} opening coordinates
|
|
1359
|
+
*/
|
|
1360
|
+
function locateTurnStart(session, state, turn, open) {
|
|
1361
|
+
if (open) return { seq: open.seq, time: open.time }
|
|
1362
|
+
const known = turn === null ? undefined : state.turns[turn]?.startSeq
|
|
1363
|
+
if (typeof known === 'number') return { seq: known, time: undefined }
|
|
1364
|
+
if (turn === null || typeof session.snapshotEvents !== 'function') return { seq: undefined, time: undefined }
|
|
1365
|
+
try {
|
|
1366
|
+
const all = session.snapshotEvents()
|
|
1367
|
+
for (let index = all.length - 1; index >= 0; index -= 1) {
|
|
1368
|
+
const event = all[index]
|
|
1369
|
+
if (event.type === 'turn/start' && event.data?.turn === turn) return { seq: event.seq, time: event.time }
|
|
1370
|
+
}
|
|
1371
|
+
} catch {
|
|
1372
|
+
// fall through to the bounded window
|
|
1373
|
+
}
|
|
1374
|
+
return { seq: undefined, time: undefined }
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Keep the per-turn index bounded; it only serves manual summaries. */
|
|
1378
|
+
function pruneTurns(state) {
|
|
1379
|
+
const turns = Object.keys(state.turns)
|
|
1380
|
+
if (turns.length <= 200) return
|
|
1381
|
+
turns.sort((a, b) => Number(a) - Number(b))
|
|
1382
|
+
for (const turn of turns.slice(0, turns.length - 200)) delete state.turns[turn]
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Remember that a turn deliberately has no summary, so the UI stops waiting. */
|
|
1386
|
+
function markSkipped(state, turn) {
|
|
1387
|
+
if (!state.skipped.includes(turn)) state.skipped.push(turn)
|
|
1388
|
+
if (state.skipped.length > 200) state.skipped.splice(0, state.skipped.length - 200)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** First human request in a turn's events, for the mechanical fallback title. */
|
|
1392
|
+
function firstUserText(events) {
|
|
1393
|
+
for (const event of events) {
|
|
1394
|
+
if (event.type !== 'user/message') continue
|
|
1395
|
+
const text = textOf(event.data).trim()
|
|
1396
|
+
if (text.length > 0) return clip(text.replace(/\s+/g, ' '), 60)
|
|
1397
|
+
}
|
|
1398
|
+
return ''
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Distinct tool names used in a turn. */
|
|
1402
|
+
function toolNamesOf(events) {
|
|
1403
|
+
const names = []
|
|
1404
|
+
for (const event of events) {
|
|
1405
|
+
if (event.type !== 'tool/call') continue
|
|
1406
|
+
const toolName = String(event.data?.name ?? 'tool')
|
|
1407
|
+
if (!names.includes(toolName)) names.push(toolName)
|
|
1408
|
+
}
|
|
1409
|
+
return names
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Last assistant prose in a turn. */
|
|
1413
|
+
function lastAssistantText(events) {
|
|
1414
|
+
let text = ''
|
|
1415
|
+
for (const event of events) {
|
|
1416
|
+
if (event.type !== 'assistant/message') continue
|
|
1417
|
+
const value = textOf(event.data?.message).trim()
|
|
1418
|
+
if (value.length > 0) text = value
|
|
1419
|
+
}
|
|
1420
|
+
return text
|
|
1421
|
+
}
|