@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.
Files changed (53) hide show
  1. package/README.md +225 -148
  2. package/README.zh.md +225 -154
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +954 -2418
  5. package/lib/core.js +557 -86
  6. package/lib/index.js +1043 -51
  7. package/lib/types/src/client/api.d.ts +44 -0
  8. package/lib/types/src/client/components/AuditView.d.ts +2 -1
  9. package/lib/types/src/client/components/CompareView.d.ts +2 -1
  10. package/lib/types/src/client/components/DashboardView.d.ts +8 -0
  11. package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
  12. package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
  13. package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
  14. package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
  15. package/lib/types/src/client/components/TimelineView.d.ts +2 -1
  16. package/lib/types/src/client/export.d.ts +6 -1
  17. package/lib/types/src/client/i18n.d.ts +8 -1
  18. package/lib/types/src/client/pricing.d.ts +6 -0
  19. package/lib/types/src/core/audit.d.ts +18 -0
  20. package/lib/types/src/core/compare.d.ts +8 -3
  21. package/lib/types/src/core/cost.d.ts +21 -3
  22. package/lib/types/src/core/index.d.ts +1 -0
  23. package/lib/types/src/core/redact.d.ts +40 -0
  24. package/lib/types/src/core/timeline.d.ts +9 -0
  25. package/lib/types/src/index.d.ts +52 -1
  26. package/lib/viewer.js +282 -29
  27. package/package.json +118 -107
  28. package/scripts/build.mjs +7 -1
  29. package/src/client/api.ts +114 -10
  30. package/src/client/components/AuditView.tsx +89 -10
  31. package/src/client/components/CompareView.tsx +89 -13
  32. package/src/client/components/DashboardView.tsx +232 -0
  33. package/src/client/components/ExportDialog.tsx +136 -0
  34. package/src/client/components/ItemDetail.tsx +36 -4
  35. package/src/client/components/OverviewView.tsx +150 -34
  36. package/src/client/components/ReplayApp.tsx +207 -60
  37. package/src/client/components/ReplayModal.tsx +41 -6
  38. package/src/client/components/SessionPicker.tsx +55 -27
  39. package/src/client/components/TimelineView.tsx +106 -41
  40. package/src/client/export.ts +20 -4
  41. package/src/client/i18n.ts +266 -45
  42. package/src/client/index.ts +7 -5
  43. package/src/client/mascot-image.ts +1 -1
  44. package/src/client/pricing.ts +90 -0
  45. package/src/client/styles.css +250 -1
  46. package/src/client/viewer-entry.tsx +25 -4
  47. package/src/core/audit.ts +274 -67
  48. package/src/core/compare.ts +0 -0
  49. package/src/core/cost.ts +94 -16
  50. package/src/core/index.ts +1 -0
  51. package/src/core/redact.ts +182 -0
  52. package/src/core/timeline.ts +107 -12
  53. 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
+ }
@@ -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') models.add(source['model'])
309
+ if (typeof source?.['model'] === 'string') {
310
+ activeModel = source['model']
311
+ models.add(activeModel)
312
+ }
269
313
  if (usage) {
270
- usageByStep.set(`${String(turn)}/${String(step)}`, usage)
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
- usageByStep.set(`${String(turn)}/${String(step)}`, usage)
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: String(get(d, 'name') ?? ''),
291
- argumentsRaw: String(get(d, 'arguments') ?? ''),
292
- argumentsPretty: tryPretty(String(get(d, 'arguments') ?? '')),
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: `${info.name}(${truncate(info.argumentsRaw, 48)})`, toolCall: info, event,
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
- typeof get(block, 'text') === 'string'
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') models.add(model)
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,