@mingozhou/dsh-replay 0.4.1 → 0.4.2
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/README.md +225 -148
- package/README.zh.md +225 -154
- package/assets/icon.png +0 -0
- package/lib/client.js +954 -2418
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +7 -1
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- package/src/index.ts +290 -54
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for anything that leaves the process: exported HTML,
|
|
3
|
+
* audit details pasted into a report, and screenshots of the UI.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately conservative and pattern-based. It is a safety net,
|
|
6
|
+
* not a guarantee: the export dialog still warns that session logs can contain
|
|
7
|
+
* arbitrary user data.
|
|
8
|
+
*/
|
|
9
|
+
import type { ParsedSession, SessionEvent } from './types.js'
|
|
10
|
+
|
|
11
|
+
export interface RedactionResult {
|
|
12
|
+
text: string
|
|
13
|
+
count: number
|
|
14
|
+
kinds: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Rule {
|
|
18
|
+
kind: string
|
|
19
|
+
re: RegExp
|
|
20
|
+
// String.replace callbacks receive the match plus arbitrary capture/offset args.
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
replacement: string | ((substring: string, ...args: any[]) => string)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RULES: Rule[] = [
|
|
26
|
+
{ kind: 'openai-key', re: /\bsk-[A-Za-z0-9_-]{16,}\b/g, replacement: '[REDACTED:openai-key]' },
|
|
27
|
+
{
|
|
28
|
+
kind: 'github-token',
|
|
29
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
|
|
30
|
+
replacement: '[REDACTED:github-token]',
|
|
31
|
+
},
|
|
32
|
+
{ kind: 'aws-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: '[REDACTED:aws-key]' },
|
|
33
|
+
{ kind: 'google-key', re: /\bAIza[0-9A-Za-z_-]{20,}\b/g, replacement: '[REDACTED:google-key]' },
|
|
34
|
+
{ kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED:slack-token]' },
|
|
35
|
+
{
|
|
36
|
+
kind: 'private-key',
|
|
37
|
+
re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
38
|
+
replacement: '[REDACTED:private-key]',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
kind: 'jwt',
|
|
42
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
43
|
+
replacement: '[REDACTED:jwt]',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
kind: 'env-assignment',
|
|
47
|
+
re: /(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL)[A-Z0-9_]*\s*=\s*)(["']?)([^\s"'\\]{4,})\2/g,
|
|
48
|
+
replacement: (_m, prefix: string, quote: string) => `${prefix}${quote}[REDACTED]${quote}`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
kind: 'key-value',
|
|
52
|
+
re: /(["']?(?:api[_-]?key|secret|token|password|passwd|authorization|jwt[_-]?secret)["']?\s*[:=]\s*["']?)([^"'\s,}]{4,})/gi,
|
|
53
|
+
replacement: (_m, prefix: string) => `${prefix}[REDACTED]`,
|
|
54
|
+
},
|
|
55
|
+
{ kind: 'bearer', re: /(\bBearer\s+)[A-Za-z0-9._~+/-]{8,}/gi, replacement: '$1[REDACTED]' },
|
|
56
|
+
{
|
|
57
|
+
kind: 'basic-auth-url',
|
|
58
|
+
re: /(https?:\/\/[^/\s:@]+:)([^@\s/]+)(@)/g,
|
|
59
|
+
replacement: '$1[REDACTED]$3',
|
|
60
|
+
},
|
|
61
|
+
{ kind: 'windows-user', re: /([A-Za-z]:\\Users\\)([^\\\s"']+)/g, replacement: '$1[user]' },
|
|
62
|
+
{ kind: 'unix-home', re: /(\/(?:home|Users)\/)([^/\s"']+)/g, replacement: '$1[user]' },
|
|
63
|
+
{ kind: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, replacement: '[email]' },
|
|
64
|
+
{ kind: 'session-id', re: /\bsession-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/gi, replacement: 'session-[id]' },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
const CREDENTIAL_KINDS = new Set([
|
|
68
|
+
'openai-key',
|
|
69
|
+
'github-token',
|
|
70
|
+
'aws-key',
|
|
71
|
+
'google-key',
|
|
72
|
+
'slack-token',
|
|
73
|
+
'private-key',
|
|
74
|
+
'jwt',
|
|
75
|
+
'env-assignment',
|
|
76
|
+
'key-value',
|
|
77
|
+
'bearer',
|
|
78
|
+
'basic-auth-url',
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
/** Mask secrets in one string. */
|
|
82
|
+
export function redactText(input: string): RedactionResult {
|
|
83
|
+
let text = input
|
|
84
|
+
let count = 0
|
|
85
|
+
const kinds = new Set<string>()
|
|
86
|
+
for (const rule of RULES) {
|
|
87
|
+
rule.re.lastIndex = 0
|
|
88
|
+
const matches = text.match(rule.re)
|
|
89
|
+
if (matches === null) continue
|
|
90
|
+
count += matches.length
|
|
91
|
+
kinds.add(rule.kind)
|
|
92
|
+
rule.re.lastIndex = 0
|
|
93
|
+
text = text.replace(rule.re, rule.replacement as string)
|
|
94
|
+
}
|
|
95
|
+
return { text, count, kinds: [...kinds] }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when the text looks like it contains a credential. Deliberately
|
|
100
|
+
* narrower than `redactText`: an email address or a home path is privacy
|
|
101
|
+
* sensitive, but not a reason to raise a critical audit finding.
|
|
102
|
+
*/
|
|
103
|
+
export function containsSecret(input: string): boolean {
|
|
104
|
+
for (const rule of RULES) {
|
|
105
|
+
if (!CREDENTIAL_KINDS.has(rule.kind)) continue
|
|
106
|
+
rule.re.lastIndex = 0
|
|
107
|
+
const hit = rule.re.test(input)
|
|
108
|
+
rule.re.lastIndex = 0
|
|
109
|
+
if (hit) return true
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Deep-redact a JSON-like value. Keys are preserved; string values are
|
|
116
|
+
* redacted. Depth is bounded so a hostile log cannot exhaust the stack.
|
|
117
|
+
*/
|
|
118
|
+
export function redactValue<T>(value: T, depth = 0): { value: T; count: number } {
|
|
119
|
+
if (depth > 20) return { value, count: 0 }
|
|
120
|
+
if (typeof value === 'string') {
|
|
121
|
+
const result = redactText(value)
|
|
122
|
+
return { value: result.text as unknown as T, count: result.count }
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
let count = 0
|
|
126
|
+
const next = value.map(item => {
|
|
127
|
+
const result = redactValue(item, depth + 1)
|
|
128
|
+
count += result.count
|
|
129
|
+
return result.value
|
|
130
|
+
})
|
|
131
|
+
return { value: next as unknown as T, count }
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === 'object' && value !== null) {
|
|
134
|
+
let count = 0
|
|
135
|
+
const next: Record<string, unknown> = {}
|
|
136
|
+
for (const [key, item] of Object.entries(value)) {
|
|
137
|
+
const result = redactValue(item, depth + 1)
|
|
138
|
+
count += result.count
|
|
139
|
+
next[key] = result.value
|
|
140
|
+
}
|
|
141
|
+
return { value: next as unknown as T, count }
|
|
142
|
+
}
|
|
143
|
+
return { value, count: 0 }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RedactSessionResult {
|
|
147
|
+
session: ParsedSession
|
|
148
|
+
count: number
|
|
149
|
+
kinds: string[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Clone and redact a parsed session. Event envelopes (seq/time/type) are
|
|
154
|
+
* preserved so the viewer still works; only payload strings are masked.
|
|
155
|
+
*/
|
|
156
|
+
export function redactSession(session: ParsedSession): RedactSessionResult {
|
|
157
|
+
let count = 0
|
|
158
|
+
const kinds = new Set<string>()
|
|
159
|
+
const header = { ...session.header }
|
|
160
|
+
for (const key of ['cwd', 'title'] as const) {
|
|
161
|
+
const value = (header as Record<string, unknown>)[key]
|
|
162
|
+
if (typeof value === 'string') {
|
|
163
|
+
const result = redactText(value)
|
|
164
|
+
;(header as Record<string, unknown>)[key] = result.text
|
|
165
|
+
count += result.count
|
|
166
|
+
for (const kind of result.kinds) kinds.add(kind)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const events: SessionEvent[] = session.events.map(event => {
|
|
170
|
+
const data = redactValue(event.data)
|
|
171
|
+
count += data.count
|
|
172
|
+
if (data.count > 0) {
|
|
173
|
+
for (const rule of RULES) {
|
|
174
|
+
rule.re.lastIndex = 0
|
|
175
|
+
if (rule.re.test(JSON.stringify(event.data))) kinds.add(rule.kind)
|
|
176
|
+
rule.re.lastIndex = 0
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { ...event, data: data.value }
|
|
180
|
+
})
|
|
181
|
+
return { session: { ...session, header, events }, count, kinds: [...kinds] }
|
|
182
|
+
}
|
package/src/core/timeline.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ToolCallInfo {
|
|
|
20
20
|
/** Raw argument JSON string exactly as the model produced it. */
|
|
21
21
|
argumentsRaw: string
|
|
22
22
|
argumentsPretty?: string
|
|
23
|
+
/** Short, human-readable summary (command/path/url/query) for the rail. */
|
|
24
|
+
summary?: string
|
|
23
25
|
resultText?: string
|
|
24
26
|
error?: { name: string; code: string }
|
|
25
27
|
callSeq: number
|
|
@@ -85,6 +87,8 @@ export interface TimelineModel {
|
|
|
85
87
|
/** Flat, seq-ordered list of everything renderable. */
|
|
86
88
|
items: TimelineItem[]
|
|
87
89
|
totals: TokenTotals
|
|
90
|
+
/** Token totals split by model, for cache-aware multi-model cost. */
|
|
91
|
+
modelTotals: Record<string, TokenTotals>
|
|
88
92
|
/** Per assistant-step usage samples in order, for the token strip. */
|
|
89
93
|
usageSamples: { seq: number; turn: number; step: number; usage: TokenUsage }[]
|
|
90
94
|
toolCalls: ToolCallInfo[]
|
|
@@ -102,11 +106,44 @@ export interface TimelineModel {
|
|
|
102
106
|
function contentText(message: MessageLike | undefined): string {
|
|
103
107
|
if (!message?.content) return ''
|
|
104
108
|
return message.content
|
|
105
|
-
.filter((b: ContentBlock) => typeof b.text === 'string')
|
|
109
|
+
.filter((b: ContentBlock) => typeof b.text === 'string' && b.type !== 'reasoning')
|
|
106
110
|
.map((b: ContentBlock) => b.text)
|
|
107
111
|
.join('')
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
function contentReasoning(message: MessageLike | undefined): string {
|
|
115
|
+
if (!message?.content) return ''
|
|
116
|
+
return message.content
|
|
117
|
+
.filter((b: ContentBlock) => b.type === 'reasoning' && typeof b.text === 'string')
|
|
118
|
+
.map((b: ContentBlock) => b.text)
|
|
119
|
+
.join('')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function oneLine(text: string, max: number): string {
|
|
123
|
+
return truncate(text.replaceAll(/\s+/gu, ' ').trim(), max)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Turn tool arguments into a label a human can scan. The raw JSON stays
|
|
128
|
+
* available in the detail panel; the rail should not be a wall of escapes.
|
|
129
|
+
*/
|
|
130
|
+
export function summarizeToolArguments(name: string, raw: string): string {
|
|
131
|
+
let args: Record<string, unknown> | undefined
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(raw) as unknown
|
|
134
|
+
if (typeof parsed === 'object' && parsed !== null) args = parsed as Record<string, unknown>
|
|
135
|
+
} catch {
|
|
136
|
+
args = undefined
|
|
137
|
+
}
|
|
138
|
+
if (args === undefined) return oneLine(raw, 64)
|
|
139
|
+
const keys = ['command', 'cmd', 'script', 'path', 'file_path', 'filePath', 'url', 'query', 'pattern', 'description']
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
const value = args[key]
|
|
142
|
+
if (typeof value === 'string' && value.trim() !== '') return oneLine(value, 64)
|
|
143
|
+
}
|
|
144
|
+
return name
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
function truncate(text: string, max = 96): string {
|
|
111
148
|
const oneLine = text.replaceAll(/\s+/gu, ' ').trim()
|
|
112
149
|
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine
|
|
@@ -193,10 +230,13 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
193
230
|
const toolCallsBySeq = new Map<string, ToolCallInfo>()
|
|
194
231
|
const toolCalls: ToolCallInfo[] = []
|
|
195
232
|
const usageByStep = new Map<string, TokenUsage>()
|
|
233
|
+
const usageModelByStep = new Map<string, string>()
|
|
234
|
+
const modelTotals: Record<string, TokenTotals> = {}
|
|
196
235
|
const models = new Set<string>()
|
|
197
236
|
const shadowed = shadowedSeqs(events)
|
|
198
237
|
let seedBoundarySeq: number | undefined
|
|
199
238
|
let activeTurn: number | undefined
|
|
239
|
+
let activeModel: string | undefined
|
|
200
240
|
let sessionTitle: string | undefined
|
|
201
241
|
|
|
202
242
|
const ensureTurn = (turn: number, event: SessionEvent): TurnModel => {
|
|
@@ -264,15 +304,21 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
264
304
|
const message = get(d, 'message') as MessageLike
|
|
265
305
|
const usage = get(d, 'usage') as TokenUsage | undefined
|
|
266
306
|
const text = contentText(message)
|
|
307
|
+
const reasoningText = contentReasoning(message)
|
|
267
308
|
const source = message?.source as Record<string, unknown> | undefined
|
|
268
|
-
if (typeof source?.['model'] === 'string')
|
|
309
|
+
if (typeof source?.['model'] === 'string') {
|
|
310
|
+
activeModel = source['model']
|
|
311
|
+
models.add(activeModel)
|
|
312
|
+
}
|
|
269
313
|
if (usage) {
|
|
270
|
-
|
|
314
|
+
const key = `${String(turn)}/${String(step)}`
|
|
315
|
+
usageByStep.set(key, usage)
|
|
316
|
+
if (activeModel !== undefined) usageModelByStep.set(key, activeModel)
|
|
271
317
|
}
|
|
272
318
|
push({
|
|
273
319
|
kind: 'assistant', seq: event.seq, time: event.time,
|
|
274
320
|
turn: turn as number, step: step as number, type: event.type,
|
|
275
|
-
label: truncate(text) || '(assistant message)', text, usage, event,
|
|
321
|
+
label: truncate(text) || '(assistant message)', text, reasoningText, usage, event,
|
|
276
322
|
})
|
|
277
323
|
break
|
|
278
324
|
}
|
|
@@ -280,16 +326,23 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
280
326
|
const chunk = get(d, 'chunk')
|
|
281
327
|
if (get(chunk, 'type') === 'usage') {
|
|
282
328
|
const usage = get(chunk, 'usage') as TokenUsage
|
|
283
|
-
|
|
329
|
+
const key = `${String(turn)}/${String(step)}`
|
|
330
|
+
usageByStep.set(key, usage)
|
|
331
|
+
if (activeModel !== undefined && !usageModelByStep.has(key)) {
|
|
332
|
+
usageModelByStep.set(key, activeModel)
|
|
333
|
+
}
|
|
284
334
|
}
|
|
285
335
|
break
|
|
286
336
|
}
|
|
287
337
|
case 'tool/call': {
|
|
338
|
+
const raw = String(get(d, 'arguments') ?? '')
|
|
339
|
+
const name = String(get(d, 'name') ?? '')
|
|
288
340
|
const info: ToolCallInfo = {
|
|
289
341
|
callId: String(get(d, 'callId') ?? ''),
|
|
290
|
-
name
|
|
291
|
-
argumentsRaw:
|
|
292
|
-
argumentsPretty: tryPretty(
|
|
342
|
+
name,
|
|
343
|
+
argumentsRaw: raw,
|
|
344
|
+
argumentsPretty: tryPretty(raw),
|
|
345
|
+
summary: summarizeToolArguments(name, raw),
|
|
293
346
|
callSeq: event.seq,
|
|
294
347
|
callTime: event.time,
|
|
295
348
|
}
|
|
@@ -298,7 +351,8 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
298
351
|
push({
|
|
299
352
|
kind: 'tool', seq: event.seq, time: event.time,
|
|
300
353
|
turn: turn as number, step: step as number, type: event.type,
|
|
301
|
-
label:
|
|
354
|
+
label: info.summary === name ? name : `${name} · ${info.summary}`,
|
|
355
|
+
toolCall: info, event,
|
|
302
356
|
})
|
|
303
357
|
break
|
|
304
358
|
}
|
|
@@ -309,9 +363,10 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
309
363
|
const info = toolCallsBySeq.get(callId)
|
|
310
364
|
const block = message?.content?.[0]
|
|
311
365
|
const resultText =
|
|
312
|
-
|
|
366
|
+
contentText(message) ||
|
|
367
|
+
(typeof get(block, 'text') === 'string'
|
|
313
368
|
? (get(block, 'text') as string)
|
|
314
|
-
: contentText({ content: get(block, 'content') as ContentBlock[] | undefined })
|
|
369
|
+
: contentText({ content: get(block, 'content') as ContentBlock[] | undefined }))
|
|
315
370
|
const error = get(d, 'error') as { name: string; code: string } | undefined
|
|
316
371
|
if (info) {
|
|
317
372
|
info.resultSeq = event.seq
|
|
@@ -329,9 +384,35 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
329
384
|
label: 'seed boundary (fork/resume point)', event,
|
|
330
385
|
})
|
|
331
386
|
break
|
|
387
|
+
case 'system/message': {
|
|
388
|
+
const message = get(d, 'message') as MessageLike
|
|
389
|
+
const text = contentText(message)
|
|
390
|
+
push({
|
|
391
|
+
kind: 'meta', seq: event.seq, time: event.time,
|
|
392
|
+
turn: typeof turn === 'number' ? turn : undefined,
|
|
393
|
+
step: typeof step === 'number' ? step : undefined,
|
|
394
|
+
type: event.type, label: 'system prompt', text, event,
|
|
395
|
+
})
|
|
396
|
+
break
|
|
397
|
+
}
|
|
398
|
+
case 'model/selection': {
|
|
399
|
+
const model = get(d, 'model')
|
|
400
|
+
if (typeof model === 'string' && model !== '') {
|
|
401
|
+
activeModel = model
|
|
402
|
+
models.add(model)
|
|
403
|
+
push({
|
|
404
|
+
kind: 'meta', seq: event.seq, time: event.time, type: event.type,
|
|
405
|
+
label: `model selected: ${model}`, event,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
break
|
|
409
|
+
}
|
|
332
410
|
case 'request/context': {
|
|
333
411
|
const model = get(d, 'model')
|
|
334
|
-
if (typeof model === 'string')
|
|
412
|
+
if (typeof model === 'string') {
|
|
413
|
+
activeModel = model
|
|
414
|
+
models.add(model)
|
|
415
|
+
}
|
|
335
416
|
break
|
|
336
417
|
}
|
|
337
418
|
case 'session/title': {
|
|
@@ -356,13 +437,26 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
356
437
|
|
|
357
438
|
// Fold usage samples (already last-wins per turn/step via the map).
|
|
358
439
|
const totals: TokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, billedInput: 0 }
|
|
440
|
+
const emptyTotals = (): TokenTotals => ({
|
|
441
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, billedInput: 0,
|
|
442
|
+
})
|
|
359
443
|
for (const [key, usage] of usageByStep) {
|
|
360
444
|
const [turnStr, stepStr] = key.split('/')
|
|
445
|
+
const billed = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
361
446
|
totals.input += usage.inputTokens
|
|
362
447
|
totals.output += usage.outputTokens
|
|
363
448
|
totals.cacheRead += usage.cacheReadTokens ?? 0
|
|
364
449
|
totals.cacheWrite += usage.cacheWriteTokens ?? 0
|
|
365
450
|
totals.reasoning += usage.reasoningTokens ?? 0
|
|
451
|
+
const modelName = usageModelByStep.get(key) ?? activeModel ?? 'unknown'
|
|
452
|
+
const modelTotal = modelTotals[modelName] ?? emptyTotals()
|
|
453
|
+
modelTotal.input += usage.inputTokens
|
|
454
|
+
modelTotal.output += usage.outputTokens
|
|
455
|
+
modelTotal.cacheRead += usage.cacheReadTokens ?? 0
|
|
456
|
+
modelTotal.cacheWrite += usage.cacheWriteTokens ?? 0
|
|
457
|
+
modelTotal.reasoning += usage.reasoningTokens ?? 0
|
|
458
|
+
modelTotal.billedInput += billed
|
|
459
|
+
modelTotals[modelName] = modelTotal
|
|
366
460
|
const turnModel = turns.get(Number(turnStr))
|
|
367
461
|
const stepModel = turnModel?.steps.find(s => s.step === Number(stepStr))
|
|
368
462
|
if (stepModel) stepModel.usage = usage
|
|
@@ -383,6 +477,7 @@ export function buildTimeline(session: ParsedSession): TimelineModel {
|
|
|
383
477
|
turns: orderedTurns,
|
|
384
478
|
items,
|
|
385
479
|
totals,
|
|
480
|
+
modelTotals,
|
|
386
481
|
usageSamples,
|
|
387
482
|
toolCalls,
|
|
388
483
|
durationMs: endTime - startTime,
|