@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/README.zh.md +164 -0
  4. package/cordis.patch.yml +6 -0
  5. package/lib/client.js +2478 -0
  6. package/lib/core.js +720 -0
  7. package/lib/index.js +111 -0
  8. package/lib/types/demo/main.d.ts +1 -0
  9. package/lib/types/demo/samples.d.ts +21 -0
  10. package/lib/types/src/client/api.d.ts +43 -0
  11. package/lib/types/src/client/components/AuditView.d.ts +8 -0
  12. package/lib/types/src/client/components/CompareView.d.ts +9 -0
  13. package/lib/types/src/client/components/ForkTreeView.d.ts +7 -0
  14. package/lib/types/src/client/components/ItemDetail.d.ts +6 -0
  15. package/lib/types/src/client/components/OverviewView.d.ts +8 -0
  16. package/lib/types/src/client/components/ReplayApp.d.ts +12 -0
  17. package/lib/types/src/client/components/ReplayModal.d.ts +8 -0
  18. package/lib/types/src/client/components/SessionPicker.d.ts +6 -0
  19. package/lib/types/src/client/components/TimelineView.d.ts +6 -0
  20. package/lib/types/src/client/export.d.ts +13 -0
  21. package/lib/types/src/client/i18n.d.ts +24 -0
  22. package/lib/types/src/client/index.d.ts +12 -0
  23. package/lib/types/src/client/mascot-image.d.ts +1 -0
  24. package/lib/types/src/client/mascot.d.ts +17 -0
  25. package/lib/types/src/client/viewer-entry.d.ts +1 -0
  26. package/lib/types/src/core/audit.d.ts +39 -0
  27. package/lib/types/src/core/compare.d.ts +31 -0
  28. package/lib/types/src/core/cost.d.ts +30 -0
  29. package/lib/types/src/core/decode.d.ts +25 -0
  30. package/lib/types/src/core/forktree.d.ts +33 -0
  31. package/lib/types/src/core/index.d.ts +7 -0
  32. package/lib/types/src/core/timeline.d.ts +96 -0
  33. package/lib/types/src/core/types.d.ts +88 -0
  34. package/lib/types/src/index.d.ts +20 -0
  35. package/lib/viewer.js +812 -0
  36. package/package.json +107 -0
  37. package/scripts/build.mjs +131 -0
  38. package/src/client/api.ts +119 -0
  39. package/src/client/components/AuditView.tsx +57 -0
  40. package/src/client/components/CompareView.tsx +186 -0
  41. package/src/client/components/ForkTreeView.tsx +123 -0
  42. package/src/client/components/ItemDetail.tsx +117 -0
  43. package/src/client/components/OverviewView.tsx +258 -0
  44. package/src/client/components/ReplayApp.tsx +205 -0
  45. package/src/client/components/ReplayModal.tsx +53 -0
  46. package/src/client/components/SessionPicker.tsx +60 -0
  47. package/src/client/components/TimelineView.tsx +296 -0
  48. package/src/client/export.ts +43 -0
  49. package/src/client/i18n.ts +360 -0
  50. package/src/client/index.ts +146 -0
  51. package/src/client/mascot-image.ts +1 -0
  52. package/src/client/mascot.tsx +192 -0
  53. package/src/client/styles.css +719 -0
  54. package/src/client/viewer-entry.tsx +66 -0
  55. package/src/core/audit.ts +227 -0
  56. package/src/core/compare.ts +0 -0
  57. package/src/core/cost.ts +61 -0
  58. package/src/core/decode.ts +125 -0
  59. package/src/core/forktree.ts +104 -0
  60. package/src/core/index.ts +7 -0
  61. package/src/core/timeline.ts +396 -0
  62. package/src/core/types.ts +90 -0
  63. package/src/index.ts +157 -0
  64. package/tsconfig.json +18 -0
  65. 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
+ }