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.
@@ -0,0 +1,124 @@
1
+ // lib/index-store.js — 会话索引缓存:增量扫描(按 mtime+size 跳过未变文件)+ 落盘缓存。
2
+ //
3
+ // 缓存位置:<dshHome>/session-flow/index-<workspace>.json
4
+ // 格式:{ version, scannedAt, sessions: { [sessionId]: <summarizeParsed 结果> } }
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { listSessionDirs, summarizeSessionFile } from './archive.js'
9
+
10
+ export function dshHome() {
11
+ return process.env.DSH_HOME || join(homedir(), '.dsh')
12
+ }
13
+
14
+ export function indexRoot(home = dshHome()) {
15
+ // DSH_SESSION_FLOW_INDEX_DIR 可覆盖索引落盘位置(开发/测试用,生产默认 <home>/session-flow)。
16
+ return process.env.DSH_SESSION_FLOW_INDEX_DIR || join(home, 'session-flow')
17
+ }
18
+
19
+ /** 工作区名 → 安全缓存文件名。 */
20
+ export function workspaceIndexFile(home, wsName) {
21
+ const safe = String(wsName).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
22
+ return join(indexRoot(home), `index-${safe}.json`)
23
+ }
24
+
25
+ /** 索引格式版本:升级时旧缓存会被自动全量重扫一次(避免字段缺失)。 */
26
+ const INDEX_VERSION = 3
27
+
28
+ export function readIndex(home, wsName) {
29
+ try {
30
+ const raw = JSON.parse(readFileSync(workspaceIndexFile(home, wsName), 'utf8'))
31
+ if (raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object') {
32
+ return { version: raw.version || 1, scannedAt: raw.scannedAt || 0, sessions: raw.sessions }
33
+ }
34
+ } catch {}
35
+ return { version: 1, scannedAt: 0, sessions: {} }
36
+ }
37
+
38
+ export function writeIndex(home, wsName, index) {
39
+ const dir = indexRoot(home)
40
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
41
+ const file = workspaceIndexFile(home, wsName)
42
+ const tmp = `${file}.tmp`
43
+ writeFileSync(tmp, JSON.stringify(index, null, 2))
44
+ renameSync(tmp, file)
45
+ }
46
+
47
+ /**
48
+ * 增量扫描一个工作区的会话索引。
49
+ * @param {string} home - dsh home 目录。
50
+ * @param {string} wsName - 工作区目录名(sessions/ 下的目录名)。
51
+ * @param {{ force?: boolean }} options - force 时忽略缓存全量重扫。
52
+ * @returns {{ index, scanned, skipped, removed }}
53
+ */
54
+ export function scanWorkspaceIndex(home, wsName, options = {}) {
55
+ const index = readIndex(home, wsName)
56
+ // 索引格式升级:旧版本缓存缺新字段(如 toolNames),必须全量重扫一次。
57
+ const force = Boolean(options && options.force) || index.version < INDEX_VERSION
58
+ let scanned = 0
59
+ let skipped = 0
60
+ const removed = []
61
+ const seen = new Set()
62
+
63
+ const dirs = listSessionDirs(join(home, 'sessions', wsName))
64
+ for (const entry of dirs) {
65
+ seen.add(entry.id)
66
+ const cached = index.sessions[entry.id]
67
+ if (!force && cached && cached.fileMtimeMs === entry.mtimeMs && cached.sizeBytes === entry.sizeBytes) {
68
+ skipped++
69
+ continue
70
+ }
71
+ try {
72
+ const { summary } = summarizeSessionFile(entry.file, { mtimeMs: entry.mtimeMs, sizeBytes: entry.sizeBytes })
73
+ // 保留旧索引中的 LLM 摘要及其生成基线(会话有新对话时由
74
+ // summaryLastEventTime 与当前 lastEventTime 对比判定过期):
75
+ // 解析结果整体覆盖会丢掉已落盘的摘要,导致「无新对话也需重新生成」。
76
+ index.sessions[entry.id] = {
77
+ ...summary,
78
+ summary: (cached && cached.summary) || null,
79
+ summaryLastEventTime: (cached && cached.summaryLastEventTime) || null,
80
+ }
81
+ scanned++
82
+ } catch (error) {
83
+ // 单个会话解析失败不阻塞整个工作区(torn 文件等):保留旧缓存并记录错误。
84
+ const reason = String(error && error.message || error)
85
+ index.sessions[entry.id] = {
86
+ id: entry.id,
87
+ fileMtimeMs: entry.mtimeMs,
88
+ sizeBytes: entry.sizeBytes,
89
+ parseError: reason,
90
+ recordCount: cached && cached.recordCount || 0,
91
+ ...(cached || {}),
92
+ fileMtimeMs: entry.mtimeMs,
93
+ sizeBytes: entry.sizeBytes,
94
+ parseError: reason,
95
+ }
96
+ }
97
+ }
98
+ for (const id of Object.keys(index.sessions)) {
99
+ if (!seen.has(id)) {
100
+ delete index.sessions[id]
101
+ removed.push(id)
102
+ }
103
+ }
104
+ index.scannedAt = Date.now()
105
+ index.version = INDEX_VERSION
106
+ writeIndex(home, wsName, index)
107
+ return { index, scanned, skipped, removed }
108
+ }
109
+
110
+ /** 按会话 id 在所有工作区中定位存档(返回 { workspace, dir, file } 或 null)。 */
111
+ export function findWorkspaceOfSession(home, sessionId) {
112
+ const root = join(home, 'sessions')
113
+ if (!existsSync(root)) return null
114
+ for (const wsName of readdirSync(root)) {
115
+ const wsDir = join(root, wsName)
116
+ try { if (!statSync(wsDir).isDirectory()) continue } catch { continue }
117
+ const dir = join(wsDir, sessionId)
118
+ const zstd = join(dir, 'session.jsonl.zstd')
119
+ if (existsSync(zstd)) return { workspace: wsName, dir, file: zstd }
120
+ const plain = join(dir, 'session.jsonl')
121
+ if (existsSync(plain)) return { workspace: wsName, dir, file: plain }
122
+ }
123
+ return null
124
+ }
@@ -0,0 +1,257 @@
1
+ // lib/timeline.js — 从解析后的会话事件派生「信息流」视图:
2
+ // 回合(Turn) → 步骤(Step) → 工具调用(ToolCall) → 产物(Artifact)
3
+ // 供 M3 折叠详情页与 M5 摘要引擎共同消费。
4
+ import { extractPaths, parseSession } from './archive.js'
5
+
6
+ /** 子代理工具结果中的会话 ID("started subagent <uuid>")。 */
7
+ const SUBAGENT_ID_RE = /started subagent\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
8
+
9
+ /**
10
+ * 从 tool/result 的文本内容里截取预览。
11
+ * 文本可能直接是顶层 text 块,也可能嵌套在 tool-result 块里
12
+ * (content[0].content[0].text),递归提取所有 text 字段。
13
+ */
14
+ export function resultTextOf(resultEvent) {
15
+ const blocks = resultEvent && resultEvent.data && resultEvent.data.message && resultEvent.data.message.content
16
+ if (!Array.isArray(blocks)) return ''
17
+ const texts = []
18
+ const walk = (b) => {
19
+ if (b === null || typeof b !== 'object') return
20
+ if (typeof b.text === 'string' && b.text.length > 0) texts.push(b.text)
21
+ if (Array.isArray(b.content)) {
22
+ for (const c of b.content) walk(c)
23
+ }
24
+ }
25
+ for (const b of blocks) walk(b)
26
+ return texts.join('\n').trim()
27
+ }
28
+
29
+ const preview = (text, max = 400) => {
30
+ const t = String(text || '')
31
+ return t.length > max ? t.slice(0, max) + '…' : t
32
+ }
33
+
34
+ /** 事件文本内容(user/assistant 消息的 text 块拼接预览)。 */
35
+ export function contentPreviewOf(content) {
36
+ if (!Array.isArray(content)) return ''
37
+ return content
38
+ .map((b) => {
39
+ if (b && b.type === 'text' && typeof b.text === 'string') return b.text
40
+ if (b && b.type === 'reasoning' && typeof b.text === 'string') return '[思考] ' + b.text
41
+ if (b && b.type === 'tool-result') return '[工具结果]'
42
+ return ''
43
+ })
44
+ .join('\n')
45
+ .trim()
46
+ }
47
+
48
+ /** 提取 assistant 消息中的思考块(reasoning)全文。 */
49
+ export function thinkingTextOf(content) {
50
+ if (!Array.isArray(content)) return ''
51
+ return content
52
+ .filter((b) => b && b.type === 'reasoning' && typeof b.text === 'string')
53
+ .map((b) => b.text)
54
+ .join('\n')
55
+ .trim()
56
+ }
57
+
58
+ /** 提取 assistant 消息中的正文文本块(text,不含思考)——即「最终结论」内容。 */
59
+ export function textTextOf(content) {
60
+ if (!Array.isArray(content)) return ''
61
+ return content
62
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
63
+ .map((b) => b.text)
64
+ .join('\n')
65
+ .trim()
66
+ }
67
+
68
+ /**
69
+ * 从解析结果派生时间线。
70
+ * @param {{ header, title, events }} parsed - parseSession 的输出。
71
+ * @returns {{ turns, artifacts, summary }} turns 按顺序;artifacts 为全局产物路径集合。
72
+ */
73
+ export function deriveTimeline(parsed) {
74
+ const { events } = parsed
75
+ const turns = []
76
+ const turnMap = new Map()
77
+ let currentStep = null // { step, startTime, endTime, toolCalls }
78
+ let currentTurn = null
79
+
80
+ const ensureTurn = (turnNo) => {
81
+ let t = turnMap.get(turnNo)
82
+ if (t === undefined) {
83
+ t = { turn: turnNo, startTime: null, endTime: null, userMessages: [], injectedMessages: [], assistantMessages: [], steps: [], items: [] }
84
+ turnMap.set(turnNo, t)
85
+ turns.push(t)
86
+ }
87
+ return t
88
+ }
89
+
90
+ for (const ev of events) {
91
+ switch (ev.type) {
92
+ case 'turn/start': {
93
+ currentTurn = ensureTurn(ev.data && ev.data.turn)
94
+ currentTurn.startTime = currentTurn.startTime ?? ev.time
95
+ break
96
+ }
97
+ case 'turn/end': {
98
+ // 记录回合闭合时间:未收到 turn/end 的回合保持 endTime=null → 运行中信号。
99
+ if (currentTurn && currentTurn.turn === (ev.data && ev.data.turn)) currentTurn.endTime = ev.time
100
+ else {
101
+ const t = turnMap.get(ev.data && ev.data.turn)
102
+ if (t) t.endTime = ev.time
103
+ }
104
+ break
105
+ }
106
+ case 'step/start': {
107
+ currentTurn = currentTurn || ensureTurn(ev.data && ev.data.turn)
108
+ const stepNo = ev.data && ev.data.step
109
+ currentStep = { step: stepNo, startTime: ev.time, endTime: null, toolCalls: [] }
110
+ currentTurn.steps.push(currentStep)
111
+ // 时间序:步骤头(渲染时在所属工具调用前显示)。
112
+ currentTurn.items.push({ kind: 'step', seq: ev.seq, time: ev.time, step: stepNo })
113
+ break
114
+ }
115
+ case 'step/end': {
116
+ if (currentStep && currentStep.step === (ev.data && ev.data.step)) currentStep.endTime = ev.time
117
+ break
118
+ }
119
+ case 'user/message': {
120
+ currentTurn = currentTurn || ensureTurn(ev.data && ev.data.turn)
121
+ const text = contentPreviewOf(ev.data && ev.data.content)
122
+ // 来源分类:只有 source.kind === 'user' 才是真正的用户发言;
123
+ // plugin / goal / skill-catalog 等是智能体/插件注入的信息,单独归类。
124
+ const sourceKind = ev.data && ev.data.source ? String(ev.data.source.kind || '') : ''
125
+ const isInjected = sourceKind !== '' && sourceKind !== 'user'
126
+ const entry = { seq: ev.seq, time: ev.time, text, preview: preview(text), sourceKind: sourceKind || null }
127
+ if (isInjected) currentTurn.injectedMessages.push(entry)
128
+ else currentTurn.userMessages.push(entry)
129
+ // 时间序:用户发言(kind: user)或注入信息(kind: inject,保持真实位置)。
130
+ currentTurn.items.push({ kind: isInjected ? 'inject' : 'user', ...entry })
131
+ break
132
+ }
133
+ case 'assistant/message': {
134
+ currentTurn = currentTurn || ensureTurn(ev.data && ev.data.turn)
135
+ const content = ev.data && ev.data.message && ev.data.message.content
136
+ const thinking = thinkingTextOf(content)
137
+ const text = textTextOf(content)
138
+ currentTurn.assistantMessages.push({
139
+ seq: ev.seq, time: ev.time,
140
+ turn: ev.data && ev.data.turn, step: ev.data && ev.data.step,
141
+ thinking, thinkingPreview: preview(thinking, 800),
142
+ text, preview: preview(text, 1200),
143
+ hasThinking: thinking.length > 0,
144
+ hasText: text.length > 0,
145
+ })
146
+ // 时间序:助手消息(思考 + 正文结论,与工具调用真实交织)。
147
+ currentTurn.items.push({
148
+ kind: 'assistant', seq: ev.seq, time: ev.time,
149
+ thinking, thinkingPreview: preview(thinking, 800),
150
+ text, preview: preview(text, 1200),
151
+ hasThinking: thinking.length > 0,
152
+ hasText: text.length > 0,
153
+ })
154
+ break
155
+ }
156
+ case 'tool/call': {
157
+ const d = ev.data || {}
158
+ currentTurn = currentTurn || ensureTurn(d.turn)
159
+ let step = currentStep
160
+ if (!step || step.step !== d.step) {
161
+ step = currentTurn.steps.find((s) => s.step === d.step) || null
162
+ if (step === null && currentTurn.steps.length > 0) step = currentTurn.steps[currentTurn.steps.length - 1]
163
+ }
164
+ if (step === null) {
165
+ step = { step: d.step, startTime: ev.time, endTime: null, toolCalls: [] }
166
+ currentTurn.steps.push(step)
167
+ currentStep = step
168
+ }
169
+ step.toolCalls.push({
170
+ seq: ev.seq, time: ev.time,
171
+ callId: d.callId, name: d.name,
172
+ argumentsText: String(d.arguments || ''),
173
+ argumentsPreview: preview(String(d.arguments || ''), 300),
174
+ resultTime: null, isError: null, resultText: '', resultPreview: '',
175
+ durationMs: null,
176
+ artifacts: [...extractPaths(d.name, d.arguments)],
177
+ })
178
+ // 时间序:工具调用行(引用 step.toolCalls 里的完整对象,渲染时取用)。
179
+ currentTurn.items.push({
180
+ kind: 'tool', seq: ev.seq, time: ev.time, step: d.step,
181
+ call: step.toolCalls[step.toolCalls.length - 1],
182
+ })
183
+ break
184
+ }
185
+ case 'tool/result': {
186
+ const d = ev.data || {}
187
+ const callId = d.message && d.message.source && d.message.source.callId
188
+ if (callId) {
189
+ // 在当前回合内找最近的匹配工具调用(callId 或 seq 回链)。
190
+ let target = null
191
+ const targets = (currentTurn && currentTurn.steps) || []
192
+ for (const step of targets) {
193
+ for (const tc of step.toolCalls) {
194
+ if (tc.callId === callId) { target = tc; break }
195
+ }
196
+ if (target) break
197
+ }
198
+ if (!target) {
199
+ // 兜底:全量扫描已见调用。
200
+ outer:
201
+ for (const t of turns) {
202
+ for (const step of t.steps) {
203
+ for (const tc of step.toolCalls) {
204
+ if (tc.callId === callId) { target = tc; break outer }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ if (target) {
210
+ target.resultTime = ev.time
211
+ target.resultText = resultTextOf(ev)
212
+ target.resultPreview = preview(target.resultText, 400)
213
+ target.isError = Array.isArray(d.message && d.message.content)
214
+ ? d.message.content.some((b) => b && b.isError === true)
215
+ : false
216
+ target.durationMs = target.resultTime !== null && target.time !== null ? target.resultTime - target.time : null
217
+ // 子代理工具:结果文本 "started subagent <uuid>" → 关联子代理会话 ID,
218
+ // 前端可一键跳转查看该子代理的实际执行内容(而非只显示这行摘要)。
219
+ // 仅对子代理类工具名提取,避免普通工具输出里的同文本误匹配。
220
+ if (/^subagent/i.test(target.name)) {
221
+ const m = SUBAGENT_ID_RE.exec(target.resultText)
222
+ if (m) target.childSessionId = m[1]
223
+ }
224
+ }
225
+ }
226
+ break
227
+ }
228
+ default:
229
+ break
230
+ }
231
+ }
232
+
233
+ for (const t of turns) {
234
+ // 回合闭合时间优先取 turn/end 事件;未收到 turn/end 时回退到「最后完成的步骤」
235
+ // (step/end 全部出现 ≈ 回合完成;若步骤也未闭合则保持 null → 运行中信号)。
236
+ if (t.endTime === null) {
237
+ t.endTime = t.steps.reduce((acc, s) => (s.endTime !== null && s.endTime > acc ? s.endTime : acc), t.startTime)
238
+ }
239
+ }
240
+
241
+ const artifacts = []
242
+ const seenArtifacts = new Set()
243
+ for (const t of turns) {
244
+ for (const step of t.steps) {
245
+ for (const tc of step.toolCalls) {
246
+ for (const p of tc.artifacts || []) {
247
+ if (!seenArtifacts.has(p)) {
248
+ seenArtifacts.add(p)
249
+ artifacts.push({ path: p, firstSeen: { turn: t.turn, step: step.step, callId: tc.callId } })
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ return { turns, artifacts }
257
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "dsh-session-flow",
3
+ "description": "会话信息流重设计插件:折叠汇总 + 会话总览归档 + 深链跳转 + 子代理血缘树 + 摘要引擎(规则/LLM 双模式),实时复用原生轨迹数据",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/host.js",
7
+ "exports": {
8
+ ".": "./lib/host.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "README.en.md"
18
+ ],
19
+ "keywords": [
20
+ "dsh",
21
+ "agent-harness",
22
+ "plugin",
23
+ "web",
24
+ "ui",
25
+ "session",
26
+ "trajectory"
27
+ ],
28
+ "license": "Apache-2.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/YeqingTang/dsh-session-flow.git"
32
+ },
33
+ "engines": {
34
+ "node": "^22.19.0 || >=24.0.0"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web"
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^18.2.0",
46
+ "react-dom": "^18.2.0",
47
+ "@deepseek-ai/cordis": "*",
48
+ "@deepseek-ai/dsh-client-runtime": "*",
49
+ "@deepseek-ai/dsh-client-connection": "*"
50
+ }
51
+ }