dsh-session-flow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host.js ADDED
@@ -0,0 +1,1132 @@
1
+ // lib/host.js — dsh-session-flow 宿主半:注册 /api/session-flow 路由。
2
+ //
3
+ // 提供方法(POST JSON,body.method):
4
+ // workspaces → 工作区列表(含会话数)
5
+ // list { workspace? } → 会话索引列表(增量扫描后返回;可带 workspace 限定)
6
+ // rescan { workspace?, force? } → 强制/增量重扫,返回最新索引
7
+ // get { sessionId } → 轻量详情(回合摘要 + 工具统计,秒开)
8
+ // getTurn { sessionId, turn } → 展开回合时按需取完整时间线
9
+ // searchIn { sessionId, query } → 会话内全文检索(匹配位置列表)
10
+ // stats → 环境信息(dsh home、索引目录、各工作区缓存状态)
11
+ //
12
+ // 依赖 cordis 服务:webServer(HTTP 载体)。
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFile } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { deflateRawSync } from 'node:zlib'
16
+ import { decodeFile, listWorkspaces, looksLikePath, parseSession, summarizeParsed } from './archive.js'
17
+ import { deriveTimeline } from './timeline.js'
18
+ import {
19
+ dshHome,
20
+ findWorkspaceOfSession,
21
+ indexRoot,
22
+ readIndex,
23
+ scanWorkspaceIndex,
24
+ workspaceIndexFile,
25
+ writeIndex,
26
+ } from './index-store.js'
27
+
28
+ export const name = 'dsh-session-flow'
29
+
30
+ /** 硬依赖:HTTP 载体;LLM 服务用于 M5 摘要(LLM 模式,缺失时降级报错)。 */
31
+ export const inject = ['webServer', 'llm']
32
+
33
+ /**
34
+ * 全程均匀采样(摘要信息源用):回合多时若只取「最近 N 个」,长会话的早期
35
+ * 任务会全部丢失,摘要会变成「最近几轮」的总结。按首/中/尾均匀抽取覆盖
36
+ * 整个会话,并保证最后一个(任务)回合在内。
37
+ * @param {Array} turns - 已过滤为「任务回合」的列表(每项有 turn 号)。
38
+ * @param {number} k - 最多采样数。
39
+ * @returns {Array} 采样结果(保持原顺序,去重)。
40
+ */
41
+ export function sampleTurns(turns, k) {
42
+ if (turns.length <= k) return turns
43
+ const out = []
44
+ for (let i = 0; i < k; i++) {
45
+ const idx = Math.round((i * (turns.length - 1)) / (k - 1))
46
+ if (!out.includes(turns[idx])) out.push(turns[idx])
47
+ }
48
+ return out
49
+ }
50
+
51
+ function readBody(req) {
52
+ return new Promise((resolve) => {
53
+ let raw = ''
54
+ req.on('data', (chunk) => { raw += chunk })
55
+ req.on('end', () => {
56
+ try { resolve(JSON.parse(raw || '{}')) } catch { resolve({}) }
57
+ })
58
+ req.on('error', () => resolve({}))
59
+ })
60
+ }
61
+
62
+ function sendJson(res, status, obj) {
63
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
64
+ res.end(JSON.stringify(obj))
65
+ }
66
+
67
+ /** 汇总输出视图(去掉 counts 细节,浏览器更轻)。 */
68
+ function sessionView(summary) {
69
+ if (!summary) return summary
70
+ const { counts, ...rest } = summary
71
+ return rest
72
+ }
73
+
74
+ // ── 时间线派生缓存(秒开关键,双通道)──────────────────────────────
75
+ // 1) 内存 LRU:同会话反复打开/展开秒回。
76
+ // 2) 磁盘持久化(<indexRoot>/timeline/<sessionId>.json):web 重启或换会话后
77
+ // 免去 zstd 解码(实测解码 777ms 是主要瓶颈,磁盘命中仅 JSON.parse ~150ms)。
78
+ // 缓存条目预计算 lightTurns / toolStats / summary,get 零遍历直接拼装。
79
+ // 按 文件 mtime+size 判定失效。
80
+ const timelineCache = new Map() // file -> { mtimeMs, sizeBytes, parsed, turns, lightTurns, toolStats, summary }
81
+ const TIMELINE_CACHE_MAX = 24
82
+
83
+ function diskCacheFile(home, sessionId) {
84
+ return join(indexRoot(home), 'timeline', String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_') + '.json')
85
+ }
86
+
87
+ function memoCache(file, entry) {
88
+ if (timelineCache.size >= TIMELINE_CACHE_MAX) timelineCache.delete(timelineCache.keys().next().value)
89
+ timelineCache.set(file, entry)
90
+ return entry
91
+ }
92
+
93
+ function cachedSession(home, sessionId, file, mtimeMs, sizeBytes) {
94
+ const hit = timelineCache.get(file)
95
+ if (hit && hit.mtimeMs === mtimeMs && hit.sizeBytes === sizeBytes) return hit
96
+ // 磁盘缓存命中(重启/换会话后免解码)
97
+ const cacheFile = diskCacheFile(home, sessionId)
98
+ try {
99
+ const disk = JSON.parse(readFileSync(cacheFile, 'utf8'))
100
+ if (disk && disk.mtimeMs === mtimeMs && disk.sizeBytes === sizeBytes && Array.isArray(disk.turns)) {
101
+ return memoCache(file, {
102
+ mtimeMs, sizeBytes,
103
+ parsed: { header: disk.header || null, title: disk.title || null, events: [] },
104
+ turns: disk.turns,
105
+ lightTurns: disk.lightTurns || disk.turns.map(lightTurnOf),
106
+ toolStats: disk.toolStats || toolStatsOf(disk.turns),
107
+ summary: disk.summary || null,
108
+ })
109
+ }
110
+ } catch {}
111
+ // 全量计算(解码 + 解析 + 派生 + 预计算摘要视图)
112
+ const parsed = parseSession(decodeFile(file))
113
+ const turns = deriveTimeline(parsed).turns
114
+ const summary = summarizeParsed(parsed)
115
+ const entry = memoCache(file, {
116
+ mtimeMs, sizeBytes, parsed, turns, summary,
117
+ lightTurns: turns.map(lightTurnOf),
118
+ toolStats: toolStatsOf(turns),
119
+ })
120
+ // 异步写盘(不阻塞响应),随后修剪超限缓存。
121
+ try {
122
+ const dir = join(indexRoot(home), 'timeline')
123
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
124
+ writeFile(cacheFile, JSON.stringify({
125
+ mtimeMs, sizeBytes,
126
+ header: parsed.header, title: parsed.title, summary,
127
+ lightTurns: entry.lightTurns, toolStats: entry.toolStats,
128
+ turns,
129
+ }), () => pruneTimelineCache(home))
130
+ } catch {}
131
+ return entry
132
+ }
133
+
134
+ /** 时间线缓存总大小上限:会话很多时防止缓存目录无限膨胀。 */
135
+ const TIMELINE_CACHE_MAX_BYTES = 512 * 1024 * 1024 // 512 MiB
136
+ /** 超过上限后修剪到的水位(保留 75%,删最旧)。 */
137
+ const TIMELINE_CACHE_TRIM_RATIO = 0.75
138
+
139
+ /** 修剪时间线缓存:总大小超限时按 mtime 从旧到新删除,直到回到水位线。 */
140
+ function pruneTimelineCache(home) {
141
+ const td = join(indexRoot(home), 'timeline')
142
+ let files = []
143
+ try {
144
+ files = readdirSync(td)
145
+ .filter((f) => f.endsWith('.json'))
146
+ .map((f) => {
147
+ const p = join(td, f)
148
+ let st
149
+ try { st = statSync(p) } catch { return null }
150
+ return { p, size: st.size, mtime: st.mtimeMs }
151
+ })
152
+ .filter(Boolean)
153
+ } catch { return }
154
+ const total = files.reduce((a, f) => a + f.size, 0)
155
+ if (total <= TIMELINE_CACHE_MAX_BYTES) return
156
+ files.sort((a, b) => a.mtime - b.mtime)
157
+ const target = TIMELINE_CACHE_MAX_BYTES * TIMELINE_CACHE_TRIM_RATIO
158
+ let removed = 0
159
+ for (const f of files) {
160
+ if (total - removed <= target) break
161
+ try { rmSync(f.p); removed += f.size } catch {}
162
+ }
163
+ }
164
+
165
+ /** 回合级摘要(light):用户发言/结论预览 + 工具统计,供详情页秒开。 */
166
+ function lightTurnOf(t) {
167
+ const toolCount = t.steps.reduce((a, s) => a + s.toolCalls.length, 0)
168
+ const errorCount = t.steps.reduce((a, s) => a + s.toolCalls.filter((c) => c.isError).length, 0)
169
+ const finals = t.assistantMessages.filter((a) => a.hasText)
170
+ return {
171
+ turn: t.turn,
172
+ startTime: t.startTime,
173
+ endTime: t.endTime,
174
+ toolCount,
175
+ errorCount,
176
+ userMessages: t.userMessages.map((u) => ({ seq: u.seq, preview: u.preview })),
177
+ conclusionPreview: finals.length > 0 ? finals[finals.length - 1].preview : '',
178
+ hasThinking: t.assistantMessages.some((a) => a.hasThinking),
179
+ }
180
+ }
181
+
182
+ /**
183
+ * M5d:渲染会话为可读 Markdown 报告(导出/归档/分享)。
184
+ * 为避免单文件过大(超大会话全文可达数 MB,部分编辑器/查看器打不开),
185
+ * 拆分为多个文档:
186
+ * 00-概览.md —— 头部元信息 + LLM/规则摘要 + 产物清单 + 工具统计
187
+ * 01-时间线-回合1-25.md —— 按目标体积滚动分卷的逐回合时间线
188
+ * 02-时间线-回合26-50.md —— ...
189
+ * 最后打包为 ZIP(deflate + CRC32,手写实现,无外部依赖)下载。
190
+ * 时间序由 turn.items 驱动,与详情页一致。
191
+ */
192
+
193
+ // 单卷目标体积(字节,UTF-8 文本);超过则开新卷。
194
+ const EXPORT_CHUNK_TARGET = 700 * 1024
195
+ // 单块围栏内容上限(防单次工具输出爆卷)。
196
+ const FENCE_MAX = 4000
197
+
198
+ /** 普通文本转义(防破坏 Markdown 结构)——用于引用块内文本。
199
+ * 含 `<`:用户消息里可能出现 `<file>`、`<details>` 等文本,不转义会被当 HTML 标签。 */
200
+ function mdEsc(s) {
201
+ return String(s || '').replace(/([\\`*_{}[\]()#+\-.!<>|~])/g, '\\$1')
202
+ }
203
+
204
+ /**
205
+ * 助手正文转义:正文是模型输出的 Markdown 片段,需保留 Markdown 语义
206
+ * (粗体/列表/链接),但**禁止裸 HTML**——模型可能讨论 `<details>`、
207
+ * `<file>` 等标签,裸 `<` 会被渲染器当 HTML 解析,吞掉/错乱文档结构
208
+ * (如正文里的 `<details>` 会真的折叠后续内容,`</details>` 显示为孤立文本)。
209
+ * 做法:逐行状态机——围栏代码块(```…```)与行内代码(`…`)内的 `<` 本就
210
+ * 安全(代码区不解析 HTML),原样保留;其余普通文本转义 `<` → `&lt;`
211
+ * (渲染为字面 `<`,不触发 HTML)。逐行处理可应对不配对的反引号
212
+ * (模型演示 ```` 语法时不会误判、不会吞掉后续内容)。
213
+ */
214
+ export function mdBodyEsc(text) { const lines = String(text || '').split('\n')
215
+ const out = []
216
+ let inFence = false
217
+ let fenceMark = ''
218
+ let inIndent = false // 4 空格缩进代码块(Markdown 规范同样不解析 HTML)
219
+ for (const line of lines) {
220
+ const fm = /^(`{3,})/.exec(line)
221
+ if (fm) {
222
+ const mark = fm[1]
223
+ if (!inFence) {
224
+ inFence = true
225
+ fenceMark = mark
226
+ out.push(line)
227
+ } else if (line.trim() === fenceMark) {
228
+ inFence = false
229
+ out.push(line)
230
+ } else {
231
+ out.push(line) // 不同长度围栏:视为普通行(不配对场景)
232
+ }
233
+ continue
234
+ }
235
+ if (inFence) { out.push(line); continue }
236
+ // 缩进代码块:连续 ≥4 空格前缀的行原样保留;空行结束。
237
+ if (/^ {4,}/.test(line) || (inIndent && line.trim() === '')) {
238
+ inIndent = /^ {4,}/.test(line) || line.trim() === ''
239
+ out.push(line)
240
+ continue
241
+ }
242
+ inIndent = false
243
+ // 普通行:行内代码保护,其余 `<` 转义。
244
+ out.push(line.replace(/`[^`\n]*`|(<)/g, (m, lt) => (lt !== undefined ? '&lt;' : m)))
245
+ }
246
+ return out.join('\n')
247
+ }
248
+
249
+ /** 行内代码内容:只处理反引号,不做其余转义(代码里 `\-`、`\.` 很丑且无意义)。 */
250
+ function mdCode(s) {
251
+ return '`' + String(s || '').replace(/`/g, '\\`') + '`'
252
+ }
253
+
254
+ /** 围栏代码块:多行/结构化内容(工具参数、结果、思考)用代码块而非行内代码;
255
+ * 内容原样保留(不转义),可选语言标注;自动加长围栏防逃逸、单块截断防爆。 */
256
+ function mdFence(text, lang) {
257
+ let t = String(text || '').replace(/\n+$/, '').replace(/\r\n/g, '\n')
258
+ let truncated = false
259
+ if (t.length > FENCE_MAX) { t = t.slice(0, FENCE_MAX); truncated = true }
260
+ let f = '```'
261
+ while (t.includes(f)) f += '`'
262
+ const body = t + (truncated ? '\n…(已截断,全文见会话档案)' : '')
263
+ return f + (lang || '') + '\n' + body + '\n' + f
264
+ }
265
+
266
+ /** 时间戳格式化(YYYY-MM-DD HH:mm:ss)。 */
267
+ function mdTime(ms) {
268
+ if (ms === null || ms === undefined) return '—'
269
+ const d = new Date(ms)
270
+ const p = (n) => String(n).padStart(2, '0')
271
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
272
+ }
273
+
274
+ /** 时长格式化(秒/分/时)。 */
275
+ function mdDur(ms) {
276
+ if (ms === null || ms === undefined) return ''
277
+ const s = Math.max(0, Math.floor(ms / 1000))
278
+ if (s < 60) return `(${s}s)`
279
+ const m = Math.floor(s / 60)
280
+ if (m < 60) return `(${m}m ${s % 60}s)`
281
+ return `(${Math.floor(m / 60)}h ${m % 60}m)`
282
+ }
283
+
284
+ /** 概览文档:元信息表 + 摘要 + 产物清单 + 工具统计。不含时间线。 */
285
+ function renderOverviewMd({ entry, sum, found, title, llmText, chunkCount }) {
286
+ const out = []
287
+ const active = typeof sum.lastEventTime === 'number' && (Date.now() - sum.lastEventTime) < 15 * 60 * 1000
288
+ out.push(`# 会话报告${title ? ':' + mdEsc(title) : ''}`)
289
+ out.push('')
290
+ out.push('| 字段 | 值 |')
291
+ out.push('| --- | --- |')
292
+ out.push(`| 会话 ID | ${mdCode(sessionIdOf(sum))} |`)
293
+ out.push(`| 工作区 | ${mdCode(found.workspace)} |`)
294
+ out.push(`| 状态 | ${active ? '🟢 进行中' : '🔚 已结束'} |`)
295
+ out.push(`| 创建 | ${mdTime(sum.createdAt)} |`)
296
+ out.push(`| 最后活动 | ${mdTime(sum.lastEventTime)} |`)
297
+ out.push(`| 回合 | ${sum.turns || 0} |`)
298
+ out.push(`| 工具调用 | ${sum.toolCalls || 0}(错误 ${sum.toolErrors || 0}) |`)
299
+ out.push(`| 消息 | 用户 ${sum.userMessages || 0} / 助手 ${sum.assistantMessages || 0} |`)
300
+ if (sum.delegationDepth > 0) out.push(`| 子代理深度 | ${sum.delegationDepth} |`)
301
+ if (sum.lastError) out.push(`| 最后错误 | 回合事件 seq ${sum.lastError.seq} @ ${mdTime(sum.lastError.time)} |`)
302
+ out.push('')
303
+
304
+ out.push('## 摘要')
305
+ out.push('')
306
+ if (llmText) {
307
+ out.push('### LLM 摘要')
308
+ out.push('')
309
+ // 摘要也是模型输出:转义裸 < 防 HTML 标签污染(同助手正文策略)。
310
+ out.push(mdBodyEsc(llmText.trim()))
311
+ out.push('')
312
+ }
313
+ const taskTurns = entry.lightTurns.filter((lt) => lt.userMessages && lt.userMessages.length > 0)
314
+ const firstTask = (() => {
315
+ for (const lt of taskTurns) {
316
+ if (lt.userMessages && lt.userMessages.length > 0) return String(lt.userMessages[0].preview).slice(0, 300)
317
+ }
318
+ return ''
319
+ })()
320
+ const lastConclusion = (() => {
321
+ for (let i = entry.lightTurns.length - 1; i >= 0; i--) {
322
+ if (entry.lightTurns[i].conclusionPreview) return String(entry.lightTurns[i].conclusionPreview).slice(0, 500)
323
+ }
324
+ return ''
325
+ })()
326
+ const topTools = entry.toolStats.slice(0, 8).map((t) => `${t.name}(${t.count})`).join('、')
327
+ out.push('### 规则摘要')
328
+ out.push('')
329
+ if (taskTurns.length > 0) out.push(`- 任务数:${taskTurns.length}`)
330
+ if (firstTask) out.push(`- 首个任务:${mdEsc(firstTask)}`)
331
+ if (lastConclusion) out.push(`- 最近结论${active ? '(可能仅为当前进展)' : ''}:${mdEsc(lastConclusion)}`)
332
+ if (topTools) out.push(`- 主要工具:${mdEsc(topTools)}`)
333
+ out.push('')
334
+
335
+ // 工具调用统计(全部,非 Top8)。
336
+ if (entry.toolStats.length > 0) {
337
+ out.push('## 工具统计')
338
+ out.push('')
339
+ out.push('| 工具 | 调用 | 错误 |')
340
+ out.push('| --- | --- | --- |')
341
+ for (const t of entry.toolStats) {
342
+ out.push(`| ${mdCode(t.name)} | ${t.count} | ${t.errors > 0 ? t.errors : '—'} |`)
343
+ }
344
+ out.push('')
345
+ }
346
+
347
+ const EXPORT_PATH_RE = /^[A-Za-z]:[\\/]|^[\\/]|^\.{1,2}[\\/]|^~[\\/]/
348
+ const artifactList = (sum.artifactPaths || []).filter((p) =>
349
+ looksLikePath(p) && !p.includes('|') && (EXPORT_PATH_RE.test(p) || /^[\w.-]+(\.[A-Za-z0-9]{1,6})$/.test(p)))
350
+ if (artifactList.length > 0) {
351
+ out.push('## 产物清单')
352
+ out.push('')
353
+ for (const p of artifactList) out.push(`- ${mdCode(p)}`)
354
+ out.push('')
355
+ }
356
+
357
+ // 分卷指引。
358
+ out.push('## 文档结构')
359
+ out.push('')
360
+ out.push(`本报告由 ${chunkCount + 1} 个文件组成:\`00-概览.md\`(本文件)+ ${chunkCount} 个时间线分卷(按体积自动拆分,避免单文件过大)。`)
361
+ out.push('')
362
+ return out.join('\n')
363
+ }
364
+
365
+ /** 单回合时间线渲染(供分卷)。返回该回合的 Markdown 文本。 */
366
+ function renderTurnMd(t, fmt, fmtDur, toolIdxRef) {
367
+ const out = []
368
+ out.push(`### 回合 ${t.turn}${t.startTime ? ' · ' + fmt(t.startTime) : ''}${t.endTime ? ' → ' + fmt(t.endTime) + fmtDur(t.endTime - t.startTime) : ''}`)
369
+ out.push('')
370
+ for (const it of t.items || []) {
371
+ if (it.kind === 'user') {
372
+ out.push('> 💬 **用户**')
373
+ out.push('>')
374
+ for (const line of String(it.text || '').split('\n')) out.push('> ' + mdEsc(line))
375
+ out.push('')
376
+ } else if (it.kind === 'inject') {
377
+ out.push(`> 📥 **注入**${it.sourceKind ? '(' + mdEsc(it.sourceKind) + ')' : ''}`)
378
+ out.push('>')
379
+ for (const line of String(it.text || '').split('\n')) out.push('> ' + mdEsc(line))
380
+ out.push('')
381
+ } else if (it.kind === 'assistant') {
382
+ if (it.text) {
383
+ out.push('🤖 **助手**')
384
+ out.push('')
385
+ // 助手正文保留 Markdown 语义(粗体/列表/链接),但转义裸 `<`
386
+ // 防 HTML 标签污染(模型可能在正文里讨论 `<details>` 等标签)。
387
+ out.push(mdBodyEsc(it.text.trim()))
388
+ out.push('')
389
+ }
390
+ if (it.thinking) {
391
+ out.push('<details>')
392
+ out.push('<summary>🧠 思考过程</summary>')
393
+ out.push('')
394
+ out.push(mdFence(it.thinking, ''))
395
+ out.push('')
396
+ out.push('</details>')
397
+ out.push('')
398
+ }
399
+ } else if (it.kind === 'tool') {
400
+ toolIdxRef.n++
401
+ const c = it.call || {}
402
+ const dur = c.durationMs !== null && c.durationMs !== undefined ? `(${(c.durationMs / 1000).toFixed(1)}s)` : ''
403
+ const errMark = c.isError ? ' ⚠️ **错误**' : ''
404
+ out.push(`${toolIdxRef.n}. 🛠️ ${mdCode(c.name)}${dur}${errMark}`)
405
+ if (c.argumentsText || c.argumentsPreview) {
406
+ out.push('')
407
+ out.push(' **参数:**')
408
+ out.push('')
409
+ out.push(mdFence(c.argumentsText || c.argumentsPreview, 'json'))
410
+ out.push('')
411
+ }
412
+ if (c.resultText) {
413
+ out.push(' **结果:**')
414
+ out.push('')
415
+ out.push(mdFence(c.resultText, c.isError ? '' : 'text'))
416
+ out.push('')
417
+ } else if (c.resultTime !== null && c.resultTime !== undefined) {
418
+ out.push('')
419
+ out.push(' **结果:**(空)')
420
+ out.push('')
421
+ }
422
+ if (c.childSessionId) out.push(` - 子代理:${mdCode(c.childSessionId)}`)
423
+ }
424
+ }
425
+ out.push('---')
426
+ out.push('')
427
+ return out.join('\n')
428
+ }
429
+
430
+ /** 按目标体积滚动分卷:返回 [{ title, body }],每卷 body ≤ 目标(单回合超限时单独成卷)。 */
431
+ function chunkTurns(turns, targetBytes) {
432
+ const chunks = []
433
+ let cur = []
434
+ let curBytes = 0
435
+ const fmt = mdTime
436
+ for (const t of turns) {
437
+ const toolIdxRef = { n: 0 }
438
+ const text = renderTurnMd(t, fmt, mdDur, toolIdxRef)
439
+ const bytes = Buffer.byteLength(text, 'utf8')
440
+ // 单回合超过整卷目标:单独成卷(不能再拆,回合是原子单元)。
441
+ if (bytes >= targetBytes) {
442
+ if (cur.length > 0) { chunks.push(cur); cur = []; curBytes = 0 }
443
+ chunks.push([{ turn: t.turn, text }])
444
+ continue
445
+ }
446
+ if (cur.length > 0 && curBytes + bytes > targetBytes) {
447
+ chunks.push(cur)
448
+ cur = []
449
+ curBytes = 0
450
+ }
451
+ cur.push({ turn: t.turn, text })
452
+ curBytes += bytes
453
+ }
454
+ if (cur.length > 0) chunks.push(cur)
455
+ return chunks.map((items, i) => {
456
+ const first = items[0].turn
457
+ const last = items[items.length - 1].turn
458
+ const title = first === last ? `回合${first}` : `回合${first}-${last}`
459
+ const body = items.map((x) => x.text).join('\n')
460
+ return { title, body }
461
+ })
462
+ }
463
+
464
+ /**
465
+ * 手写 ZIP(deflate + CRC32,无外部依赖)。
466
+ * @param {Array<{name: string, data: Buffer}>} files
467
+ * @returns {Buffer} ZIP 文件(含 UTF-8 文件名标志)。
468
+ */
469
+ export function buildZip(files) {
470
+ const localParts = []
471
+ const centralParts = []
472
+ let offset = 0
473
+ const crcTable = (() => {
474
+ const table = new Int32Array(256)
475
+ for (let n = 0; n < 256; n++) {
476
+ let c = n
477
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
478
+ table[n] = c
479
+ }
480
+ return table
481
+ })()
482
+ const crc32 = (buf) => {
483
+ let c = 0xffffffff
484
+ for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
485
+ return (c ^ 0xffffffff) >>> 0
486
+ }
487
+ const dosTime = (d) => ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff
488
+ const dosDate = (d) => (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff
489
+ const now = new Date()
490
+
491
+ for (const file of files) {
492
+ const nameBuf = Buffer.from(file.name, 'utf8')
493
+ const data = file.data
494
+ const crc = crc32(data)
495
+ const compressed = deflateRawSync(data, { level: 6 })
496
+ const csize = compressed.length
497
+ const usize = data.length
498
+ const time = dosTime(now)
499
+ const date = dosDate(now)
500
+ // Local file header
501
+ const lh = Buffer.alloc(30)
502
+ lh.writeUInt32LE(0x04034b50, 0) // signature
503
+ lh.writeUInt16LE(20, 4) // version needed
504
+ lh.writeUInt16LE(0x0800, 6) // flags: UTF-8 names
505
+ lh.writeUInt16LE(8, 8) // method: deflate
506
+ lh.writeUInt16LE(time, 10)
507
+ lh.writeUInt16LE(date, 12)
508
+ lh.writeUInt32LE(crc, 14)
509
+ lh.writeUInt32LE(csize, 18)
510
+ lh.writeUInt32LE(usize, 22)
511
+ lh.writeUInt16LE(nameBuf.length, 26)
512
+ lh.writeUInt16LE(0, 28) // extra len
513
+ localParts.push(lh, nameBuf, compressed)
514
+ // Central directory entry
515
+ const ch = Buffer.alloc(46)
516
+ ch.writeUInt32LE(0x02014b50, 0) // signature
517
+ ch.writeUInt16LE(0x031e, 4) // version made by (3=unix, 0x1e=30)
518
+ ch.writeUInt16LE(20, 6) // version needed
519
+ ch.writeUInt16LE(0x0800, 8)
520
+ ch.writeUInt16LE(8, 10)
521
+ ch.writeUInt16LE(time, 12)
522
+ ch.writeUInt16LE(date, 14)
523
+ ch.writeUInt32LE(crc, 16)
524
+ ch.writeUInt32LE(csize, 20)
525
+ ch.writeUInt32LE(usize, 24)
526
+ ch.writeUInt16LE(nameBuf.length, 28)
527
+ ch.writeUInt16LE(0, 30) // extra
528
+ ch.writeUInt16LE(0, 32) // comment
529
+ ch.writeUInt16LE(0, 34) // disk
530
+ ch.writeUInt16LE(0, 36) // internal attrs
531
+ ch.writeUInt32LE(0, 38) // external attrs
532
+ ch.writeUInt32LE(offset, 42) // local header offset
533
+ centralParts.push(ch, nameBuf)
534
+ offset += lh.length + nameBuf.length + csize
535
+ }
536
+ const central = Buffer.concat(centralParts)
537
+ const eocd = Buffer.alloc(22)
538
+ eocd.writeUInt32LE(0x06054b50, 0) // signature
539
+ eocd.writeUInt16LE(0, 4) // disk
540
+ eocd.writeUInt16LE(0, 6) // cd start disk
541
+ eocd.writeUInt16LE(files.length, 8)
542
+ eocd.writeUInt16LE(files.length, 10)
543
+ eocd.writeUInt32LE(central.length, 12)
544
+ eocd.writeUInt32LE(offset, 16)
545
+ eocd.writeUInt16LE(0, 20) // comment len
546
+ return Buffer.concat([...localParts, central, eocd])
547
+ }
548
+
549
+ /** 会话 ID(summarizeParsed 结果里的 id 字段)。 */
550
+ function sessionIdOf(sum) {
551
+ return sum && sum.id ? sum.id : 'unknown'
552
+ }
553
+
554
+ /** 会话内工具统计聚合(右侧导航数据源,服务端聚合避免前端全量扫描)。 */
555
+ function toolStatsOf(turns) { const agg = new Map()
556
+ for (const t of turns) {
557
+ for (const step of t.steps) {
558
+ for (const c of step.toolCalls) {
559
+ let e = agg.get(c.name)
560
+ if (!e) { e = { name: c.name, count: 0, errors: 0, calls: [], errorCalls: [] }; agg.set(c.name, e) }
561
+ e.count++
562
+ const pos = { callId: c.callId, turn: t.turn, preview: c.argumentsPreview || '' }
563
+ if (c.isError === true) { e.errors++; e.errorCalls.push(pos) }
564
+ e.calls.push(pos)
565
+ }
566
+ }
567
+ }
568
+ return [...agg.values()].sort((a, b) => b.count - a.count)
569
+ }
570
+
571
+ /**
572
+ * 工作区显示名与完整路径(与左侧栏一致:只显示最后一级目录,悬浮显示全路径)。
573
+ * 优先从会话的 cwd 派生(真实路径,可靠);cwd 缺失时回退解析编码目录名
574
+ * (如 "--C-path-proj--" → 去首尾 "--" → "C-path-proj" → 取末段 "proj")。
575
+ */
576
+ function workspaceLabelOf(wsName, sessions) {
577
+ const withCwd = sessions.find((s) => typeof s.cwd === 'string' && s.cwd.length > 0)
578
+ if (withCwd && withCwd.cwd) {
579
+ const base = String(withCwd.cwd).replace(/[\\/]+$/, '').split(/[\\/]/).pop()
580
+ if (base && base.length > 0) return { label: base, cwd: withCwd.cwd }
581
+ }
582
+ const decoded = String(wsName).replace(/^--|--$/g, '')
583
+ const last = decoded.split('-').filter(Boolean).pop()
584
+ return { label: last || wsName, cwd: null }
585
+ }
586
+
587
+ export function apply(ctx) {
588
+ const webServer = ctx.get('webServer')
589
+ if (webServer === undefined) {
590
+ console.error('[dsh-session-flow] webServer service unavailable at apply; route not registered')
591
+ return
592
+ }
593
+
594
+ webServer.register({
595
+ kind: 'exact',
596
+ path: '/api/session-flow',
597
+ handler: async (req, res) => {
598
+ try {
599
+ const body = await readBody(req)
600
+ const method = String(body.method || '')
601
+ const home = dshHome()
602
+
603
+ if (method === 'workspaces') {
604
+ const workspaces = listWorkspaces(home).map((ws) => {
605
+ const index = readIndex(home, ws.name)
606
+ const sessions = Object.values(index.sessions)
607
+ const meta = workspaceLabelOf(ws.name, sessions)
608
+ return {
609
+ name: ws.name,
610
+ label: meta.label,
611
+ cwd: meta.cwd,
612
+ sessionCount: ws.sessionCount,
613
+ indexFile: workspaceIndexFile(home, ws.name),
614
+ }
615
+ })
616
+ return sendJson(res, 200, { ok: true, home, workspaces })
617
+ }
618
+
619
+ if (method === 'list' || method === 'rescan') {
620
+ const requested = typeof body.workspace === 'string' && body.workspace ? body.workspace : null
621
+ const force = method === 'rescan' && body.force === true
622
+ const workspaces = []
623
+ let scanned = 0
624
+ let skipped = 0
625
+ const removed = []
626
+ for (const ws of listWorkspaces(home)) {
627
+ if (requested !== null && ws.name !== requested) continue
628
+ const result = scanWorkspaceIndex(home, ws.name, { force })
629
+ const sessions = Object.values(result.index.sessions)
630
+ .map(sessionView)
631
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
632
+ const meta = workspaceLabelOf(ws.name, sessions)
633
+ workspaces.push({
634
+ name: ws.name,
635
+ label: meta.label,
636
+ cwd: meta.cwd,
637
+ sessionCount: sessions.length,
638
+ scannedAt: result.index.scannedAt,
639
+ sessions,
640
+ })
641
+ scanned += result.scanned
642
+ skipped += result.skipped
643
+ removed.push(...result.removed)
644
+ }
645
+ return sendJson(res, 200, {
646
+ ok: true,
647
+ method,
648
+ scanned,
649
+ skipped,
650
+ removed,
651
+ scannedAt: Date.now(),
652
+ workspaces,
653
+ })
654
+ }
655
+
656
+ if (method === 'get') {
657
+ // 轻量详情(秒开):只返回回合摘要 + 工具统计;完整时间线由 getTurn 按需取。
658
+ const sessionId = String(body.sessionId || '')
659
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: 'sessionId required' })
660
+ const found = findWorkspaceOfSession(home, sessionId)
661
+ if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
662
+ let st
663
+ try { st = statSync(found.file) } catch { st = null }
664
+ const entry = cachedSession(home, sessionId, found.file, st ? st.mtimeMs : 0, st ? st.size : 0)
665
+ // 预计算摘要(内存/磁盘缓存都有),get 零遍历。
666
+ const sum = entry.summary || summarizeParsed(entry.parsed)
667
+ if (st) {
668
+ sum.fileMtimeMs = st.mtimeMs
669
+ sum.sizeBytes = st.size
670
+ }
671
+ const counts = Object.fromEntries(
672
+ Object.entries(sum.counts || {}).filter(([k]) =>
673
+ ['tool/call', 'tool/result', 'user/message', 'assistant/message', 'turn/start', 'step/start', 'step/end'].includes(k)),
674
+ )
675
+ const wsMeta = workspaceLabelOf(found.workspace, [sum])
676
+ // 索引中已缓存的 LLM 摘要(若生成过)+ 过期判定:
677
+ // 生成基线(生成摘要时的最后事件时间)若早于当前最后事件时间,
678
+ // 说明生成后又有新对话,摘要可能不准,前端据此提示可重新生成。
679
+ let storedSummary = null
680
+ let summaryStale = false
681
+ try {
682
+ const index = readIndex(home, found.workspace)
683
+ const s = index.sessions[sessionId]
684
+ if (s && s.summary) {
685
+ storedSummary = s.summary
686
+ const base = s.summaryLastEventTime
687
+ const cur = entry.summary ? entry.summary.lastEventTime : null
688
+ summaryStale = typeof base === 'number' && typeof cur === 'number' && cur > base
689
+ }
690
+ } catch {}
691
+ return sendJson(res, 200, {
692
+ ok: true,
693
+ workspace: found.workspace,
694
+ workspaceLabel: wsMeta.label,
695
+ workspaceCwd: wsMeta.cwd,
696
+ session: sessionView(sum),
697
+ counts,
698
+ lightTurns: entry.lightTurns,
699
+ toolStats: entry.toolStats,
700
+ title: entry.parsed.title,
701
+ header: entry.parsed.header,
702
+ summary: storedSummary,
703
+ summaryStale,
704
+ })
705
+ }
706
+
707
+ if (method === 'summarize') {
708
+ // M5 摘要引擎(LLM 模式):走 DSH 自身模型通道(ctx.llm)。
709
+ // 规则摘要由前端从 get 响应的 lightTurns/toolStats 实时组装(零请求)。
710
+ const sessionId = String(body.sessionId || '')
711
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: 'sessionId required' })
712
+ const found = findWorkspaceOfSession(home, sessionId)
713
+ if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
714
+ const llm = ctx.get('llm')
715
+ if (llm === undefined) return sendJson(res, 501, { ok: false, error: 'llm service unavailable (LLM 摘要不可用,规则摘要仍可用)' })
716
+ let st
717
+ try { st = statSync(found.file) } catch { st = null }
718
+ const entry = cachedSession(home, sessionId, found.file, st ? st.mtimeMs : 0, st ? st.size : 0)
719
+
720
+ // 解析 provider/model:调用方传入优先,否则从已注册提供商兜底取第一个可用模型。
721
+ let provider = String(body.provider || '')
722
+ let model = String(body.model || '')
723
+ if (!provider || !model) {
724
+ const providers = llm.listProviders()
725
+ for (const p of providers) {
726
+ try {
727
+ const models = await llm.listModels(p.id)
728
+ if (models.length > 0) { provider = p.id; model = models[0].id; break }
729
+ } catch {}
730
+ }
731
+ }
732
+ if (!provider || !model) return sendJson(res, 502, { ok: false, error: 'no usable model' })
733
+
734
+ // 组装摘要输入(多任务会话信息源):一次会话可能包含多个相互独立的
735
+ // 任务,最后一条回复可能只是当前进展而非最终结论。因此以「回合」为
736
+ // 任务单元,逐回合枚举「任务(该回合最后一条用户消息)→ 结果(该
737
+ // 回合最后结论)」,并携带会话状态(进行中/已结束)让模型按实际情况
738
+ // 概括,而不是只取首条目标 + 末条结论。
739
+ const ACTIVE_WINDOW_MS = 15 * 60 * 1000 // 与 client 端一致:15 分钟内活跃视为进行中
740
+ const SUMMARY_MAX_TURNS = 8 // 最多枚举回合数(控制 token 预算)
741
+ const lastEventTime = entry.summary ? entry.summary.lastEventTime : null
742
+ const isRunning = typeof lastEventTime === 'number' && (Date.now() - lastEventTime) < ACTIVE_WINDOW_MS
743
+ const totalToolCalls = entry.toolStats.reduce((a, t) => a + t.count, 0)
744
+ const totalErrors = entry.summary ? entry.summary.toolErrors || 0 : 0
745
+ // 全程均匀采样:若只取「最近 N 个回合」,长会话的早期任务会全部丢失,
746
+ // 摘要会变成「最近几轮」的总结(用户反馈确认)。改为按首/中/尾均匀
747
+ // 抽取,覆盖整个会话,并保证最后一个回合在内(其结论代表当前进展,
748
+ // 由「会话状态」标注兜底)。
749
+ // 先取「有用户消息的任务回合」再均匀采样:注入/续写回合不代表独立任务,
750
+ // 直接采样会浪费名额;同时保证最后一个任务回合在内。
751
+ const taskTurns = entry.lightTurns.filter((lt) => lt.userMessages && lt.userMessages.length > 0)
752
+ const sampled = sampleTurns(taskTurns, SUMMARY_MAX_TURNS)
753
+ const sampledLabels = sampled.map((lt) => `回合 ${lt.turn}`).join('、')
754
+ const turnLines = sampled.map((lt) => {
755
+ const task = String(lt.userMessages[lt.userMessages.length - 1].preview).slice(0, 200)
756
+ const result = lt.conclusionPreview ? String(lt.conclusionPreview).slice(0, 300) : '(无明确结果)'
757
+ const stats = '工具 ' + (lt.toolCount || 0) + ' 次' + ((lt.errorCount || 0) > 0 ? ',错误 ' + lt.errorCount + ' 处' : '')
758
+ return `回合 ${lt.turn}:任务「${task}」→ 结果「${result}」(${stats})`
759
+ }).join('\n')
760
+ const topTools = entry.toolStats.slice(0, 8).map((t) => `${t.name}(${t.count})`).join('、')
761
+ const prompt =
762
+ '请为以下 DSH 智能体会话生成一段简洁中文摘要(250 字以内)。注意:一次会话可能包含多个相互独立的任务,最后一条回复可能只是当前进展而非最终结论,请按实际情况逐任务概括。\n\n' +
763
+ '会话概况:\n' +
764
+ '回合数:' + entry.lightTurns.length + '(任务回合 ' + taskTurns.length + ' 个' + (sampled.length < taskTurns.length ? `,以下均匀采样 ${sampled.length} 个:${sampledLabels},覆盖首/中/尾` : ',以下全部列出') + ')\n' +
765
+ '工具调用:' + totalToolCalls + ' 次,错误:' + totalErrors + ' 处\n' +
766
+ '主要工具:' + (topTools || '(无)') + '\n' +
767
+ '会话状态:' + (isRunning ? '进行中(最后一条回复可能只是当前进展)' : '已结束') + '\n\n' +
768
+ '各回合任务与结果:\n' + (turnLines || '(无回合数据)') + '\n\n' +
769
+ '请输出:1) 本次会话包含哪些任务及各自目标;2) 各任务的结果与状态(完成/进行中/失败);3) 遗留问题或未完成事项。\n摘要:'
770
+
771
+ let text = ''
772
+ let reasoning = ''
773
+ try {
774
+ const chunks = llm.stream({
775
+ provider,
776
+ model,
777
+ system: '你是会话档案员,擅长把一次智能体工作会话总结成简明摘要。会话可能包含多个独立任务,请逐任务概括,不要臆造单一目标。',
778
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
779
+ maxTokens: 2000,
780
+ temperature: 0.3,
781
+ })
782
+ for await (const chunk of chunks) {
783
+ if (chunk.type === 'text-delta') text += chunk.text
784
+ if (chunk.type === 'reasoning-delta') reasoning += chunk.text
785
+ if (chunk.type === 'finish') {
786
+ // DSH 协议:finish.reason 是对象 { kind: 'stop'|'max-tokens'|'error'|'aborted', failure? }。
787
+ // 兼容字符串形式('stop'/'error')与对象形式,失败时透传 failure 详情。
788
+ const reason = chunk.reason
789
+ const kind = typeof reason === 'string' ? reason : reason && reason.kind
790
+ if (kind === 'error' || kind === 'aborted') {
791
+ const failure = (reason && reason.failure) || {}
792
+ const detail = String(failure.message || '')
793
+ const code = String(failure.code || '')
794
+ throw new Error(`llm stream finished with ${kind}` + (detail || code ? ': ' + detail + (code && !detail.includes(code) ? ' (' + code + ')' : '') : ''))
795
+ }
796
+ }
797
+ }
798
+ } catch (e) {
799
+ return sendJson(res, 500, { ok: false, error: 'LLM 摘要生成失败: ' + String(e && e.message || e) })
800
+ }
801
+ text = text.trim()
802
+ if (!text) {
803
+ // reasoner 类模型可能只输出思考过程(无正文结论):以思考收尾句兜底,
804
+ // 并明确标注来源,避免「摘要为空」误报。
805
+ reasoning = reasoning.trim()
806
+ if (reasoning) {
807
+ const lines = reasoning.split(/\n+/).map((s) => s.trim()).filter(Boolean)
808
+ text = lines.slice(-3).join(' ') // 思考的最后几句通常包含结论
809
+ text = text.slice(0, 600)
810
+ }
811
+ }
812
+ if (!text) return sendJson(res, 500, { ok: false, error: 'LLM 摘要生成为空(模型未输出任何内容)' })
813
+
814
+ // 摘要写回索引缓存(下次 get 直接带出;无新对话时可直接复用)。
815
+ // 同时记录生成基线 = 生成摘要时会话的最后事件时间,供 get 对比
816
+ // 判定「此后是否有新对话」(有则摘要可能过时,前端提示重新生成)。
817
+ try {
818
+ const index = readIndex(home, found.workspace)
819
+ if (index.sessions[sessionId]) {
820
+ index.sessions[sessionId].summary = text
821
+ index.sessions[sessionId].summaryLastEventTime = entry.summary ? entry.summary.lastEventTime : null
822
+ writeIndex(home, found.workspace, index)
823
+ }
824
+ } catch (e) {
825
+ console.error('[dsh-session-flow] summary writeback failed:', e)
826
+ }
827
+ return sendJson(res, 200, { ok: true, mode: 'llm', provider, model, summary: text })
828
+ }
829
+
830
+ if (method === 'exportMd') {
831
+ // M5d 导出可读 Markdown 报告(ZIP 分卷):超大会话单文件可达数 MB,
832
+ // 部分查看器打不开——拆为「概览 + 时间线分卷」并打包 ZIP 下载。
833
+ // 概览:元信息 + 摘要 + 工具统计 + 产物清单;时间线按目标体积滚动分卷。
834
+ const sessionId = String(body.sessionId || '')
835
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: 'sessionId required' })
836
+ const found = findWorkspaceOfSession(home, sessionId)
837
+ if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
838
+ let st
839
+ try { st = statSync(found.file) } catch { st = null }
840
+ const entry = cachedSession(home, sessionId, found.file, st ? st.mtimeMs : 0, st ? st.size : 0)
841
+ const sum = entry.summary || summarizeParsed(entry.parsed)
842
+ // 索引中的 LLM 摘要(若有)。
843
+ let llmText = null
844
+ try {
845
+ const index = readIndex(home, found.workspace)
846
+ llmText = (index.sessions[sessionId] && index.sessions[sessionId].summary) || null
847
+ } catch {}
848
+ const title = entry.parsed.title && entry.parsed.title.title !== undefined ? entry.parsed.title.title : null
849
+ // 分卷时间线。
850
+ const chunks = chunkTurns(entry.turns, EXPORT_CHUNK_TARGET)
851
+ // 概览(含分卷指引)。
852
+ const overview = renderOverviewMd({ entry, sum, found, title, llmText, chunkCount: chunks.length })
853
+ const rawTitle = title || sessionId
854
+ const safeTitle = String(rawTitle).replace(/[\\/:*?"<>|\r\n]/g, '_').slice(0, 60)
855
+ // 组装 ZIP 文件清单:概览 + 时间线分卷(00-概览.md / 01-时间线-回合X-Y.md …)。
856
+ const zipFiles = [{ name: '00-概览.md', data: Buffer.from(overview, 'utf8') }]
857
+ chunks.forEach((c, i) => {
858
+ const n = String(i + 1).padStart(2, '0')
859
+ const body = `# 会话报告${title ? ':' + mdEsc(title) : ''} · 时间线 ${c.title}\n\n> 本文件为分卷 ${i + 1}/${chunks.length}(按体积自动拆分,避免单文件过大)。概览与其余分卷见同目录。\n\n` + c.body
860
+ zipFiles.push({ name: `${n}-时间线-${c.title}.md`, data: Buffer.from(body, 'utf8') })
861
+ })
862
+ const zip = buildZip(zipFiles)
863
+ const base64 = zip.toString('base64')
864
+ return sendJson(res, 200, {
865
+ ok: true,
866
+ filename: `会话报告-${safeTitle}.zip`,
867
+ sizeBytes: zip.length,
868
+ uncompressedBytes: zipFiles.reduce((a, f) => a + f.data.length, 0),
869
+ fileCount: zipFiles.length,
870
+ files: zipFiles.map((f) => ({ name: f.name, bytes: f.data.length })),
871
+ base64,
872
+ })
873
+ }
874
+
875
+ if (method === 'getTurn') {
876
+ // 展开回合时按需取完整时间线(复用派生缓存,秒开后的展开也是快的)。
877
+ const sessionId = String(body.sessionId || '')
878
+ const turnNo = Number(body.turn)
879
+ if (!sessionId || !Number.isInteger(turnNo)) return sendJson(res, 400, { ok: false, error: 'sessionId and turn required' })
880
+ const found = findWorkspaceOfSession(home, sessionId)
881
+ if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
882
+ let st
883
+ try { st = statSync(found.file) } catch { st = null }
884
+ const entry = cachedSession(home, sessionId, found.file, st ? st.mtimeMs : 0, st ? st.size : 0)
885
+ const turn = entry.turns.find((t) => t.turn === turnNo)
886
+ if (turn === undefined) return sendJson(res, 404, { ok: false, error: `turn ${turnNo} not found` })
887
+ return sendJson(res, 200, { ok: true, turn })
888
+ }
889
+
890
+ if (method === 'lineage') {
891
+ // M4 血缘树(离线通道):基于会话头的 parentSession 链接构建「以目标会话为根的后代树」。
892
+ // 子代理存档可能不在磁盘(实时通道在 client 端用 subagents API),这里返回离线可见的部分。
893
+ const sessionId = String(body.sessionId || '')
894
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: 'sessionId required' })
895
+ const all = {}
896
+ for (const ws of listWorkspaces(home)) {
897
+ const index = readIndex(home, ws.name)
898
+ for (const [id, s] of Object.entries(index.sessions)) all[id] = s
899
+ }
900
+ const focus = all[sessionId]
901
+ if (focus === undefined) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not indexed` })
902
+ const childrenOf = new Map()
903
+ for (const s of Object.values(all)) {
904
+ if (s.parentSession && all[s.parentSession]) {
905
+ if (!childrenOf.has(s.parentSession)) childrenOf.set(s.parentSession, [])
906
+ childrenOf.get(s.parentSession).push(s)
907
+ }
908
+ }
909
+ const buildNode = (s) => ({
910
+ id: s.id,
911
+ title: s.title || null,
912
+ delegationDepth: s.delegationDepth || 0,
913
+ createdAt: s.createdAt || null,
914
+ lastEventTime: s.lastEventTime || null,
915
+ toolCalls: s.toolCalls || 0,
916
+ toolErrors: s.toolErrors || 0,
917
+ turns: s.turns || 0,
918
+ empty: s.empty === true,
919
+ children: (childrenOf.get(s.id) || []).map(buildNode),
920
+ })
921
+ return sendJson(res, 200, {
922
+ ok: true,
923
+ focus: buildNode(focus),
924
+ })
925
+ }
926
+
927
+ if (method === 'derive') {
928
+ // M4 桥接:运行时子代理会话的事件(来自 subagents.history,不在磁盘存档)
929
+ // 由 host 端用同一套 timeline 管线派生,前端直接渲染折叠视图。
930
+ const events = Array.isArray(body.events) ? body.events : null
931
+ if (events === null || events.length === 0) return sendJson(res, 400, { ok: false, error: 'events required' })
932
+ const parsed = { header: null, title: null, events }
933
+ const summary = summarizeParsed(parsed)
934
+ const timeline = deriveTimeline(parsed)
935
+ const counts = Object.fromEntries(
936
+ Object.entries(summary.counts || {}).filter(([k]) =>
937
+ ['tool/call', 'tool/result', 'user/message', 'assistant/message', 'turn/start', 'step/start', 'step/end'].includes(k)),
938
+ )
939
+ // M6 运行中判定(结构信号,不依赖时间戳):事件流里存在未闭合的回合/步骤/
940
+ // 工具调用(start 未配 end),或最后事件是流式中间态(assistant/chunk 等),
941
+ // 说明会话仍在运行——即使输出间隔长(模型思考/工具执行中)也不会误判停止。
942
+ let openTurns = 0
943
+ let openSteps = 0
944
+ let openTools = 0
945
+ let lastType = null
946
+ for (const ev of events) {
947
+ lastType = ev && ev.type
948
+ switch (lastType) {
949
+ case 'turn/start': openTurns++; break
950
+ case 'turn/end': openTurns = Math.max(0, openTurns - 1); break
951
+ case 'step/start': openSteps++; break
952
+ case 'step/end': openSteps = Math.max(0, openSteps - 1); break
953
+ case 'tool/call': openTools++; break
954
+ case 'tool/result': openTools = Math.max(0, openTools - 1); break
955
+ }
956
+ }
957
+ const STREAM_MID_TYPES = new Set(['assistant/chunk', 'assistant/message', 'tool/call', 'step/start', 'turn/start', 'user/message', 'request/header'])
958
+ const running = openTurns > 0 || openSteps > 0 || openTools > 0 || STREAM_MID_TYPES.has(lastType)
959
+ return sendJson(res, 200, {
960
+ ok: true,
961
+ session: sessionView(summary),
962
+ counts,
963
+ timeline,
964
+ running,
965
+ })
966
+ }
967
+
968
+ if (method === 'searchIn') {
969
+ // M5c 方案C:会话内全文检索 → 返回所有匹配位置(turn + callId/seq + 命中片段)。
970
+ // 匹配域:工具名/参数、工具结果文本、用户发言、助手思考与正文;支持结构化前缀。
971
+ const sessionId = String(body.sessionId || '')
972
+ const query = String(body.query || '')
973
+ if (!sessionId || !query.trim()) return sendJson(res, 400, { ok: false, error: 'sessionId and query required' })
974
+ const found = findWorkspaceOfSession(home, sessionId)
975
+ if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
976
+ let st
977
+ try { st = statSync(found.file) } catch { st = null }
978
+ const { turns } = cachedSession(home, sessionId, found.file, st ? st.mtimeMs : 0, st ? st.size : 0)
979
+
980
+ const q = query.trim().toLowerCase()
981
+ const stMatch = /^(tool|file|path|err|error):(.*)$/.exec(q)
982
+ const stKind = stMatch ? stMatch[1].toLowerCase() : null
983
+ const stVal = stMatch ? stMatch[2].trim().toLowerCase() : ''
984
+
985
+ const matches = []
986
+ const hit = (m) => { if (matches.length < 200) matches.push(m) }
987
+ const snippet = (text, needle) => {
988
+ const t = String(text || '')
989
+ const i = t.toLowerCase().indexOf(needle)
990
+ if (i < 0) return t.slice(0, 100)
991
+ const start = Math.max(0, i - 30)
992
+ return (start > 0 ? '…' : '') + t.slice(start, i + needle.length + 60) + (i + needle.length + 60 < t.length ? '…' : '')
993
+ }
994
+
995
+ for (const t of turns) {
996
+ // 用户发言 / 助手思考与正文:仅自由文本检索时参与(结构化前缀只针对工具域)。
997
+ if (!stMatch) {
998
+ for (const u of t.userMessages) {
999
+ if (u.text.toLowerCase().includes(q)) {
1000
+ hit({ kind: 'user', turn: t.turn, seq: u.seq, preview: snippet(u.text, q) })
1001
+ }
1002
+ }
1003
+ for (const a of t.assistantMessages) {
1004
+ if (a.hasThinking && a.thinking.toLowerCase().includes(q)) {
1005
+ hit({ kind: 'thinking', turn: t.turn, seq: a.seq, preview: snippet(a.thinking, q) })
1006
+ }
1007
+ if (a.hasText && a.text.toLowerCase().includes(q)) {
1008
+ hit({ kind: 'assistant', turn: t.turn, seq: a.seq, preview: snippet(a.text, q) })
1009
+ }
1010
+ }
1011
+ }
1012
+ // 工具调用:结构化(tool/file/err)+ 自由文本(名称/参数/结果)。
1013
+ for (const s of t.steps) {
1014
+ for (const c of s.toolCalls) {
1015
+ const argsLower = c.argumentsText.toLowerCase()
1016
+ const resLower = c.resultText.toLowerCase()
1017
+ let matched = false
1018
+ if (stKind === 'tool') matched = c.name.toLowerCase().includes(stVal) || argsLower.includes(stVal)
1019
+ else if (stKind === 'file' || stKind === 'path') matched = argsLower.includes(stVal) || resLower.includes(stVal)
1020
+ else if (stKind === 'err' || stKind === 'error') matched = c.isError === true
1021
+ else matched = c.name.toLowerCase().includes(q) || argsLower.includes(q) || resLower.includes(q)
1022
+ if (matched) {
1023
+ const inArgs = stKind === 'tool' ? (c.name.toLowerCase().includes(stVal) || argsLower.includes(stVal))
1024
+ : stKind === 'file' || stKind === 'path' ? argsLower.includes(stVal)
1025
+ : argsLower.includes(q)
1026
+ hit({
1027
+ kind: c.isError === true ? 'error' : 'tool',
1028
+ turn: t.turn, callId: c.callId,
1029
+ name: c.name,
1030
+ preview: snippet(inArgs ? c.argumentsText : c.resultText, stVal || q) || c.resultPreview,
1031
+ })
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ return sendJson(res, 200, { ok: true, query, count: matches.length, matches })
1037
+ }
1038
+
1039
+ if (method === 'cacheInfo') {
1040
+ // 缓存管理:统计索引与时间线缓存的体积/数量。
1041
+ const root = indexRoot(home)
1042
+ const info = { root, indexFiles: [], timelineFiles: [], indexBytes: 0, timelineBytes: 0, totalBytes: 0 }
1043
+ try {
1044
+ for (const f of readdirSync(root)) {
1045
+ if (!f.startsWith('index-') || !f.endsWith('.json')) continue
1046
+ const p = join(root, f)
1047
+ const st = statSync(p)
1048
+ info.indexFiles.push({ name: f, bytes: st.size, mtimeMs: st.mtimeMs })
1049
+ info.indexBytes += st.size
1050
+ }
1051
+ } catch {}
1052
+ try {
1053
+ const td = join(root, 'timeline')
1054
+ for (const f of readdirSync(td)) {
1055
+ if (!f.endsWith('.json')) continue
1056
+ const p = join(td, f)
1057
+ const st = statSync(p)
1058
+ info.timelineFiles.push({ name: f, bytes: st.size, mtimeMs: st.mtimeMs })
1059
+ info.timelineBytes += st.size
1060
+ }
1061
+ } catch {}
1062
+ info.totalBytes = info.indexBytes + info.timelineBytes
1063
+ info.timelineLimit = TIMELINE_CACHE_MAX_BYTES
1064
+ return sendJson(res, 200, { ok: true, ...info })
1065
+ }
1066
+
1067
+ if (method === 'cacheClean') {
1068
+ // 清理缓存:what = all | index | timeline;失败不中断,逐个删除。
1069
+ const what = String(body.what || 'all')
1070
+ const root = indexRoot(home)
1071
+ let removed = 0
1072
+ let bytes = 0
1073
+ const targets = []
1074
+ try {
1075
+ for (const f of readdirSync(root)) {
1076
+ if (what === 'timeline') break
1077
+ if (f.startsWith('index-') && f.endsWith('.json')) targets.push(join(root, f))
1078
+ }
1079
+ } catch {}
1080
+ if (what !== 'index') {
1081
+ try {
1082
+ const td = join(root, 'timeline')
1083
+ for (const f of readdirSync(td)) {
1084
+ if (f.endsWith('.json')) targets.push(join(td, f))
1085
+ }
1086
+ } catch {}
1087
+ }
1088
+ for (const p of targets) {
1089
+ try {
1090
+ const st = statSync(p)
1091
+ rmSync(p)
1092
+ removed++
1093
+ bytes += st.size
1094
+ } catch {}
1095
+ }
1096
+ // 内存派生缓存同步失效(避免清理后仍从内存返回旧数据)。
1097
+ if (what !== 'index') timelineCache.clear()
1098
+ return sendJson(res, 200, { ok: true, what, removed, bytes })
1099
+ }
1100
+
1101
+ if (method === 'stats') {
1102
+ const workspaces = []
1103
+ for (const ws of listWorkspaces(home)) {
1104
+ const index = readIndex(home, ws.name)
1105
+ const cacheFile = workspaceIndexFile(home, ws.name)
1106
+ let cacheSize = null
1107
+ let cacheMtime = null
1108
+ try {
1109
+ const st = statSync(cacheFile)
1110
+ cacheSize = st.size
1111
+ cacheMtime = st.mtimeMs
1112
+ } catch {}
1113
+ workspaces.push({ name: ws.name, sessionCount: ws.sessionCount, indexedCount: Object.keys(index.sessions).length, scannedAt: index.scannedAt, cacheSize, cacheMtime })
1114
+ }
1115
+ return sendJson(res, 200, {
1116
+ ok: true,
1117
+ home,
1118
+ indexRoot: indexRoot(home),
1119
+ node: process.version,
1120
+ workspaces,
1121
+ })
1122
+ }
1123
+
1124
+ return sendJson(res, 400, { ok: false, error: `unknown method: ${method}` })
1125
+ } catch (error) {
1126
+ sendJson(res, 500, { ok: false, error: String(error && error.message || error) })
1127
+ }
1128
+ },
1129
+ })
1130
+
1131
+ console.log('[dsh-session-flow] host half up: /api/session-flow (workspaces/list/rescan/get/stats)')
1132
+ }