@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,66 @@
1
+ /**
2
+ * Standalone viewer entry — bundled (react inlined) into lib/viewer.js and
3
+ * embedded into exported HTML files. Reads `window.__DSHR_DATA__`:
4
+ * { sessions: [{ header, events, title? }] }
5
+ */
6
+ import React, { useState } from 'react'
7
+ import { createRoot } from 'react-dom/client'
8
+ import type { SessionEvent, SessionHeader } from '../core/types.js'
9
+ import { StaticReplayApi } from './api.js'
10
+ import { ReplayApp } from './components/ReplayApp.js'
11
+ // eslint-disable-next-line import/no-unresolved
12
+ import cssText from './styles.css'
13
+
14
+ interface ViewerData {
15
+ sessions: { header: SessionHeader; events: SessionEvent[]; title?: string }[]
16
+ }
17
+
18
+ const data = (window as unknown as { __DSHR_DATA__?: ViewerData }).__DSHR_DATA__ ?? { sessions: [] }
19
+ const api = StaticReplayApi.fromParsed(data.sessions)
20
+ const first = data.sessions[0]?.header.id
21
+
22
+ function ViewerShell(): React.ReactElement {
23
+ const [dark, setDark] = useState(
24
+ globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false,
25
+ )
26
+ return (
27
+ <div data-theme={dark ? 'dark' : 'light'} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
28
+ <div
29
+ style={{
30
+ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', fontSize: 13,
31
+ borderBottom: '1px solid ' + (dark ? '#2c2c2a' : '#e1e0d9'),
32
+ background: dark ? '#0d0d0d' : '#f9f9f7', color: dark ? '#fff' : '#0b0b0b',
33
+ }}
34
+ >
35
+ <strong>dsh-replay</strong>
36
+ <span style={{ opacity: 0.6 }}>exported session replay</span>
37
+ <span style={{ flex: 1 }} />
38
+ <a
39
+ href="https://github.com/MingoZhou/dsh-replay"
40
+ target="_blank"
41
+ rel="noreferrer"
42
+ style={{ color: 'inherit', opacity: 0.7 }}
43
+ >
44
+ GitHub
45
+ </a>
46
+ <button
47
+ onClick={() => setDark(d => !d)}
48
+ style={{
49
+ font: 'inherit', cursor: 'pointer', border: '1px solid ' + (dark ? '#2c2c2a' : '#e1e0d9'),
50
+ borderRadius: 6, padding: '2px 9px', background: 'transparent', color: 'inherit',
51
+ }}
52
+ >
53
+ {dark ? 'light' : 'dark'}
54
+ </button>
55
+ </div>
56
+ <div style={{ flex: 1, minHeight: 0 }}>
57
+ <ReplayApp api={api} sessionId={first} />
58
+ </div>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ const style = document.createElement('style')
64
+ style.textContent = (cssText as unknown as string) + '\nhtml, body, #root { height: 100%; margin: 0; }'
65
+ document.head.append(style)
66
+ createRoot(document.getElementById('root')!).render(<ViewerShell />)
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Security-audit pass over a decoded session: classify events into findings
3
+ * with a severity, grouped for the audit view. Rules are data, not code —
4
+ * extend `DEFAULT_RULES` or pass your own list.
5
+ *
6
+ * Severity vocabulary mirrors a standard status palette:
7
+ * 'critical' | 'serious' | 'warning' | 'info'.
8
+ */
9
+ import type { ParsedSession, SessionEvent } from './types.js'
10
+
11
+ export type Severity = 'critical' | 'serious' | 'warning' | 'info'
12
+
13
+ export interface AuditFinding {
14
+ ruleId: string
15
+ severity: Severity
16
+ seq: number
17
+ time: number
18
+ /** English title (stable fallback). */
19
+ title: string
20
+ detail?: string
21
+ /** Structured message id + params, so UIs can localize findings. */
22
+ messageKey?: string
23
+ params?: Record<string, string>
24
+ }
25
+
26
+ export interface AuditRule {
27
+ id: string
28
+ severity: Severity | ((event: SessionEvent) => Severity)
29
+ /** Return a finding title when the event matches, undefined otherwise. */
30
+ match: (event: SessionEvent) => {
31
+ title: string
32
+ detail?: string
33
+ messageKey?: string
34
+ params?: Record<string, string>
35
+ } | undefined
36
+ }
37
+
38
+ export interface AuditReport {
39
+ findings: AuditFinding[]
40
+ counts: Record<Severity, number>
41
+ }
42
+
43
+ function get(obj: unknown, key: string): unknown {
44
+ return typeof obj === 'object' && obj !== null ? (obj as Record<string, unknown>)[key] : undefined
45
+ }
46
+
47
+ function str(value: unknown): string {
48
+ return typeof value === 'string' ? value : JSON.stringify(value) ?? ''
49
+ }
50
+
51
+ const SHELL_TOOLS = /^(bash|shell|exec|run_command|terminal|subprocess)/iu
52
+ const WRITE_TOOLS = /^(write|edit|create_file|str_replace|apply_patch|notebook_edit|fs_write|mv|rm|delete)/iu
53
+ const NETWORK_TOOLS = /^(fetch|web_fetch|http|curl|download|web_search|browser)/iu
54
+
55
+ const DANGEROUS_SHELL = [
56
+ { re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/iu, why: 'recursive/forced delete', key: 'rm-rf' },
57
+ { re: /\bsudo\b/u, why: 'privilege escalation', key: 'sudo' },
58
+ { re: /\bchmod\s+([0-7]{3,4}|\+x)/u, why: 'permission change', key: 'chmod' },
59
+ { re: /\b(curl|wget)\b[^|;&]*\|\s*(ba)?sh\b/iu, why: 'pipe remote script to shell', key: 'curl-pipe' },
60
+ { re: /\bgit\s+push\s+.*--force/iu, why: 'force push', key: 'force-push' },
61
+ { re: /\b(dd|mkfs|fdisk)\b/u, why: 'raw disk operation', key: 'disk' },
62
+ { re: /(\bssh\b|\bscp\b|\brsync\b.*@)/u, why: 'remote host access', key: 'remote' },
63
+ { re: /\b(export|echo)\b.*\b(API_?KEY|TOKEN|SECRET|PASSWORD)\b/iu, why: 'credential material in command', key: 'creds' },
64
+ ]
65
+
66
+ const SENSITIVE_PATH = /(\.env\b|\.ssh\/|id_rsa|\.aws\/|credentials|\.npmrc|\.netrc|secrets?\.|\.pem\b)/iu
67
+
68
+ export const DEFAULT_RULES: AuditRule[] = [
69
+ {
70
+ id: 'shell-exec',
71
+ severity: event => {
72
+ const args = str(get(event.data, 'arguments'))
73
+ return DANGEROUS_SHELL.some(p => p.re.test(args)) ? 'critical' : 'warning'
74
+ },
75
+ match: event => {
76
+ if (event.type !== 'tool/call') return undefined
77
+ const name = str(get(event.data, 'name'))
78
+ if (!SHELL_TOOLS.test(name)) return undefined
79
+ const args = str(get(event.data, 'arguments'))
80
+ const hit = DANGEROUS_SHELL.find(p => p.re.test(args))
81
+ return {
82
+ title: hit ? `shell: ${hit.why}` : `shell command via ${name}`,
83
+ detail: args.slice(0, 400),
84
+ messageKey: hit ? 'f.shell.dangerous' : 'f.shell.plain',
85
+ params: (hit ? { whyKey: hit.key, why: hit.why } : { name }) as Record<string, string>,
86
+ }
87
+ },
88
+ },
89
+ {
90
+ id: 'file-write',
91
+ severity: event =>
92
+ SENSITIVE_PATH.test(str(get(event.data, 'arguments'))) ? 'critical' : 'info',
93
+ match: event => {
94
+ if (event.type !== 'tool/call') return undefined
95
+ const name = str(get(event.data, 'name'))
96
+ if (!WRITE_TOOLS.test(name)) return undefined
97
+ const args = str(get(event.data, 'arguments'))
98
+ const sensitive = SENSITIVE_PATH.test(args)
99
+ return {
100
+ title: sensitive ? `file write touches sensitive path` : `file mutation via ${name}`,
101
+ detail: args.slice(0, 400),
102
+ messageKey: sensitive ? 'f.file.sensitive' : 'f.file.write',
103
+ params: { name },
104
+ }
105
+ },
106
+ },
107
+ {
108
+ id: 'sensitive-read',
109
+ severity: 'serious',
110
+ match: event => {
111
+ if (event.type !== 'tool/call') return undefined
112
+ const args = str(get(event.data, 'arguments'))
113
+ const name = str(get(event.data, 'name'))
114
+ if (WRITE_TOOLS.test(name) || SHELL_TOOLS.test(name)) return undefined
115
+ if (!SENSITIVE_PATH.test(args)) return undefined
116
+ return {
117
+ title: `access to sensitive path via ${name}`,
118
+ detail: args.slice(0, 400),
119
+ messageKey: 'f.read.sensitive',
120
+ params: { name },
121
+ }
122
+ },
123
+ },
124
+ {
125
+ id: 'network',
126
+ severity: 'info',
127
+ match: event => {
128
+ if (event.type !== 'tool/call') return undefined
129
+ const name = str(get(event.data, 'name'))
130
+ if (!NETWORK_TOOLS.test(name)) return undefined
131
+ return {
132
+ title: `network access via ${name}`,
133
+ detail: str(get(event.data, 'arguments')).slice(0, 400),
134
+ messageKey: 'f.network',
135
+ params: { name },
136
+ }
137
+ },
138
+ },
139
+ {
140
+ id: 'approval-denied',
141
+ severity: 'warning',
142
+ match: event => {
143
+ if (event.type !== 'approval/decided') return undefined
144
+ const outcome = str(get(event.data, 'outcome') ?? get(event.data, 'decision'))
145
+ if (!/den|reject|refus/iu.test(outcome)) return undefined
146
+ return { title: 'user denied an approval', detail: outcome, messageKey: 'f.approval.denied' }
147
+ },
148
+ },
149
+ {
150
+ id: 'sandbox-change',
151
+ severity: 'serious',
152
+ match: event =>
153
+ event.type === 'sandbox/mode'
154
+ ? { title: 'sandbox mode changed', detail: JSON.stringify(event.data), messageKey: 'f.sandbox.change' }
155
+ : undefined,
156
+ },
157
+ {
158
+ id: 'permission-change',
159
+ severity: 'serious',
160
+ match: event =>
161
+ event.type === 'permission/preset'
162
+ ? { title: 'permission preset changed', detail: JSON.stringify(event.data), messageKey: 'f.permission.change' }
163
+ : undefined,
164
+ },
165
+ {
166
+ id: 'subagent',
167
+ severity: 'info',
168
+ match: event =>
169
+ event.type === 'subagent/descriptor'
170
+ ? {
171
+ title: `subagent spawned (${str(get(event.data, 'mode'))})`,
172
+ detail: str(get(event.data, 'persona') ?? get(event.data, 'label') ?? ''),
173
+ messageKey: 'f.subagent',
174
+ params: { mode: str(get(event.data, 'mode')) },
175
+ }
176
+ : undefined,
177
+ },
178
+ {
179
+ id: 'tool-error',
180
+ severity: 'warning',
181
+ match: event => {
182
+ if (event.type !== 'tool/result') return undefined
183
+ const error = get(event.data, 'error')
184
+ if (error === undefined) return undefined
185
+ return {
186
+ title: `tool failed: ${str(get(error, 'name'))}`,
187
+ detail: str(get(error, 'code')),
188
+ messageKey: 'f.tool.error',
189
+ params: { name: str(get(error, 'name')) },
190
+ }
191
+ },
192
+ },
193
+ {
194
+ id: 'turn-error',
195
+ severity: 'warning',
196
+ match: event => {
197
+ if (event.type !== 'turn/end') return undefined
198
+ const kind = str(get(get(event.data, 'reason'), 'kind'))
199
+ if (kind === 'completed' || kind === '') return undefined
200
+ return { title: `turn ended abnormally: ${kind}`, messageKey: 'f.turn.error', params: { kind } }
201
+ },
202
+ },
203
+ ]
204
+
205
+ const SEVERITY_ORDER: Severity[] = ['critical', 'serious', 'warning', 'info']
206
+
207
+ export function auditSession(
208
+ session: ParsedSession,
209
+ rules: AuditRule[] = DEFAULT_RULES,
210
+ ): AuditReport {
211
+ const findings: AuditFinding[] = []
212
+ for (const event of session.events) {
213
+ for (const rule of rules) {
214
+ const hit = rule.match(event)
215
+ if (hit === undefined) continue
216
+ const severity = typeof rule.severity === 'function' ? rule.severity(event) : rule.severity
217
+ findings.push({ ruleId: rule.id, severity, seq: event.seq, time: event.time, ...hit })
218
+ }
219
+ }
220
+ findings.sort(
221
+ (a, b) =>
222
+ SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) || a.seq - b.seq,
223
+ )
224
+ const counts: Record<Severity, number> = { critical: 0, serious: 0, warning: 0, info: 0 }
225
+ for (const finding of findings) counts[finding.severity]++
226
+ return { findings, counts }
227
+ }
Binary file
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cost estimation from token totals. Prices are USD per 1M tokens and are
3
+ * ESTIMATES — provider list prices change; override the table if you need
4
+ * exact numbers. Cache-write falls back to the input rate when a provider
5
+ * doesn't price it separately.
6
+ */
7
+ import type { TokenTotals } from './timeline.js'
8
+
9
+ export interface PricingRate {
10
+ /** USD per 1M uncached input tokens. */
11
+ input: number
12
+ /** USD per 1M cache-read tokens. */
13
+ cacheRead: number
14
+ /** USD per 1M cache-write tokens (defaults to `input`). */
15
+ cacheWrite?: number
16
+ /** USD per 1M output tokens. */
17
+ output: number
18
+ }
19
+
20
+ export interface PricingEntry {
21
+ match: RegExp
22
+ rate: PricingRate
23
+ label: string
24
+ }
25
+
26
+ /** Editable defaults — first match wins, most specific first. */
27
+ export const DEFAULT_PRICING: PricingEntry[] = [
28
+ { match: /deepseek.*pro/iu, rate: { input: 1.1, cacheRead: 0.27, output: 4.4 }, label: 'DeepSeek Pro' },
29
+ { match: /deepseek/iu, rate: { input: 0.27, cacheRead: 0.07, output: 1.1 }, label: 'DeepSeek' },
30
+ { match: /claude|anthropic/iu, rate: { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 }, label: 'Claude' },
31
+ { match: /gpt|^o\d|openai|codex/iu, rate: { input: 2.5, cacheRead: 1.25, output: 10 }, label: 'OpenAI' },
32
+ { match: /gemini/iu, rate: { input: 1.25, cacheRead: 0.31, output: 10 }, label: 'Gemini' },
33
+ ]
34
+
35
+ export interface CostEstimate {
36
+ usd: number
37
+ label: string
38
+ }
39
+
40
+ export function estimateCost(
41
+ totals: TokenTotals,
42
+ models: string[],
43
+ pricing: PricingEntry[] = DEFAULT_PRICING,
44
+ ): CostEstimate | undefined {
45
+ const entry = pricing.find(p => models.some(m => p.match.test(m)))
46
+ if (entry === undefined) return undefined
47
+ const { rate } = entry
48
+ const usd =
49
+ (totals.input * rate.input +
50
+ totals.cacheRead * rate.cacheRead +
51
+ totals.cacheWrite * (rate.cacheWrite ?? rate.input) +
52
+ totals.output * rate.output) /
53
+ 1_000_000
54
+ return { usd, label: entry.label }
55
+ }
56
+
57
+ export function formatUsd(usd: number): string {
58
+ if (usd >= 1) return `$${usd.toFixed(2)}`
59
+ if (usd >= 0.01) return `$${usd.toFixed(3)}`
60
+ return `$${usd.toFixed(4)}`
61
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Decode a DeepSeek Harness session log (JSONL text or pre-parsed records)
3
+ * into a `ParsedSession`: header + flat, seq-ordered `SessionEvent[]`.
4
+ *
5
+ * Handles the two wire realities documented in the harness:
6
+ * - the header line (`type: 'session'`) is not an event;
7
+ * - runs of streaming chunks may be packed into `*-chunks` rows
8
+ * (`seq0`/`time0`/`dt[]`) that expand back into `assistant/chunk` events.
9
+ *
10
+ * A torn final line (crash tail without newline) is tolerated and reported
11
+ * in `malformed`, mirroring the harness's own committed-bytes semantics.
12
+ */
13
+ import type {
14
+ ChunkRowBase,
15
+ ParsedSession,
16
+ SessionEvent,
17
+ SessionHeader,
18
+ StorageRecord,
19
+ } from './types.js'
20
+
21
+ const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
22
+
23
+ export function isChunkRow(record: StorageRecord): record is ChunkRowBase {
24
+ return CHUNK_ROW_TYPES.has(record.type)
25
+ }
26
+
27
+ /** Expand one packed chunk row into its constituent `assistant/chunk` events. */
28
+ export function expandChunkRow(row: ChunkRowBase): SessionEvent[] {
29
+ const { seq0, time0, data } = row
30
+ const { turn, step, index, dt } = data
31
+ const events: SessionEvent[] = []
32
+ const count = (row.type === 'tool-call-chunks' ? data.args?.length : data.texts?.length) ?? 0
33
+ let time = time0
34
+ for (let k = 0; k < count; k++) {
35
+ if (k > 0) time += dt[k - 1] ?? 0
36
+ let chunk: Record<string, unknown>
37
+ if (row.type === 'text-chunks') {
38
+ chunk = { type: 'text-delta', index, text: data.texts![k] }
39
+ } else if (row.type === 'reasoning-chunks') {
40
+ chunk = { type: 'reasoning-delta', index, text: data.texts![k] }
41
+ } else {
42
+ chunk = {
43
+ type: 'tool-call-delta',
44
+ index,
45
+ id: data.id,
46
+ ...(k === 0 && data.name !== undefined ? { name: data.name } : {}),
47
+ args: data.args![k],
48
+ }
49
+ }
50
+ events.push({
51
+ type: 'assistant/chunk',
52
+ seq: seq0 + k,
53
+ time,
54
+ data: { turn, step, chunk } as SessionEvent['data'],
55
+ })
56
+ }
57
+ return events
58
+ }
59
+
60
+ /** Decode one storage record (already JSON.parsed) into 0..n events. */
61
+ export function decodeStorageRecord(record: StorageRecord): SessionEvent[] {
62
+ if (isChunkRow(record)) return expandChunkRow(record)
63
+ return [record]
64
+ }
65
+
66
+ function isHeaderLine(value: Record<string, unknown>): boolean {
67
+ return value['type'] === 'session' && typeof value['id'] === 'string'
68
+ }
69
+
70
+ /**
71
+ * Parse raw JSONL text (uncompressed). The first line must be the session
72
+ * header; every further line is a `StorageRecord`.
73
+ */
74
+ export function parseSessionJsonl(text: string): ParsedSession {
75
+ const lines = text.split('\n')
76
+ const malformed: ParsedSession['malformed'] = []
77
+ let header: SessionHeader | undefined
78
+ const events: SessionEvent[] = []
79
+ const endsWithNewline = text.endsWith('\n')
80
+
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const raw = lines[i]
83
+ if (raw.trim() === '') continue
84
+ const isLastLine = i === lines.length - 1
85
+ let value: Record<string, unknown>
86
+ try {
87
+ value = JSON.parse(raw) as Record<string, unknown>
88
+ } catch (error) {
89
+ if (isLastLine && !endsWithNewline) {
90
+ malformed.push({ line: i + 1, raw, error: 'torn tail (uncommitted final record)' })
91
+ } else {
92
+ malformed.push({ line: i + 1, raw, error: String(error) })
93
+ }
94
+ continue
95
+ }
96
+ if (header === undefined) {
97
+ if (!isHeaderLine(value)) {
98
+ malformed.push({ line: i + 1, raw, error: 'expected session header line' })
99
+ continue
100
+ }
101
+ header = value as unknown as SessionHeader
102
+ continue
103
+ }
104
+ try {
105
+ events.push(...decodeStorageRecord(value as unknown as StorageRecord))
106
+ } catch (error) {
107
+ malformed.push({ line: i + 1, raw, error: String(error) })
108
+ }
109
+ }
110
+
111
+ if (header === undefined) {
112
+ throw new Error('session log has no header line')
113
+ }
114
+ events.sort((a, b) => a.seq - b.seq)
115
+ return { header, events, malformed }
116
+ }
117
+
118
+ /** Build a ParsedSession from an already-materialized header + events (e.g. an API response). */
119
+ export function fromInspection(
120
+ header: SessionHeader,
121
+ records: StorageRecord[],
122
+ ): ParsedSession {
123
+ const events = records.flatMap(decodeStorageRecord).sort((a, b) => a.seq - b.seq)
124
+ return { header, events, malformed: [] }
125
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Build the session lineage forest from headers (`parentSession` +
3
+ * `seedLength`), the durable fork/resume metadata the harness keeps
4
+ * out-of-log. Produces a forest plus a deterministic tidy layout
5
+ * (depth = x, DFS row = y) the SVG view renders directly.
6
+ */
7
+ import type { SessionHeader } from './types.js'
8
+
9
+ export interface ForkNode {
10
+ id: string
11
+ header: SessionHeader
12
+ title?: string
13
+ children: ForkNode[]
14
+ /** Inclusive source event count inherited from the parent (undefined for roots). */
15
+ seedLength?: number
16
+ depth: number
17
+ /** Row index assigned by DFS layout. */
18
+ row: number
19
+ /** True when parentSession points at a header we never saw. */
20
+ orphan?: boolean
21
+ }
22
+
23
+ export interface ForkForest {
24
+ roots: ForkNode[]
25
+ nodes: Map<string, ForkNode>
26
+ rowCount: number
27
+ maxDepth: number
28
+ }
29
+
30
+ export interface SessionListEntry {
31
+ header: SessionHeader
32
+ title?: string
33
+ }
34
+
35
+ export function buildForkForest(entries: SessionListEntry[]): ForkForest {
36
+ const nodes = new Map<string, ForkNode>()
37
+ for (const { header, title } of entries) {
38
+ nodes.set(header.id, {
39
+ id: header.id,
40
+ header,
41
+ title,
42
+ children: [],
43
+ seedLength: header.seedLength,
44
+ depth: 0,
45
+ row: 0,
46
+ })
47
+ }
48
+ const roots: ForkNode[] = []
49
+ for (const node of nodes.values()) {
50
+ const parentId = node.header.parentSession
51
+ if (parentId === undefined) {
52
+ roots.push(node)
53
+ continue
54
+ }
55
+ const parent = nodes.get(parentId)
56
+ if (parent === undefined) {
57
+ node.orphan = true
58
+ roots.push(node)
59
+ } else {
60
+ parent.children.push(node)
61
+ }
62
+ }
63
+ const byCreated = (a: ForkNode, b: ForkNode): number =>
64
+ a.header.createdAt - b.header.createdAt || a.id.localeCompare(b.id)
65
+ roots.sort(byCreated)
66
+ for (const node of nodes.values()) node.children.sort(byCreated)
67
+
68
+ // Cycle guard (corrupt lineage should degrade, not hang): DFS with visited set.
69
+ let row = 0
70
+ let maxDepth = 0
71
+ const visited = new Set<string>()
72
+ const assign = (node: ForkNode, depth: number): void => {
73
+ if (visited.has(node.id)) return
74
+ visited.add(node.id)
75
+ node.depth = depth
76
+ node.row = row++
77
+ maxDepth = Math.max(maxDepth, depth)
78
+ for (const child of node.children) assign(child, depth + 1)
79
+ }
80
+ for (const root of roots) assign(root, 0)
81
+ // Lineage cycles have no root: promote one unvisited member per cycle.
82
+ for (const node of nodes.values()) {
83
+ if (!visited.has(node.id)) {
84
+ node.orphan = true
85
+ roots.push(node)
86
+ assign(node, 0)
87
+ }
88
+ }
89
+ return { roots, nodes, rowCount: row, maxDepth }
90
+ }
91
+
92
+ /** Path from root to the given session (inclusive), for breadcrumbs. */
93
+ export function lineageOf(forest: ForkForest, id: string): ForkNode[] {
94
+ const path: ForkNode[] = []
95
+ let current = forest.nodes.get(id)
96
+ const guard = new Set<string>()
97
+ while (current !== undefined && !guard.has(current.id)) {
98
+ guard.add(current.id)
99
+ path.unshift(current)
100
+ const parentId = current.header.parentSession
101
+ current = parentId === undefined ? undefined : forest.nodes.get(parentId)
102
+ }
103
+ return path
104
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types.js'
2
+ export * from './decode.js'
3
+ export * from './timeline.js'
4
+ export * from './audit.js'
5
+ export * from './forktree.js'
6
+ export * from './compare.js'
7
+ export * from './cost.js'