dsh-visualization 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/fold.cjs ADDED
@@ -0,0 +1,835 @@
1
+ // bridge.cjs —— 把 DSH 真实会话状态喂给 instrument.html
2
+ //
3
+ // node bridge.cjs → http://127.0.0.1:8787
4
+ // node bridge.cjs 9000 → 换端口
5
+ // node bridge.cjs 8787 <sessionId> → 指定会话(默认取当前工作区最新的)
6
+ //
7
+ // 为什么需要它:页面如果是 file:// 打开的,浏览器不允许它读 ~/.dsh 下的文件。
8
+ // 桥用 node 读会话日志(zstd 压缩的 JSONL),折成一份小 JSON,页面每 900ms 拉一次。
9
+ //
10
+ // 数据源(只读,不写任何东西):
11
+ // ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
12
+ 'use strict'
13
+ const fs = require('node:fs')
14
+ const path = require('node:path')
15
+ const os = require('node:os')
16
+ const zlib = require('node:zlib')
17
+
18
+ const PORT = 0
19
+ /* CURRENT = 当前盯着的会话 id;null 表示"自动跟最新"。可以在页面上切换。 */
20
+ let CURRENT = null
21
+ const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
22
+ const SESSIONS_ROOT = process.env.DSHVIZ_SESSIONS_ROOT || path.join(DSH_HOME, 'sessions')
23
+ /* 老一些的 Node 没有 zstd API(22.15 才有);没有就给出人话,而不是 TypeError */
24
+ const HAS_ZSTD = typeof zlib.zstdDecompressSync === 'function'
25
+ const PROJ_CACHE = path.join(DSH_HOME, 'storages', 'session_projcache', 'sessions')
26
+ const PAGE = path.join(__dirname, 'web', 'index.html')
27
+
28
+
29
+ /* 日志是「多帧 zstd」追加写的:较老的 Node 的 zstdDecompressSync 只解第一帧。
30
+ 这里按 zstd 帧头魔数 28 B5 2F FD 切开,逐帧解压再拼起来。 */
31
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
32
+
33
+ /* 按 RFC 8878 走一遍帧结构,得到精确边界(不再靠扫魔数猜):
34
+ 帧头 = 魔数(4) + FHD(1) + [Window_Descriptor] + [Dictionary_ID] + [Frame_Content_Size]
35
+ 块 = 3 字节块头 + 块体(RLE 块只有 1 字节)+ ... 直到 Last_Block
36
+ 末尾可能还有 4 字节 content checksum。
37
+ 解析不动数据,只算长度;遇到写了一半的尾帧就停在那里。 */
38
+ function zstdFrameEnd(buf, off) {
39
+ if (off + 5 > buf.length) return -1
40
+ if (buf[off] !== 0x28 || buf[off + 1] !== 0xb5 || buf[off + 2] !== 0x2f || buf[off + 3] !== 0xfd) return -1
41
+ let p = off + 4
42
+ const fhd = buf[p++]
43
+ const fcsFlag = fhd >> 6
44
+ const singleSegment = (fhd >> 5) & 1
45
+ const hasChecksum = (fhd >> 2) & 1
46
+ const dictFlag = fhd & 3
47
+ if (!singleSegment) p += 1
48
+ p += [0, 1, 2, 4][dictFlag]
49
+ p += (fcsFlag === 0) ? (singleSegment ? 1 : 0) : [2, 4, 8][fcsFlag - 1]
50
+ if (p > buf.length) return -1
51
+ for (;;) {
52
+ if (p + 3 > buf.length) return -1
53
+ const h = buf[p] | (buf[p + 1] << 8) | (buf[p + 2] << 16)
54
+ p += 3
55
+ const last = h & 1
56
+ const type = (h >> 1) & 3
57
+ const size = h >>> 3
58
+ if (type === 3) return -1 /* 保留类型,说明这不是合法的块头 */
59
+ p += (type === 1) ? 1 : size /* RLE 块在流里只占 1 字节 */
60
+ if (p > buf.length) return -1
61
+ if (last) break
62
+ }
63
+ if (hasChecksum) p += 4
64
+ if (p > buf.length) return -1
65
+ return p
66
+ }
67
+
68
+ /* 返回 [{start, end}],解析不了就停(尾帧没写完就是这种情况) */
69
+ function zstdFrames(buf) {
70
+ const out = []
71
+ let off = 0
72
+ while (off + 5 <= buf.length) {
73
+ const end = zstdFrameEnd(buf, off)
74
+ if (end < 0) break
75
+ out.push({ start: off, end: end })
76
+ off = end
77
+ }
78
+ return out
79
+ }
80
+
81
+ /* 兼容旧接口:只要起始位置 */
82
+ function zstdFrameHeads(buf) {
83
+ const fr = zstdFrames(buf)
84
+ return fr.length ? fr.map(function (f) { return f.start }) : [0]
85
+ }
86
+ function unzstdAll(buf) {
87
+ const frames = zstdFrames(buf)
88
+ if (frames.length <= 1) return zlib.zstdDecompressSync(buf)
89
+ const parts = []
90
+ for (const f of frames) {
91
+ try { parts.push(zlib.zstdDecompressSync(buf.slice(f.start, f.end))) } catch (e) {
92
+ /* 单帧损坏就跳过它,别让整份日志都读不出来 */
93
+ }
94
+ }
95
+ return Buffer.concat(parts)
96
+ }
97
+
98
+ /* ---------------- 找会话文件 ---------------- */
99
+ function workspaceDir(cwd) {
100
+ const enc = '--' + cwd.replace(/[:\\/]+/g, '-').replace(/^-+|-+$/g, '') + '--'
101
+ const p = path.join(SESSIONS_ROOT, enc)
102
+ if (fs.existsSync(p)) return p
103
+ const base = path.basename(cwd).toLowerCase()
104
+ const hit = fs.readdirSync(SESSIONS_ROOT).find((d) => d.toLowerCase().includes(base))
105
+ return hit ? path.join(SESSIONS_ROOT, hit) : null
106
+ }
107
+
108
+ /* 列出所有会话(跨工作区),按最近写盘排序 */
109
+ /* 会话日志文件名带版本号(session.v4.jsonl.zstd)。DSH 升版后不要直接失效,
110
+ 取目录里版本号最大的那个。 */
111
+ function logFileOf(dirPath) {
112
+ let best = null, bestV = -1
113
+ try {
114
+ const names = fs.readdirSync(dirPath)
115
+ for (const nm of names) {
116
+ const m = /^session\.v(\d+)\.jsonl\.zstd$/.exec(nm)
117
+ if (!m) continue
118
+ const v = Number(m[1])
119
+ if (v > bestV) { bestV = v; best = nm }
120
+ }
121
+ } catch (e) { }
122
+ return best ? path.join(dirPath, best) : path.join(dirPath, 'session.v4.jsonl.zstd')
123
+ }
124
+
125
+ function listSessions(limit) {
126
+ const out = []
127
+ let dirs = []
128
+ try { dirs = fs.readdirSync(SESSIONS_ROOT) } catch (e) { return out }
129
+ for (const ws of dirs) {
130
+ let ids = []
131
+ try { ids = fs.readdirSync(path.join(SESSIONS_ROOT, ws)) } catch (e) { continue }
132
+ for (const id of ids) {
133
+ if (!id.startsWith('session-')) continue
134
+ const file = logFileOf(path.join(SESSIONS_ROOT, ws, id))
135
+ let st
136
+ try { st = fs.statSync(file) } catch (e) { continue }
137
+ out.push({ id, ws, file, mtime: st.mtimeMs, bytes: st.size })
138
+ }
139
+ }
140
+ out.sort((a, b) => b.mtime - a.mtime)
141
+ const top = out.slice(0, limit || 40)
142
+ /* 从投影缓存的前几百字节里抠出真实 cwd(比解码目录名靠谱) */
143
+ for (const s of top) {
144
+ s.cwd = ''
145
+ try {
146
+ const fd = fs.openSync(path.join(PROJ_CACHE, s.id + '.json'), 'r')
147
+ const buf = Buffer.alloc(700)
148
+ const n = fs.readSync(fd, buf, 0, 700, 0)
149
+ fs.closeSync(fd)
150
+ const m = buf.slice(0, n).toString('utf8').match(/"cwd":\s*"((?:[^"\\]|\\.)*)"/)
151
+ if (m) s.cwd = JSON.parse('"' + m[1] + '"')
152
+ } catch (e) { }
153
+ s.short = s.id.replace('session-', '').slice(0, 8)
154
+ if (!s.cwd) s.cwd = s.ws.replace(/^--|--$/g, '')
155
+ delete s.file
156
+ }
157
+ return top
158
+ }
159
+
160
+ function findById(id) {
161
+ const want = 'session-' + String(id).replace(/^session-/, '')
162
+ let dirs = []
163
+ try { dirs = fs.readdirSync(SESSIONS_ROOT) } catch (e) { return null }
164
+ for (const ws of dirs) {
165
+ const file = logFileOf(path.join(SESSIONS_ROOT, ws, want))
166
+ try {
167
+ const st = fs.statSync(file)
168
+ return { id: want, ws, file, mtime: st.mtimeMs }
169
+ } catch (e) { }
170
+ }
171
+ return null
172
+ }
173
+
174
+ function pickSession() {
175
+ if (CURRENT) {
176
+ const hit = findById(CURRENT)
177
+ if (hit) return hit
178
+ }
179
+ /* 插件跑在 harness 进程里,cwd 不是用户工作区 —— 直接跨工作区取最近写盘的那个 */
180
+ const list = listSessions(1)
181
+ if (list.length) {
182
+ const hit = findById(list[0].id)
183
+ if (hit) return hit
184
+ }
185
+ return null
186
+ }
187
+
188
+ /* ---------------- 折叠事件 → 状态 ---------------- */
189
+ const clip = (s, n) => {
190
+ s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
191
+ return s.length > n ? s.slice(0, n - 1) + '…' : s
192
+ }
193
+ function textOf(content) {
194
+ if (!Array.isArray(content)) return ''
195
+ return content.filter((c) => c && c.type === 'text').map((c) => c.text).join(' ')
196
+ }
197
+ function shortArgs(raw, max) {
198
+ const lim = max || 88
199
+ if (!raw) return ''
200
+ try {
201
+ const o = typeof raw === 'string' ? JSON.parse(raw) : raw
202
+ const first = Object.entries(o).map(([k, v]) => k + '=' + (typeof v === 'string' ? v : JSON.stringify(v)))
203
+ return clip(first.join(' '), lim)
204
+ } catch (e) { return clip(raw, lim) }
205
+ }
206
+
207
+ /* 并发度:把每次工具调用看成一段区间,扫一遍算出「同时有几个在跑」
208
+ 返回最忙的并行数 + 重叠区间(给环上的弧段用) */
209
+ function concurrency(tools, winMs) {
210
+ const cuts = []
211
+ const from = Date.now() - (winMs || 120000)
212
+ for (const t of tools) {
213
+ const a = t.t
214
+ const b = t.t + (t.ms == null ? (Date.now() - t.t) : t.ms) // 没回来的算「还在跑」
215
+ if (b < from) continue
216
+ cuts.push([Math.max(a, from), 1], [b, -1])
217
+ }
218
+ cuts.sort((x, y) => x[0] - y[0] || x[1] - y[1])
219
+ let n = 0, max = 0, prev = null
220
+ const segs = []
221
+ for (const [at, d] of cuts) {
222
+ if (prev != null && at > prev && n >= 2) {
223
+ if (at - prev >= 30) segs.push({ a: prev, b: at, n })
224
+ }
225
+ n += d
226
+ if (n > max) max = n
227
+ prev = at
228
+ }
229
+ return { max, segs: segs.slice(-60) }
230
+ }
231
+
232
+ function makeFolder() {
233
+ const S = {
234
+ session: { id: '', cwd: '', preset: '', depth: 0, title: '', createdAt: 0 },
235
+ turn: 0, step: 0, turnOpen: false, turns: 0, model: '', provider: '',
236
+ ctxWin: 0, ctxUsed: 0,
237
+ tok: { in: 0, out: 0, cache: 0 },
238
+ tools: [], toolCount: 0, pending: new Set(),
239
+ jobs: [], steps: [], turns: [], pendingPrompt: '', skills: [], ticks: [], toolAgg: {},
240
+ approvalsList: [], changes: [], delivs: [],
241
+ subagents: 0, approvals: 0, todos: 0, files: 0,
242
+ log: [], lastEvent: 0, lastAssistant: 0, prevAssistant: 0, lastOut: 0,
243
+ text: '', errs: 0
244
+ }
245
+ S.turnRec = []
246
+ const byCall = new Map()
247
+ const byJob = new Map()
248
+ let NOWT = 0
249
+ let NOWTURN = 0
250
+
251
+ /* 步骤节奏:每个 step 从 step/start 到下一条 step/start(或本轮结束)的时长 */
252
+ const closeStep = (at) => {
253
+ const s = S.steps[S.steps.length - 1]
254
+ if (s && !s.end && at > s.t) s.end = at
255
+ }
256
+
257
+ const addLog = (layer, name, detail, ms, ok) => {
258
+ S.log.push({
259
+ atMs: NOWT, turn: NOWTURN, layer, name: clip(name, 40),
260
+ detail: clip(detail, 120), ms: ms == null ? null : ms, ok: ok !== false
261
+ })
262
+ if (S.log.length > 400) S.log.shift()
263
+ return S.log[S.log.length - 1]
264
+ }
265
+
266
+ const push = function (e) {
267
+ const d = e.data || {}
268
+ NOWT = e.time || 0
269
+ if (typeof d.turn === 'number') NOWTURN = d.turn
270
+ S.lastEvent = Math.max(S.lastEvent, e.time || 0)
271
+ switch (e.type) {
272
+ case 'session': {
273
+ const sd = (e.id ? e : (e.data || {}))
274
+ S.session.id = sd.id || ''
275
+ S.session.cwd = sd.cwd || ''
276
+ S.session.preset = sd.agentPreset || ''
277
+ S.session.depth = sd.delegationDepth || 0
278
+ S.session.createdAt = sd.createdAt || 0
279
+ break
280
+ }
281
+ case 'session/title':
282
+ S.session.title = d.title || ''
283
+ break
284
+ case 'request/context':
285
+ if (d.contextWindow) S.ctxWin = d.contextWindow
286
+ if (d.model) S.model = d.model
287
+ if (d.provider) S.provider = d.provider
288
+ break
289
+ case 'turn/start':
290
+ S.turn = d.turn; S.step = 0; S.turnOpen = true
291
+ S.turnRec.push({
292
+ no: d.turn, start: e.time, end: 0, prompt: S.pendingPrompt || '',
293
+ steps: 0, tools: 0, files: 0, reason: ''
294
+ })
295
+ S.pendingPrompt = ''
296
+ addLog('session', 'TURN ' + d.turn, '会话开始')
297
+ break
298
+ case 'turn/end': {
299
+ S.turnOpen = false
300
+ closeStep(e.time)
301
+ const tr = S.turnRec[S.turnRec.length - 1]
302
+ if (tr && !tr.end) { tr.end = e.time; tr.reason = clip(d.reason || '', 40) }
303
+ addLog('session', 'TURN ' + d.turn, '结束 · ' + clip(d.reason || '', 20))
304
+ break
305
+ }
306
+ case 'step/start': {
307
+ S.turn = d.turn; S.step = d.step
308
+ closeStep(e.time)
309
+ S.steps.push({ turn: d.turn, step: d.step, t: e.time, end: 0 })
310
+ const t2 = S.turnRec[S.turnRec.length - 1]
311
+ if (t2) t2.steps++
312
+ break
313
+ }
314
+ case 'user/message': {
315
+ const txt = textOf(d.content)
316
+ const kind = (d.source && d.source.kind) || 'user'
317
+ /* 只把真人说的话当"这一轮的请求";runtime-context / time-context / skill-catalog 是系统注入的 */
318
+ if (kind === 'user') {
319
+ const cur = S.turnRec[S.turnRec.length - 1]
320
+ if (cur && !cur.end && !cur.prompt) cur.prompt = clip(txt, 60)
321
+ else S.pendingPrompt = clip(txt, 60)
322
+ }
323
+ addLog('user', kind === 'user' ? 'user' : kind, txt)
324
+ break
325
+ }
326
+ case 'assistant/message': {
327
+ S.turn = d.turn || S.turn; S.step = d.step || S.step
328
+ const u = d.usage
329
+ if (u) {
330
+ S.tok.in += u.inputTokens || 0
331
+ S.tok.out += u.outputTokens || 0
332
+ S.tok.cache += u.cacheReadTokens || 0
333
+ S.ctxUsed = u.totalTokens || S.ctxUsed
334
+ S.prevAssistant = S.lastAssistant
335
+ S.lastAssistant = e.time
336
+ S.lastOut = u.outputTokens || 0
337
+ }
338
+ const blocks = (d.message && d.message.content) || []
339
+ const txt = textOf(blocks)
340
+ if (txt) { S.text = clip(txt, 160); addLog('session', 'assistant', clip(txt, 90)) }
341
+ break
342
+ }
343
+ case 'tool/call': {
344
+ S.toolCount++
345
+ const rec = { name: d.name, args: shortArgs(d.arguments).slice(0, 40), t: e.time, ms: null, ok: true, id: d.callId, turn: d.turn || 0, step: d.step || 0 }
346
+ S.tools.push(rec)
347
+ if (S.tools.length > 1500) S.tools.shift()
348
+ /* 常驻记忆:全程每次调用的时间点(给环上的淡痕),以及按工具的累计 */
349
+ S.ticks.push(e.time)
350
+ if (S.ticks.length > 1500) S.ticks.shift()
351
+ const agg = S.toolAgg[d.name] || (S.toolAgg[d.name] = { name: d.name, count: 0, ms: 0, fail: 0, last: 0 })
352
+ agg.count++; agg.last = e.time
353
+ const curT = S.turnRec[S.turnRec.length - 1]
354
+ if (curT) curT.tools++
355
+ byCall.set(d.callId, rec)
356
+ S.pending.add(d.callId)
357
+ if (d.name === 'skill') {
358
+ let nm = 'skill'
359
+ try { nm = JSON.parse(d.arguments).name || nm } catch (err) { }
360
+ const sk = S.skills.find((s) => s.name === nm)
361
+ if (sk) { sk.uses++; sk.last = e.time } else S.skills.push({ name: nm, uses: 1, last: e.time })
362
+ }
363
+ if (d.name === 'subagent' || d.name === 'subagent_fork') S.subagents++
364
+ rec.entry = addLog('tool', d.name, shortArgs(d.arguments), null, true)
365
+ break
366
+ }
367
+ case 'tool/result': {
368
+ // 注意:调用 id 藏在 message.source.callId 里,data 顶层没有 toolCallId
369
+ const rid = d.toolCallId
370
+ || (d.message && d.message.source && d.message.source.callId)
371
+ || (d.message && d.message.toolCallId) || ''
372
+ const rec = byCall.get(rid)
373
+ if (rid) S.pending.delete(rid)
374
+ const bad = !!(d.message && d.message.isError)
375
+ if (bad) S.errs++
376
+ if (rec) {
377
+ rec.ms = e.time - rec.t; rec.ok = !bad
378
+ if (rec.entry) { rec.entry.ms = rec.ms; rec.entry.ok = !bad }
379
+ const agg = S.toolAgg[rec.name]
380
+ if (agg) { if (rec.ms != null) agg.ms += rec.ms; if (bad) agg.fail++ }
381
+ }
382
+ break
383
+ }
384
+ case 'job/registered': {
385
+ const j = { id: d.jobId, kind: d.kind || 'job', label: clip(d.label, 64), state: 'RUN', t: d.startedAt || e.time, end: 0, turn: NOWTURN }
386
+ S.jobs.push(j); byJob.set(d.jobId, j)
387
+ addLog('task', d.kind || 'job', j.label)
388
+ break
389
+ }
390
+ case 'job/settled': {
391
+ const j = byJob.get(d.jobId)
392
+ if (j) { j.state = d.status || 'DONE'; j.end = d.finishedAt || e.time }
393
+ addLog('task', d.kind || 'job', 'settled · ' + (d.status || ''))
394
+ break
395
+ }
396
+ case 'approval/asked':
397
+ S.approvals++
398
+ S.approvalsList.push({ t: e.time, id: d.id, tool: d.toolName || '', outcome: '' })
399
+ addLog('state', 'approval', d.toolName + ' · ' + clip(d.reason || '', 60))
400
+ break
401
+ case 'approval/decided': {
402
+ const ap = S.approvalsList.filter(function (x) { return x.id === d.id })[0]
403
+ if (ap) ap.outcome = d.outcome || 'done'
404
+ else S.approvalsList.push({ t: e.time, id: d.id, tool: '', outcome: d.outcome || 'done' })
405
+ break
406
+ }
407
+ case 'workspace/changes':
408
+ S.changes.push({ t: e.time, turn: d.turn || NOWTURN })
409
+ break
410
+ case 'todo/write':
411
+ S.todos = Array.isArray(d.todos) ? d.todos.length : S.todos
412
+ addLog('state', 'todo', S.todos + ' 项待办')
413
+ break
414
+ case 'deliverables/presented': {
415
+ const nf = Array.isArray(d.files) ? d.files.length : 0
416
+ S.files += nf
417
+ S.delivs.push({ t: e.time, turn: d.turn || NOWTURN, n: nf })
418
+ const curD = S.turnRec[S.turnRec.length - 1]
419
+ if (curD) curD.files += nf
420
+ }
421
+ addLog('state', 'present', S.files + ' 个文件')
422
+ break
423
+ default: break
424
+ }
425
+ }
426
+ /* 输出:字段照旧,但不在累加器上做破坏性删除 —— 这样同一个折叠器可以一直 push 下去 */
427
+ const result = function () {
428
+ const out = {}
429
+ for (const k in S) {
430
+ if (k === 'turnRec' || k === 'pendingPrompt' || k === 'toolAgg') continue
431
+ out[k] = S[k]
432
+ }
433
+ out.jobs = S.jobs.length > 200 ? S.jobs.slice(-200) : S.jobs
434
+ out.steps = S.steps.length > 1200 ? S.steps.slice(-1200) : S.steps
435
+ out.log = S.log.length > 300 ? S.log.slice(-300) : S.log
436
+ out.turnsList = S.turnRec.slice(-60)
437
+ out.toolStats = Object.keys(S.toolAgg).map(function (k) { return S.toolAgg[k] })
438
+ .sort(function (a, b) { return b.count - a.count })
439
+ return out
440
+ }
441
+
442
+ return { push: push, result: result }
443
+ }
444
+
445
+ /* 全量折叠(重放、测试用);线上走增量 */
446
+ function fold(events) {
447
+ const f = makeFolder()
448
+ for (const e of events) f.push(e)
449
+ return f.result()
450
+ }
451
+
452
+ /* ---------------- 缓存 + 轮询 + 重放 ---------------- */
453
+ let cache = { key: '', at: 0, state: null, error: null }
454
+ let EVENTS = { key: '', list: [], file: '', frames: 0 }
455
+ /* REPLAY:把会话自己的历史按虚拟时钟快进播放
456
+ t0/t1 = 首末事件时间;elapsed = 已播放的真实毫秒;vtime = t0 + elapsed*speed */
457
+ let REPLAY = null
458
+
459
+ function replayAdvance() {
460
+ if (!REPLAY) return
461
+ const nowMs = Date.now()
462
+ const dt = nowMs - REPLAY.lastTick
463
+ REPLAY.lastTick = nowMs
464
+ if (!REPLAY.playing) return
465
+ if (REPLAY.mode === 'seq') {
466
+ /* 事件序列:每 stepMs 走一条事件,事件之间的真实间隔一律不保留 */
467
+ REPLAY.elapsed += dt
468
+ const k = Math.floor(REPLAY.elapsed / REPLAY.stepMs)
469
+ if (k > 0) {
470
+ REPLAY.elapsed -= k * REPLAY.stepMs
471
+ REPLAY.idx = Math.min(REPLAY.total - 1, REPLAY.idx + k)
472
+ if (REPLAY.idx >= REPLAY.total - 1) REPLAY.playing = false
473
+ }
474
+ } else {
475
+ REPLAY.elapsed += dt * REPLAY.speed
476
+ }
477
+ }
478
+ function replayVTime() {
479
+ if (!REPLAY) return 0
480
+ if (REPLAY.mode === 'seq') return REPLAY.vtime || REPLAY.t0
481
+ const v = REPLAY.t0 + REPLAY.elapsed
482
+ if (v >= REPLAY.t1) {
483
+ REPLAY.playing = false
484
+ REPLAY.elapsed = REPLAY.t1 - REPLAY.t0
485
+ return REPLAY.t1
486
+ }
487
+ return v
488
+ }
489
+
490
+ /* 增量折叠:日志只追加时,把新事件 push 进已有的折叠器,不必重扫全部历史 */
491
+ let FOLDER = null
492
+ let FOLDED = 0
493
+
494
+ function refresh(force, sessionId) {
495
+ const s = sessionId ? (findById(sessionId) || pickSession()) : pickSession()
496
+ if (!s) {
497
+ let why
498
+ try {
499
+ if (!fs.existsSync(SESSIONS_ROOT)) why = '还没找到 DSH 会话目录(' + SESSIONS_ROOT + '),确认 DSH_HOME 是否正确'
500
+ else if (!fs.readdirSync(SESSIONS_ROOT).length) why = '会话目录是空的 —— 先在这个 profile 里跑一轮对话'
501
+ else why = '这些会话里还没有可读的日志 —— 先跑一轮对话'
502
+ } catch (e) { why = String(e.message || e) }
503
+ cache = { key: '', at: Date.now(), state: null, error: why }
504
+ return cache
505
+ }
506
+ if (!HAS_ZSTD) {
507
+ cache = {
508
+ key: '', at: Date.now(), state: null,
509
+ error: '这个运行环境不支持 zstd 解压(需要 Node ≥ 22.15),DSH VISUALIZATION 读不了会话日志。Node 版本:' +
510
+ process.version,
511
+ }
512
+ return cache
513
+ }
514
+ const st = fs.statSync(s.file)
515
+ const key = s.id + ':' + st.size + ':' + st.mtimeMs
516
+ if (!force && key === cache.key && !REPLAY) return cache
517
+ try {
518
+ let events
519
+ let readFrom = -1 /* -1 = 没读盘(命中缓存);0 = 整份重读;>0 = 只追加 */
520
+ if (EVENTS.key === key) events = EVENTS.list
521
+ else {
522
+ /* 日志是多帧追加的:只解「上次之后新追加的帧」,不必每次啃完 14MB */
523
+ const buf = fs.readFileSync(s.file)
524
+ const frames = zstdFrames(buf)
525
+ const heads = frames.length ? frames.map(function (f) { return f.start }) : [0]
526
+ const sameFile = EVENTS.file === s.file
527
+ const canAppend = sameFile && EVENTS.frames > 0 && heads.length >= EVENTS.frames
528
+ const from = canAppend ? EVENTS.frames : 0
529
+ readFrom = from
530
+ const keep = canAppend ? EVENTS.list.length : 0
531
+ const list = canAppend ? EVENTS.list : []
532
+ if (!canAppend) list.length = 0
533
+ const chunks = []
534
+ let used = 0, parseFails = 0
535
+ for (let i = from; i < heads.length; i++) {
536
+ const stop = frames.length ? frames[i].end : buf.length
537
+ const seg = buf.slice(heads[i], stop)
538
+ try {
539
+ chunks.push(zlib.zstdDecompressSync(seg))
540
+ used++
541
+ } catch (e) {
542
+ break /* 尾帧还没写完:停在这里,下次从这一帧继续 */
543
+ }
544
+ }
545
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf8') : ''
546
+ for (const line of text.split('\n')) {
547
+ if (!line) continue
548
+ try { list.push(JSON.parse(line)) } catch (e) { parseFails++ }
549
+ }
550
+ events = list
551
+ /* 帧是按魔数切出来的,理论上可能切错。发现解析失败就整包再解一次,
552
+ 取数量多的那一份 —— 宁可慢一次,也不静默丢事件。 */
553
+ if (parseFails > 0) {
554
+ try {
555
+ const whole = zlib.zstdDecompressSync(buf).toString('utf8')
556
+ const alt = []
557
+ for (const line of whole.split('\n')) {
558
+ if (!line) continue
559
+ try { alt.push(JSON.parse(line)) } catch (e) { }
560
+ }
561
+ if (alt.length > list.length) {
562
+ events = alt
563
+ EVENTS = { key: EVENTS.key, list: alt, file: s.file, frames: 0 }
564
+ addLog && 0
565
+ }
566
+ } catch (e) { }
567
+ }
568
+ if (events !== list) { /* 已经换成整包结果 */ }
569
+ else EVENTS = { key, list: list, file: s.file, frames: from + used }
570
+ /* 文件很大却只解析出个位数事件 = 撞上写入窗口,别缓存这次结果 */
571
+ if (events.length < 5 && st.size > 50000) throw new Error('会话日志正在写入(读到 ' + events.length + ' 条),稍后重试')
572
+ if (keep === 0 && events.length === 0) throw new Error('会话日志还是空的')
573
+ }
574
+ /* 重放:只喂「虚拟时钟之前」的事件 */
575
+ let use = events
576
+ let vtime = 0
577
+ if (REPLAY) {
578
+ replayAdvance()
579
+ if (REPLAY.mode === 'seq') {
580
+ use = events.slice(0, REPLAY.idx + 1)
581
+ let vt = REPLAY.t0
582
+ for (let k = use.length - 1; k >= 0; k--) {
583
+ if (typeof use[k].time === 'number') { vt = use[k].time; break }
584
+ }
585
+ REPLAY.vtime = vt
586
+ vtime = vt
587
+ } else {
588
+ vtime = replayVTime()
589
+ use = events.filter((e) => typeof e.time !== 'number' || e.time <= vtime)
590
+ }
591
+ }
592
+ let S
593
+ if (REPLAY) {
594
+ S = fold(use) /* 重放喂的是切片,只能整体折 */
595
+ } else {
596
+ if (!FOLDER || FOLDED > events.length || readFrom === 0) { FOLDER = makeFolder(); FOLDED = 0 }
597
+ for (let i = FOLDED; i < events.length; i++) FOLDER.push(events[i])
598
+ FOLDED = events.length
599
+ S = FOLDER.result()
600
+ }
601
+ /* 压包体:页面只用得到这几个字段(args 走 /calls 按需拉) */
602
+ if (S.tools) {
603
+ S.tools = S.tools.map(function (x) {
604
+ return { name: x.name, t: x.t, ms: x.ms, ok: x.ok, turn: x.turn, step: x.step }
605
+ })
606
+ }
607
+ S.ok = true
608
+ S.bridge = { port: PORT, polledAt: Date.now(), file: path.basename(path.dirname(s.file)), bytes: st.size, events: use.length }
609
+ S.running = S.turnOpen || S.pending.size > 0
610
+ S.state = !S.turnOpen ? 'IDLE' : (S.pending.size ? 'TOOL'
611
+ : ((Date.now() - S.lastAssistant) < 4000 && !REPLAY ? 'STREAMING' : 'THINKING'))
612
+ S.tps = (S.lastAssistant && S.prevAssistant && S.lastAssistant > S.prevAssistant)
613
+ ? Math.round(S.lastOut / Math.max(0.4, (S.lastAssistant - S.prevAssistant) / 1000)) : 0
614
+ S.ctxPct = S.ctxWin ? Math.min(1, S.ctxUsed / S.ctxWin) : 0
615
+ S.conc = concurrency(S.tools, 120000)
616
+ S.pendingIds = [...S.pending]
617
+ delete S.pending
618
+ cache = { key, at: Date.now(), state: S, error: null }
619
+ } catch (e) {
620
+ cache = { key, at: Date.now(), state: cache.state, error: String(e.message || e) }
621
+ }
622
+ return cache
623
+ }
624
+
625
+ /* ---------------- HTTP ---------------- */
626
+ /* 真实 http origin(桌面端是 dsh-app:// 协议,浏览器打不开) */
627
+ let ORIGIN = ''
628
+ function setOrigin(o) { ORIGIN = String(o || '').replace(/\/+$/, '') }
629
+
630
+ /* 响应体:会话状态 + 自动跟随标记 + 重放射影 */
631
+ function stateBody(c) {
632
+ const body = c.state || { ok: false, error: c.error }
633
+ body.autoFollow = !CURRENT
634
+ if (ORIGIN) body.origin = ORIGIN
635
+ if (REPLAY) {
636
+ const vt = replayVTime()
637
+ body.replay = {
638
+ on: true, mode: REPLAY.mode, playing: REPLAY.playing, speed: REPLAY.speed,
639
+ stepMs: REPLAY.stepMs, idx: REPLAY.idx, total: REPLAY.total,
640
+ t0: REPLAY.t0, t1: REPLAY.t1, vtime: vt,
641
+ pct: REPLAY.mode === 'seq'
642
+ ? (REPLAY.total > 1 ? REPLAY.idx / (REPLAY.total - 1) : 0)
643
+ : (REPLAY.t1 > REPLAY.t0 ? (vt - REPLAY.t0) / (REPLAY.t1 - REPLAY.t0) : 0),
644
+ done: REPLAY.mode === 'seq' ? REPLAY.idx >= REPLAY.total - 1 : vt >= REPLAY.t1,
645
+ }
646
+ } else body.replay = { on: false }
647
+ return body
648
+ }
649
+
650
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
651
+ function isLoopback(req) {
652
+ const ip = (req.socket && (req.socket.remoteAddress || '')) || ''
653
+ return LOOPBACK.has(ip)
654
+ }
655
+ let ALLOW_REMOTE = false
656
+ let sseClients = 0
657
+ function setPolicy(policy) {
658
+ if (policy && typeof policy.allowRemote === 'boolean') ALLOW_REMOTE = policy.allowRemote
659
+ }
660
+
661
+ function handle(req, res) {
662
+ if (!ALLOW_REMOTE && !isLoopback(req)) {
663
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
664
+ res.end('DSH VISUALIZATION: 只允许本机访问(需要远程访问请在插件配置里打开 allowRemote)')
665
+ return
666
+ }
667
+ const full = (req.url || '/').replace(/^\/dsh-visualization/, '') || '/'
668
+ const url = full.split('?')[0]
669
+ const q = new URLSearchParams(full.split('?')[1] || '')
670
+ const json = (obj) => {
671
+ const body = Buffer.from(JSON.stringify(obj), 'utf8')
672
+ const ae = String(req.headers['accept-encoding'] || '')
673
+ /* 状态里有几千条记录,gzip 能压到 1/5,值得做 */
674
+ if (body.length > 2048 && /\bgzip\b/.test(ae)) {
675
+ res.writeHead(200, {
676
+ 'content-type': 'application/json; charset=utf-8',
677
+ 'cache-control': 'no-store',
678
+ 'content-encoding': 'gzip',
679
+ vary: 'accept-encoding',
680
+ })
681
+ res.end(zlib.gzipSync(body))
682
+ return
683
+ }
684
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
685
+ res.end(body)
686
+ }
687
+ /* 救急:把浏览器里还开着的那一页 DOM 存回磁盘(instrument.html 被误覆盖时用) */
688
+ if (req.method === 'POST' && url === '/savehtml') {
689
+ let body = ''
690
+ req.on('data', (c) => { body += c })
691
+ req.on('end', () => {
692
+ try {
693
+ fs.writeFileSync(path.join(__dirname, 'instrument.recovered.html'), body, 'utf8')
694
+ json({ ok: true, bytes: body.length })
695
+ } catch (e) { res.writeHead(500); res.end(String(e.message)) }
696
+ })
697
+ return
698
+ }
699
+
700
+ if (url === '/state') {
701
+ const c = refresh(false, q.get('session') || null)
702
+ json(stateBody(c))
703
+ return
704
+ }
705
+ /* SSE:宿主这边一有变化就推,前端不必每 900ms 拉一次 */
706
+ if (url === '/events') {
707
+ if (sseClients >= 8) {
708
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
709
+ res.end('DSH VISUALIZATION: 同时打开的窗口太多了(上限 8)')
710
+ return
711
+ }
712
+ sseClients++
713
+ res.writeHead(200, {
714
+ 'content-type': 'text/event-stream; charset=utf-8',
715
+ 'cache-control': 'no-store',
716
+ connection: 'keep-alive',
717
+ 'x-accel-buffering': 'no',
718
+ })
719
+ res.write('retry: 2000\n\n')
720
+ const sid = q.get('session') || null
721
+ let last = ''
722
+ let closed = false
723
+ const tick = () => {
724
+ if (closed) return
725
+ try {
726
+ const c = refresh(false, sid)
727
+ const body = stateBody(c)
728
+ /* 变化判据:文件版本 + 重放进度 + 运行状态 —— 没变就不发 */
729
+ const key = String(c.key) + '|' + (REPLAY ? REPLAY.idx : '') + '|' +
730
+ (body.state || '') + '|' + (body.turn || 0) + '|' + (body.step || 0)
731
+ if (key === last) return
732
+ last = key
733
+ res.write('data: ' + JSON.stringify(body) + '\n\n')
734
+ } catch (e) { /* 单次失败不影响连接 */ }
735
+ }
736
+ tick()
737
+ const timer = setInterval(tick, 250)
738
+ const ping = setInterval(() => {
739
+ if (closed) return
740
+ try { res.write(': ping\n\n') } catch (e) { }
741
+ }, 15000)
742
+ req.on('close', () => { closed = true; sseClients = Math.max(0, sseClients - 1); clearInterval(timer); clearInterval(ping) })
743
+ return
744
+ }
745
+ /* 某个工具的最近几次调用(含完整参数,按需拉,不进 /state 免得放大轮询) */
746
+ if (url === '/calls') {
747
+ const tool = q.get('tool') || ''
748
+ const lim = Math.min(40, Number(q.get('limit') || 8))
749
+ refresh(false)
750
+ const list = EVENTS.list || []
751
+ const out = []
752
+ for (let k = list.length - 1; k >= 0 && out.length < lim; k--) {
753
+ const e = list[k]
754
+ if (e.type !== 'tool/call' || !e.data || e.data.name !== tool) continue
755
+ out.push({
756
+ t: e.time, turn: e.data.turn, step: e.data.step,
757
+ args: shortArgs(e.data.arguments, 240)
758
+ })
759
+ }
760
+ json({ tool, calls: out })
761
+ return
762
+ }
763
+ /* 重放:把选中会话的历史按虚拟时钟快进
764
+ /replay?action=start|pause|resume|restart|stop&speed=50 */
765
+ if (url === '/replay') {
766
+ const act = q.get('action') || 'start'
767
+ if (act === 'stop') {
768
+ REPLAY = null
769
+ cache = { key: '', at: 0, state: null, error: null }
770
+ json({ ok: true, replay: { on: false } })
771
+ return
772
+ }
773
+ if (!REPLAY || act === 'start' || act === 'restart') {
774
+ if (act === 'start' && !q.get('speed')) { /* 沿用已有速度 */ }
775
+ refresh(!REPLAY)
776
+ const list = EVENTS.list || []
777
+ if (!list.length) { json({ ok: false, error: '这个会话没有事件可重放' }); return }
778
+ const timed = list.filter((e) => typeof e.time === 'number')
779
+ const t0 = timed.length ? timed[0].time : Date.now()
780
+ const t1 = timed.length ? timed[timed.length - 1].time : t0
781
+ const speed = Math.max(1, Math.min(2000, Number(q.get('speed') || (REPLAY ? REPLAY.speed : 50)) || 50))
782
+ const mode = q.get('mode') || (REPLAY ? REPLAY.mode : 'seq')
783
+ const stepMs = Math.max(16, Math.min(3000, Number(q.get('step') || (REPLAY ? REPLAY.stepMs : 110)) || 110))
784
+ REPLAY = {
785
+ mode, t0, t1, speed, stepMs, total: list.length, idx: 0,
786
+ playing: true, elapsed: 0, lastTick: Date.now(), vtime: t0
787
+ }
788
+ } else {
789
+ if (act === 'pause' && REPLAY.playing) { replayAdvance(); REPLAY.playing = false }
790
+ if (act === 'resume' && !REPLAY.playing) { REPLAY.playing = true; REPLAY.lastTick = Date.now() }
791
+ const sp = Number(q.get('speed') || 0)
792
+ if (sp) REPLAY.speed = Math.max(1, Math.min(2000, sp))
793
+ const st = Number(q.get('step') || 0)
794
+ if (st) REPLAY.stepMs = Math.max(16, Math.min(3000, st))
795
+ }
796
+ cache = { key: '', at: 0, state: null, error: null }
797
+ const c = refresh(true)
798
+ const vt = replayVTime()
799
+ json({
800
+ ok: !!c.state,
801
+ replay: {
802
+ on: true, playing: REPLAY.playing, speed: REPLAY.speed, t0: REPLAY.t0, t1: REPLAY.t1,
803
+ vtime: vt, pct: REPLAY.t1 > REPLAY.t0 ? (vt - REPLAY.t0) / (REPLAY.t1 - REPLAY.t0) : 0,
804
+ done: vt >= REPLAY.t1
805
+ }
806
+ })
807
+ return
808
+ }
809
+ /* 会话列表:跨工作区,最近写盘的在前 */
810
+ if (url === '/sessions') {
811
+ json({ sessions: listSessions(40), current: (refresh(false).state || {}).session || null, auto: !CURRENT })
812
+ return
813
+ }
814
+ /* 切换会话:?session=<id 或空=自动跟最新> */
815
+ if (url === '/select') {
816
+ const id = q.get('session') || ''
817
+ CURRENT = id ? 'session-' + id.replace(/^session-/, '') : null
818
+ REPLAY = null /* 换会话就退出重放 */
819
+ cache = { key: '', at: 0, state: null, error: null }
820
+ const c = refresh(true)
821
+ json({ ok: !!c.state, current: c.state ? c.state.session : null, auto: !CURRENT, error: c.error })
822
+ return
823
+ }
824
+ if (url === '/' || url === '/index.html' || url === '/instrument.html') {
825
+ fs.readFile(PAGE, (err, buf) => {
826
+ if (err) { res.writeHead(500); res.end('instrument.html 读不到: ' + err.message); return }
827
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
828
+ res.end(buf)
829
+ })
830
+ return
831
+ }
832
+ res.writeHead(404); res.end('not found')
833
+ }
834
+
835
+ module.exports = { handle, listSessions, pickSession, refresh, fold, makeFolder, setOrigin, setPolicy, hasZstd: HAS_ZSTD }