@mingozhou/dsh-replay 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/README.zh.md +164 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +2478 -0
- package/lib/core.js +720 -0
- package/lib/index.js +111 -0
- package/lib/types/demo/main.d.ts +1 -0
- package/lib/types/demo/samples.d.ts +21 -0
- package/lib/types/src/client/api.d.ts +43 -0
- package/lib/types/src/client/components/AuditView.d.ts +8 -0
- package/lib/types/src/client/components/CompareView.d.ts +9 -0
- package/lib/types/src/client/components/ForkTreeView.d.ts +7 -0
- package/lib/types/src/client/components/ItemDetail.d.ts +6 -0
- package/lib/types/src/client/components/OverviewView.d.ts +8 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +12 -0
- package/lib/types/src/client/components/ReplayModal.d.ts +8 -0
- package/lib/types/src/client/components/SessionPicker.d.ts +6 -0
- package/lib/types/src/client/components/TimelineView.d.ts +6 -0
- package/lib/types/src/client/export.d.ts +13 -0
- package/lib/types/src/client/i18n.d.ts +24 -0
- package/lib/types/src/client/index.d.ts +12 -0
- package/lib/types/src/client/mascot-image.d.ts +1 -0
- package/lib/types/src/client/mascot.d.ts +17 -0
- package/lib/types/src/client/viewer-entry.d.ts +1 -0
- package/lib/types/src/core/audit.d.ts +39 -0
- package/lib/types/src/core/compare.d.ts +31 -0
- package/lib/types/src/core/cost.d.ts +30 -0
- package/lib/types/src/core/decode.d.ts +25 -0
- package/lib/types/src/core/forktree.d.ts +33 -0
- package/lib/types/src/core/index.d.ts +7 -0
- package/lib/types/src/core/timeline.d.ts +96 -0
- package/lib/types/src/core/types.d.ts +88 -0
- package/lib/types/src/index.d.ts +20 -0
- package/lib/viewer.js +812 -0
- package/package.json +107 -0
- package/scripts/build.mjs +131 -0
- package/src/client/api.ts +119 -0
- package/src/client/components/AuditView.tsx +57 -0
- package/src/client/components/CompareView.tsx +186 -0
- package/src/client/components/ForkTreeView.tsx +123 -0
- package/src/client/components/ItemDetail.tsx +117 -0
- package/src/client/components/OverviewView.tsx +258 -0
- package/src/client/components/ReplayApp.tsx +205 -0
- package/src/client/components/ReplayModal.tsx +53 -0
- package/src/client/components/SessionPicker.tsx +60 -0
- package/src/client/components/TimelineView.tsx +296 -0
- package/src/client/export.ts +43 -0
- package/src/client/i18n.ts +360 -0
- package/src/client/index.ts +146 -0
- package/src/client/mascot-image.ts +1 -0
- package/src/client/mascot.tsx +192 -0
- package/src/client/styles.css +719 -0
- package/src/client/viewer-entry.tsx +66 -0
- package/src/core/audit.ts +227 -0
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +61 -0
- package/src/core/decode.ts +125 -0
- package/src/core/forktree.ts +104 -0
- package/src/core/index.ts +7 -0
- package/src/core/timeline.ts +396 -0
- package/src/core/types.ts +90 -0
- package/src/index.ts +157 -0
- package/tsconfig.json +18 -0
- package/types.d.ts +15 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold a decoded session into the timeline model the UI renders:
|
|
3
|
+
* turns → steps → items (user / assistant / tool / meta), with durations,
|
|
4
|
+
* per-step token usage (last sample wins per turn/step, per the harness's
|
|
5
|
+
* `usageOf` replace-not-accumulate rule), and cumulative token totals.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
ContentBlock,
|
|
9
|
+
MessageLike,
|
|
10
|
+
ParsedSession,
|
|
11
|
+
SessionEvent,
|
|
12
|
+
TokenUsage,
|
|
13
|
+
} from './types.js'
|
|
14
|
+
|
|
15
|
+
export type ItemKind = 'user' | 'assistant' | 'tool' | 'meta'
|
|
16
|
+
|
|
17
|
+
export interface ToolCallInfo {
|
|
18
|
+
callId: string
|
|
19
|
+
name: string
|
|
20
|
+
/** Raw argument JSON string exactly as the model produced it. */
|
|
21
|
+
argumentsRaw: string
|
|
22
|
+
argumentsPretty?: string
|
|
23
|
+
resultText?: string
|
|
24
|
+
error?: { name: string; code: string }
|
|
25
|
+
callSeq: number
|
|
26
|
+
resultSeq?: number
|
|
27
|
+
callTime: number
|
|
28
|
+
resultTime?: number
|
|
29
|
+
durationMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TimelineItem {
|
|
33
|
+
kind: ItemKind
|
|
34
|
+
seq: number
|
|
35
|
+
time: number
|
|
36
|
+
turn?: number
|
|
37
|
+
step?: number
|
|
38
|
+
type: string
|
|
39
|
+
/** Short one-line label for the timeline rail. */
|
|
40
|
+
label: string
|
|
41
|
+
/** Full text body when the item carries prose (user/assistant messages). */
|
|
42
|
+
text?: string
|
|
43
|
+
reasoningText?: string
|
|
44
|
+
toolCall?: ToolCallInfo
|
|
45
|
+
usage?: TokenUsage
|
|
46
|
+
event: SessionEvent
|
|
47
|
+
/** True when this surface node was later shadowed by compaction. */
|
|
48
|
+
shadowed?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StepModel {
|
|
52
|
+
turn: number
|
|
53
|
+
step: number
|
|
54
|
+
startSeq: number
|
|
55
|
+
endSeq?: number
|
|
56
|
+
startTime: number
|
|
57
|
+
endTime?: number
|
|
58
|
+
usage?: TokenUsage
|
|
59
|
+
items: TimelineItem[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TurnModel {
|
|
63
|
+
turn: number
|
|
64
|
+
startSeq: number
|
|
65
|
+
endSeq?: number
|
|
66
|
+
startTime: number
|
|
67
|
+
endTime?: number
|
|
68
|
+
endReason?: string
|
|
69
|
+
steps: StepModel[]
|
|
70
|
+
items: TimelineItem[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TokenTotals {
|
|
74
|
+
input: number
|
|
75
|
+
output: number
|
|
76
|
+
cacheRead: number
|
|
77
|
+
cacheWrite: number
|
|
78
|
+
reasoning: number
|
|
79
|
+
/** billed input = input + cacheRead + cacheWrite (disjoint counts). */
|
|
80
|
+
billedInput: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TimelineModel {
|
|
84
|
+
turns: TurnModel[]
|
|
85
|
+
/** Flat, seq-ordered list of everything renderable. */
|
|
86
|
+
items: TimelineItem[]
|
|
87
|
+
totals: TokenTotals
|
|
88
|
+
/** Per assistant-step usage samples in order, for the token strip. */
|
|
89
|
+
usageSamples: { seq: number; turn: number; step: number; usage: TokenUsage }[]
|
|
90
|
+
toolCalls: ToolCallInfo[]
|
|
91
|
+
durationMs: number
|
|
92
|
+
startTime: number
|
|
93
|
+
endTime: number
|
|
94
|
+
eventCount: number
|
|
95
|
+
/** seq of the last `session/end-seed` marker (fork/resume boundary), if any. */
|
|
96
|
+
seedBoundarySeq?: number
|
|
97
|
+
models: string[]
|
|
98
|
+
/** Latest `session/title` event payload, when the log carries one. */
|
|
99
|
+
title?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function contentText(message: MessageLike | undefined): string {
|
|
103
|
+
if (!message?.content) return ''
|
|
104
|
+
return message.content
|
|
105
|
+
.filter((b: ContentBlock) => typeof b.text === 'string')
|
|
106
|
+
.map((b: ContentBlock) => b.text)
|
|
107
|
+
.join('')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function truncate(text: string, max = 96): string {
|
|
111
|
+
const oneLine = text.replaceAll(/\s+/gu, ' ').trim()
|
|
112
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function tryPretty(raw: string): string | undefined {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.stringify(JSON.parse(raw), null, 2)
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function get(obj: unknown, key: string): unknown {
|
|
124
|
+
return typeof obj === 'object' && obj !== null ? (obj as Record<string, unknown>)[key] : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const HIDDEN_TYPES = new Set(['assistant/chunk', 'step/start', 'step/end', 'turn/start'])
|
|
128
|
+
|
|
129
|
+
/** Meta events worth a rail entry (log-only vocabulary). */
|
|
130
|
+
function metaLabel(event: SessionEvent): string | undefined {
|
|
131
|
+
const d = event.data
|
|
132
|
+
switch (event.type) {
|
|
133
|
+
case 'turn/end': {
|
|
134
|
+
const reason = get(get(d, 'reason'), 'kind')
|
|
135
|
+
return reason === 'completed' ? undefined : `turn ended: ${String(reason)}`
|
|
136
|
+
}
|
|
137
|
+
case 'compaction/summary':
|
|
138
|
+
return 'context compacted (summary)'
|
|
139
|
+
case 'compaction/prune':
|
|
140
|
+
return 'context pruned'
|
|
141
|
+
case 'approval/asked':
|
|
142
|
+
return `approval requested: ${String(get(d, 'toolName') ?? '')}`
|
|
143
|
+
case 'approval/decided': {
|
|
144
|
+
const outcome = get(d, 'outcome') ?? get(d, 'decision')
|
|
145
|
+
return `approval decided: ${typeof outcome === 'string' ? outcome : JSON.stringify(outcome)}`
|
|
146
|
+
}
|
|
147
|
+
case 'sandbox/mode':
|
|
148
|
+
return `sandbox mode → ${JSON.stringify(d)}`
|
|
149
|
+
case 'permission/preset':
|
|
150
|
+
return `permission preset → ${JSON.stringify(d)}`
|
|
151
|
+
case 'plan/mode':
|
|
152
|
+
return `plan mode change`
|
|
153
|
+
case 'subagent/descriptor':
|
|
154
|
+
return `subagent spawned (${String(get(d, 'mode') ?? '')})`
|
|
155
|
+
case 'session/end-seed':
|
|
156
|
+
return 'seed boundary (fork/resume point)'
|
|
157
|
+
case 'llm/retry':
|
|
158
|
+
case 'llm/retry-started':
|
|
159
|
+
return 'model request retried'
|
|
160
|
+
case 'command/run':
|
|
161
|
+
return `command: ${truncate(JSON.stringify(d), 60)}`
|
|
162
|
+
case 'goal/change':
|
|
163
|
+
return 'goal changed'
|
|
164
|
+
case 'session/title':
|
|
165
|
+
return undefined
|
|
166
|
+
default:
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Compute which surface seqs are shadowed by later replace ops. */
|
|
172
|
+
function shadowedSeqs(events: SessionEvent[]): Set<number> {
|
|
173
|
+
const surfaceOrder: number[] = [] // seqs of surface nodes in order
|
|
174
|
+
const shadowed = new Set<number>()
|
|
175
|
+
for (const event of events) {
|
|
176
|
+
const op = event.surfaceOp
|
|
177
|
+
if (op === undefined) continue
|
|
178
|
+
if (op === 'append') {
|
|
179
|
+
surfaceOrder.push(event.seq)
|
|
180
|
+
} else if (typeof op === 'object' && op.op === 'replace') {
|
|
181
|
+
const removed = surfaceOrder.splice(op.start, op.end - op.start + 1, event.seq)
|
|
182
|
+
for (const seq of removed) shadowed.add(seq)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return shadowed
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
189
|
+
const { events } = session
|
|
190
|
+
const turns = new Map<number, TurnModel>()
|
|
191
|
+
const items: TimelineItem[] = []
|
|
192
|
+
const usageSamples: TimelineModel['usageSamples'] = []
|
|
193
|
+
const toolCallsBySeq = new Map<string, ToolCallInfo>()
|
|
194
|
+
const toolCalls: ToolCallInfo[] = []
|
|
195
|
+
const usageByStep = new Map<string, TokenUsage>()
|
|
196
|
+
const models = new Set<string>()
|
|
197
|
+
const shadowed = shadowedSeqs(events)
|
|
198
|
+
let seedBoundarySeq: number | undefined
|
|
199
|
+
let activeTurn: number | undefined
|
|
200
|
+
let sessionTitle: string | undefined
|
|
201
|
+
|
|
202
|
+
const ensureTurn = (turn: number, event: SessionEvent): TurnModel => {
|
|
203
|
+
let model = turns.get(turn)
|
|
204
|
+
if (model === undefined) {
|
|
205
|
+
model = { turn, startSeq: event.seq, startTime: event.time, steps: [], items: [] }
|
|
206
|
+
turns.set(turn, model)
|
|
207
|
+
}
|
|
208
|
+
return model
|
|
209
|
+
}
|
|
210
|
+
const ensureStep = (turnModel: TurnModel, step: number, event: SessionEvent): StepModel => {
|
|
211
|
+
let model = turnModel.steps.find(s => s.step === step)
|
|
212
|
+
if (model === undefined) {
|
|
213
|
+
model = { turn: turnModel.turn, step, startSeq: event.seq, startTime: event.time, items: [] }
|
|
214
|
+
turnModel.steps.push(model)
|
|
215
|
+
}
|
|
216
|
+
return model
|
|
217
|
+
}
|
|
218
|
+
const push = (item: TimelineItem): void => {
|
|
219
|
+
if (shadowed.has(item.seq)) item.shadowed = true
|
|
220
|
+
items.push(item)
|
|
221
|
+
if (item.turn !== undefined) {
|
|
222
|
+
const turnModel = ensureTurn(item.turn, item.event)
|
|
223
|
+
turnModel.items.push(item)
|
|
224
|
+
if (item.step !== undefined) ensureStep(turnModel, item.step, item.event).items.push(item)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const event of events) {
|
|
229
|
+
const d = event.data
|
|
230
|
+
const turn = get(d, 'turn')
|
|
231
|
+
const step = get(d, 'step')
|
|
232
|
+
switch (event.type) {
|
|
233
|
+
case 'turn/start':
|
|
234
|
+
activeTurn = turn as number
|
|
235
|
+
ensureTurn(activeTurn, event)
|
|
236
|
+
break
|
|
237
|
+
case 'turn/end': {
|
|
238
|
+
const model = ensureTurn(turn as number, event)
|
|
239
|
+
model.endSeq = event.seq
|
|
240
|
+
model.endTime = event.time
|
|
241
|
+
model.endReason = String(get(get(d, 'reason'), 'kind') ?? 'completed')
|
|
242
|
+
activeTurn = undefined
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
case 'step/start':
|
|
246
|
+
ensureStep(ensureTurn(turn as number, event), step as number, event)
|
|
247
|
+
break
|
|
248
|
+
case 'step/end': {
|
|
249
|
+
const stepModel = ensureStep(ensureTurn(turn as number, event), step as number, event)
|
|
250
|
+
stepModel.endSeq = event.seq
|
|
251
|
+
stepModel.endTime = event.time
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
case 'user/message': {
|
|
255
|
+
const message = d as MessageLike
|
|
256
|
+
const text = contentText(message)
|
|
257
|
+
push({
|
|
258
|
+
kind: 'user', seq: event.seq, time: event.time, type: event.type,
|
|
259
|
+
turn: activeTurn, label: truncate(text) || '(user message)', text, event,
|
|
260
|
+
})
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
case 'assistant/message': {
|
|
264
|
+
const message = get(d, 'message') as MessageLike
|
|
265
|
+
const usage = get(d, 'usage') as TokenUsage | undefined
|
|
266
|
+
const text = contentText(message)
|
|
267
|
+
const source = message?.source as Record<string, unknown> | undefined
|
|
268
|
+
if (typeof source?.['model'] === 'string') models.add(source['model'])
|
|
269
|
+
if (usage) {
|
|
270
|
+
usageByStep.set(`${String(turn)}/${String(step)}`, usage)
|
|
271
|
+
}
|
|
272
|
+
push({
|
|
273
|
+
kind: 'assistant', seq: event.seq, time: event.time,
|
|
274
|
+
turn: turn as number, step: step as number, type: event.type,
|
|
275
|
+
label: truncate(text) || '(assistant message)', text, usage, event,
|
|
276
|
+
})
|
|
277
|
+
break
|
|
278
|
+
}
|
|
279
|
+
case 'assistant/chunk': {
|
|
280
|
+
const chunk = get(d, 'chunk')
|
|
281
|
+
if (get(chunk, 'type') === 'usage') {
|
|
282
|
+
const usage = get(chunk, 'usage') as TokenUsage
|
|
283
|
+
usageByStep.set(`${String(turn)}/${String(step)}`, usage)
|
|
284
|
+
}
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
case 'tool/call': {
|
|
288
|
+
const info: ToolCallInfo = {
|
|
289
|
+
callId: String(get(d, 'callId') ?? ''),
|
|
290
|
+
name: String(get(d, 'name') ?? ''),
|
|
291
|
+
argumentsRaw: String(get(d, 'arguments') ?? ''),
|
|
292
|
+
argumentsPretty: tryPretty(String(get(d, 'arguments') ?? '')),
|
|
293
|
+
callSeq: event.seq,
|
|
294
|
+
callTime: event.time,
|
|
295
|
+
}
|
|
296
|
+
toolCallsBySeq.set(info.callId, info)
|
|
297
|
+
toolCalls.push(info)
|
|
298
|
+
push({
|
|
299
|
+
kind: 'tool', seq: event.seq, time: event.time,
|
|
300
|
+
turn: turn as number, step: step as number, type: event.type,
|
|
301
|
+
label: `${info.name}(${truncate(info.argumentsRaw, 48)})`, toolCall: info, event,
|
|
302
|
+
})
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
case 'tool/result': {
|
|
306
|
+
const message = get(d, 'message') as MessageLike
|
|
307
|
+
const source = message?.source as Record<string, unknown> | undefined
|
|
308
|
+
const callId = String(source?.['callId'] ?? '')
|
|
309
|
+
const info = toolCallsBySeq.get(callId)
|
|
310
|
+
const block = message?.content?.[0]
|
|
311
|
+
const resultText =
|
|
312
|
+
typeof get(block, 'text') === 'string'
|
|
313
|
+
? (get(block, 'text') as string)
|
|
314
|
+
: contentText({ content: get(block, 'content') as ContentBlock[] | undefined })
|
|
315
|
+
const error = get(d, 'error') as { name: string; code: string } | undefined
|
|
316
|
+
if (info) {
|
|
317
|
+
info.resultSeq = event.seq
|
|
318
|
+
info.resultTime = event.time
|
|
319
|
+
info.durationMs = event.time - info.callTime
|
|
320
|
+
info.resultText = resultText
|
|
321
|
+
info.error = error
|
|
322
|
+
}
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
case 'session/end-seed':
|
|
326
|
+
seedBoundarySeq = event.seq
|
|
327
|
+
push({
|
|
328
|
+
kind: 'meta', seq: event.seq, time: event.time, type: event.type,
|
|
329
|
+
label: 'seed boundary (fork/resume point)', event,
|
|
330
|
+
})
|
|
331
|
+
break
|
|
332
|
+
case 'request/context': {
|
|
333
|
+
const model = get(d, 'model')
|
|
334
|
+
if (typeof model === 'string') models.add(model)
|
|
335
|
+
break
|
|
336
|
+
}
|
|
337
|
+
case 'session/title': {
|
|
338
|
+
const title = get(d, 'title')
|
|
339
|
+
if (typeof title === 'string' && title !== '') sessionTitle = title
|
|
340
|
+
break
|
|
341
|
+
}
|
|
342
|
+
default: {
|
|
343
|
+
if (HIDDEN_TYPES.has(event.type)) break
|
|
344
|
+
const label = metaLabel(event)
|
|
345
|
+
if (label !== undefined) {
|
|
346
|
+
push({
|
|
347
|
+
kind: 'meta', seq: event.seq, time: event.time,
|
|
348
|
+
turn: typeof turn === 'number' ? turn : undefined,
|
|
349
|
+
step: typeof step === 'number' ? step : undefined,
|
|
350
|
+
type: event.type, label, event,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Fold usage samples (already last-wins per turn/step via the map).
|
|
358
|
+
const totals: TokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, billedInput: 0 }
|
|
359
|
+
for (const [key, usage] of usageByStep) {
|
|
360
|
+
const [turnStr, stepStr] = key.split('/')
|
|
361
|
+
totals.input += usage.inputTokens
|
|
362
|
+
totals.output += usage.outputTokens
|
|
363
|
+
totals.cacheRead += usage.cacheReadTokens ?? 0
|
|
364
|
+
totals.cacheWrite += usage.cacheWriteTokens ?? 0
|
|
365
|
+
totals.reasoning += usage.reasoningTokens ?? 0
|
|
366
|
+
const turnModel = turns.get(Number(turnStr))
|
|
367
|
+
const stepModel = turnModel?.steps.find(s => s.step === Number(stepStr))
|
|
368
|
+
if (stepModel) stepModel.usage = usage
|
|
369
|
+
const anchor = stepModel?.items.find(i => i.kind === 'assistant')
|
|
370
|
+
usageSamples.push({
|
|
371
|
+
seq: anchor?.seq ?? stepModel?.startSeq ?? 0,
|
|
372
|
+
turn: Number(turnStr), step: Number(stepStr), usage,
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
usageSamples.sort((a, b) => a.seq - b.seq)
|
|
376
|
+
totals.billedInput = totals.input + totals.cacheRead + totals.cacheWrite
|
|
377
|
+
|
|
378
|
+
const orderedTurns = [...turns.values()].sort((a, b) => a.turn - b.turn)
|
|
379
|
+
for (const t of orderedTurns) t.steps.sort((a, b) => a.step - b.step)
|
|
380
|
+
const startTime = events[0]?.time ?? session.header.createdAt
|
|
381
|
+
const endTime = events.at(-1)?.time ?? startTime
|
|
382
|
+
return {
|
|
383
|
+
turns: orderedTurns,
|
|
384
|
+
items,
|
|
385
|
+
totals,
|
|
386
|
+
usageSamples,
|
|
387
|
+
toolCalls,
|
|
388
|
+
durationMs: endTime - startTime,
|
|
389
|
+
startTime,
|
|
390
|
+
endTime,
|
|
391
|
+
eventCount: events.length,
|
|
392
|
+
seedBoundarySeq,
|
|
393
|
+
models: [...models],
|
|
394
|
+
title: sessionTitle,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural types for the DeepSeek Harness session log (format version 0).
|
|
3
|
+
*
|
|
4
|
+
* These are intentionally *structural* copies of the shapes in
|
|
5
|
+
* `@deepseek-ai/dsh-session` — the core analysis layer has zero runtime
|
|
6
|
+
* dependencies on harness packages so it can run in the browser, in tests,
|
|
7
|
+
* and inside the host plugin alike. Field names track the upstream
|
|
8
|
+
* vocabulary exactly; unknown event types are preserved untyped.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type JsonValue =
|
|
12
|
+
| null
|
|
13
|
+
| boolean
|
|
14
|
+
| number
|
|
15
|
+
| string
|
|
16
|
+
| JsonValue[]
|
|
17
|
+
| { [key: string]: JsonValue }
|
|
18
|
+
|
|
19
|
+
/** Session header — line 1 of a JSONL log / `sessions` row in SQLite. */
|
|
20
|
+
export interface SessionHeader {
|
|
21
|
+
version?: number
|
|
22
|
+
id: string
|
|
23
|
+
createdAt: number
|
|
24
|
+
cwd?: string
|
|
25
|
+
parentSession?: string
|
|
26
|
+
seedLength?: number
|
|
27
|
+
origin?: 'subagent'
|
|
28
|
+
delegationDepth?: number
|
|
29
|
+
agentPreset?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The event envelope. `seq` is contiguous and monotonic from 0. */
|
|
33
|
+
export interface SessionEvent {
|
|
34
|
+
type: string
|
|
35
|
+
seq: number
|
|
36
|
+
time: number
|
|
37
|
+
data: JsonValue
|
|
38
|
+
ignorable?: true
|
|
39
|
+
sourceEventSeqs?: number[]
|
|
40
|
+
surfaceOp?: 'append' | { op: 'replace'; start: number; end: number }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Disjoint token counts. Billed input = input + cacheRead + cacheWrite. */
|
|
44
|
+
export interface TokenUsage {
|
|
45
|
+
inputTokens: number
|
|
46
|
+
outputTokens: number
|
|
47
|
+
cacheReadTokens?: number
|
|
48
|
+
cacheWriteTokens?: number
|
|
49
|
+
reasoningTokens?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Packed chunk rows — the one wire shape that is not a SessionEvent. */
|
|
53
|
+
export interface ChunkRowBase {
|
|
54
|
+
type: 'text-chunks' | 'reasoning-chunks' | 'tool-call-chunks'
|
|
55
|
+
seq0: number
|
|
56
|
+
time0: number
|
|
57
|
+
data: {
|
|
58
|
+
turn: number
|
|
59
|
+
step: number
|
|
60
|
+
index: number
|
|
61
|
+
dt: number[]
|
|
62
|
+
texts?: string[]
|
|
63
|
+
id?: string
|
|
64
|
+
name?: string
|
|
65
|
+
args?: string[]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type StorageRecord = SessionEvent | ChunkRowBase
|
|
70
|
+
|
|
71
|
+
export interface ContentBlock {
|
|
72
|
+
type: string
|
|
73
|
+
text?: string
|
|
74
|
+
[key: string]: JsonValue | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MessageLike {
|
|
78
|
+
id?: string
|
|
79
|
+
role?: string
|
|
80
|
+
content?: ContentBlock[]
|
|
81
|
+
source?: { kind?: string; [key: string]: JsonValue | undefined }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A parsed session: header + fully decoded, seq-ordered events. */
|
|
85
|
+
export interface ParsedSession {
|
|
86
|
+
header: SessionHeader
|
|
87
|
+
events: SessionEvent[]
|
|
88
|
+
/** Lines (or rows) that failed to decode — kept for diagnostics, never dropped silently. */
|
|
89
|
+
malformed: { line: number; raw: string; error: string }[]
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-replay — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a small read-only HTTP API on the harness web server that the
|
|
5
|
+
* browser half (and nothing else) consumes:
|
|
6
|
+
*
|
|
7
|
+
* GET /replay/api/sessions → { sessions: [{ header, title }] }
|
|
8
|
+
* GET /replay/api/session/<id> → { header, events }
|
|
9
|
+
*
|
|
10
|
+
* Reads go through `ctx.sessionQuery` (live-preferred corpus), so an open
|
|
11
|
+
* session replays with its in-memory state, not a stale disk artifact.
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import { readFile } from 'node:fs/promises'
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-replay'
|
|
18
|
+
|
|
19
|
+
export const inject = ['webServer', 'sessionQuery']
|
|
20
|
+
|
|
21
|
+
export interface Config {
|
|
22
|
+
/** URL prefix for the replay API (exact-prefix route). */
|
|
23
|
+
routePrefix?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SessionQueryLike {
|
|
27
|
+
listSessions: (signal?: AbortSignal) => Promise<unknown[]>
|
|
28
|
+
readSession: (sessionId: string) => Promise<unknown>
|
|
29
|
+
readTitleSnapshots?: (ids: string[], signal?: AbortSignal) => Promise<unknown[]>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface WebServerLike {
|
|
33
|
+
register: (route: {
|
|
34
|
+
kind: 'exact' | 'prefix'
|
|
35
|
+
path: string
|
|
36
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
37
|
+
}) => () => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
'content-type': 'application/json; charset=utf-8',
|
|
43
|
+
'cache-control': 'no-store',
|
|
44
|
+
})
|
|
45
|
+
res.end(JSON.stringify(body))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function get(obj: unknown, key: string): unknown {
|
|
49
|
+
return typeof obj === 'object' && obj !== null ? (obj as Record<string, unknown>)[key] : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pull a SessionHeader-shaped object out of whatever record shape the engine returns. */
|
|
53
|
+
function headerOf(record: unknown): Record<string, unknown> | undefined {
|
|
54
|
+
for (const candidate of [get(record, 'header'), get(record, 'meta'), record]) {
|
|
55
|
+
if (typeof get(candidate, 'id') === 'string' && typeof get(candidate, 'createdAt') === 'number') {
|
|
56
|
+
return candidate as Record<string, unknown>
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Depth-limited recursive search for a non-empty string `title` field. */
|
|
63
|
+
function titleOf(record: unknown, depth = 3): string | undefined {
|
|
64
|
+
if (typeof record !== 'object' || record === null || depth < 0) return undefined
|
|
65
|
+
const direct = (record as Record<string, unknown>)['title']
|
|
66
|
+
if (typeof direct === 'string' && direct.length > 0) return direct
|
|
67
|
+
for (const value of Object.values(record)) {
|
|
68
|
+
if (typeof value === 'object' && value !== null) {
|
|
69
|
+
const found = titleOf(value, depth - 1)
|
|
70
|
+
if (found !== undefined) return found
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
77
|
+
const prefix = config.routePrefix ?? '/replay/api'
|
|
78
|
+
const webServer = (ctx as unknown as { webServer: WebServerLike }).webServer
|
|
79
|
+
const sessionQuery = (ctx as unknown as { sessionQuery: SessionQueryLike }).sessionQuery
|
|
80
|
+
|
|
81
|
+
const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
82
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
83
|
+
const rest = url.pathname.slice(prefix.length).replace(/^\//u, '')
|
|
84
|
+
try {
|
|
85
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
86
|
+
json(res, 405, { error: 'method not allowed' })
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (rest === 'sessions') {
|
|
90
|
+
const records = await sessionQuery.listSessions()
|
|
91
|
+
const headers = records.map(headerOf).filter((h): h is Record<string, unknown> => h !== undefined)
|
|
92
|
+
const titles = new Map<string, string>()
|
|
93
|
+
if (sessionQuery.readTitleSnapshots !== undefined) {
|
|
94
|
+
try {
|
|
95
|
+
const snapshots = await sessionQuery.readTitleSnapshots(headers.map(h => String(h['id'])))
|
|
96
|
+
snapshots.forEach((snapshot, i) => {
|
|
97
|
+
const title = titleOf(snapshot)
|
|
98
|
+
const id = headers[i]?.['id']
|
|
99
|
+
if (title !== undefined && typeof id === 'string') titles.set(id, title)
|
|
100
|
+
})
|
|
101
|
+
} catch {
|
|
102
|
+
/* titles are decoration — never fail the listing over them */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Per-id fallback for engines where the batch call misses.
|
|
106
|
+
const readTitle = (sessionQuery as { readTitle?: (id: string) => Promise<unknown> }).readTitle
|
|
107
|
+
if (readTitle !== undefined) {
|
|
108
|
+
await Promise.all(
|
|
109
|
+
headers
|
|
110
|
+
.filter(h => !titles.has(String(h['id'])))
|
|
111
|
+
.map(async h => {
|
|
112
|
+
try {
|
|
113
|
+
const title = titleOf(await readTitle.call(sessionQuery, String(h['id'])))
|
|
114
|
+
if (title !== undefined) titles.set(String(h['id']), title)
|
|
115
|
+
} catch {
|
|
116
|
+
/* decoration only */
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
json(res, 200, {
|
|
122
|
+
sessions: headers.map(h => ({ header: h, title: titles.get(String(h['id'])) })),
|
|
123
|
+
})
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (rest === 'viewer.js') {
|
|
127
|
+
// The standalone viewer bundle (sibling artifact of this module),
|
|
128
|
+
// inlined into exported HTML files by the browser half.
|
|
129
|
+
const source = await readFile(new URL('./viewer.js', import.meta.url), 'utf8')
|
|
130
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
|
|
131
|
+
res.end(source)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (rest.startsWith('session/')) {
|
|
135
|
+
const id = decodeURIComponent(rest.slice('session/'.length))
|
|
136
|
+
const snapshot = await sessionQuery.readSession(id)
|
|
137
|
+
const header = headerOf(snapshot) ?? headerOf(get(snapshot, 'session'))
|
|
138
|
+
const events =
|
|
139
|
+
get(snapshot, 'events') ?? get(get(snapshot, 'session'), 'events') ?? []
|
|
140
|
+
if (header === undefined) {
|
|
141
|
+
json(res, 404, { error: 'session not found' })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
json(res, 200, { header, events })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
json(res, 404, { error: 'unknown replay endpoint' })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
json(res, 500, { error: String(error) })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
ctx.effect(
|
|
154
|
+
() => webServer.register({ kind: 'prefix', path: prefix, handler }),
|
|
155
|
+
'dsh-replay: http api',
|
|
156
|
+
)
|
|
157
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": false,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"emitDeclarationOnly": true,
|
|
13
|
+
"outDir": "lib/types",
|
|
14
|
+
"allowImportingTsExtensions": false,
|
|
15
|
+
"types": ["node"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*", "demo/**/*.ts", "demo/**/*.tsx", "types.d.ts"]
|
|
18
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare module '*.css' {
|
|
2
|
+
const text: string
|
|
3
|
+
export default text
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module '@deepseek-ai/cordis' {
|
|
7
|
+
export interface Context {
|
|
8
|
+
effect: (callback: () => () => void, label?: string) => void
|
|
9
|
+
[key: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Window {
|
|
14
|
+
__ModuleLoader__?: { load: (record: { id: string; factory: (require: (id: string) => unknown) => unknown }) => void }
|
|
15
|
+
}
|