@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,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
|
package/src/core/cost.ts
ADDED
|
@@ -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
|
+
}
|