@mingozhou/dsh-replay 0.3.3 → 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 -164
  2. package/README.zh.md +225 -164
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +955 -2419
  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 +8 -2
  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
package/lib/core.js CHANGED
@@ -90,11 +90,34 @@ function fromInspection(header, records) {
90
90
  // src/core/timeline.ts
91
91
  function contentText(message) {
92
92
  if (!message?.content) return "";
93
- return message.content.filter((b) => typeof b.text === "string").map((b) => b.text).join("");
93
+ return message.content.filter((b) => typeof b.text === "string" && b.type !== "reasoning").map((b) => b.text).join("");
94
+ }
95
+ function contentReasoning(message) {
96
+ if (!message?.content) return "";
97
+ return message.content.filter((b) => b.type === "reasoning" && typeof b.text === "string").map((b) => b.text).join("");
98
+ }
99
+ function oneLine(text, max) {
100
+ return truncate(text.replaceAll(/\s+/gu, " ").trim(), max);
101
+ }
102
+ function summarizeToolArguments(name, raw) {
103
+ let args;
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ if (typeof parsed === "object" && parsed !== null) args = parsed;
107
+ } catch {
108
+ args = void 0;
109
+ }
110
+ if (args === void 0) return oneLine(raw, 64);
111
+ const keys = ["command", "cmd", "script", "path", "file_path", "filePath", "url", "query", "pattern", "description"];
112
+ for (const key of keys) {
113
+ const value = args[key];
114
+ if (typeof value === "string" && value.trim() !== "") return oneLine(value, 64);
115
+ }
116
+ return name;
94
117
  }
95
118
  function truncate(text, max = 96) {
96
- const oneLine = text.replaceAll(/\s+/gu, " ").trim();
97
- return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
119
+ const oneLine2 = text.replaceAll(/\s+/gu, " ").trim();
120
+ return oneLine2.length > max ? `${oneLine2.slice(0, max - 1)}\u2026` : oneLine2;
98
121
  }
99
122
  function tryPretty(raw) {
100
123
  try {
@@ -170,10 +193,13 @@ function buildTimeline(session) {
170
193
  const toolCallsBySeq = /* @__PURE__ */ new Map();
171
194
  const toolCalls = [];
172
195
  const usageByStep = /* @__PURE__ */ new Map();
196
+ const usageModelByStep = /* @__PURE__ */ new Map();
197
+ const modelTotals = {};
173
198
  const models = /* @__PURE__ */ new Set();
174
199
  const shadowed = shadowedSeqs(events);
175
200
  let seedBoundarySeq;
176
201
  let activeTurn;
202
+ let activeModel;
177
203
  let sessionTitle;
178
204
  const ensureTurn = (turn, event) => {
179
205
  let model = turns.get(turn);
@@ -245,10 +271,16 @@ function buildTimeline(session) {
245
271
  const message = get(d, "message");
246
272
  const usage = get(d, "usage");
247
273
  const text = contentText(message);
274
+ const reasoningText = contentReasoning(message);
248
275
  const source = message?.source;
249
- if (typeof source?.["model"] === "string") models.add(source["model"]);
276
+ if (typeof source?.["model"] === "string") {
277
+ activeModel = source["model"];
278
+ models.add(activeModel);
279
+ }
250
280
  if (usage) {
251
- usageByStep.set(`${String(turn)}/${String(step)}`, usage);
281
+ const key = `${String(turn)}/${String(step)}`;
282
+ usageByStep.set(key, usage);
283
+ if (activeModel !== void 0) usageModelByStep.set(key, activeModel);
252
284
  }
253
285
  push({
254
286
  kind: "assistant",
@@ -259,6 +291,7 @@ function buildTimeline(session) {
259
291
  type: event.type,
260
292
  label: truncate(text) || "(assistant message)",
261
293
  text,
294
+ reasoningText,
262
295
  usage,
263
296
  event
264
297
  });
@@ -268,16 +301,23 @@ function buildTimeline(session) {
268
301
  const chunk = get(d, "chunk");
269
302
  if (get(chunk, "type") === "usage") {
270
303
  const usage = get(chunk, "usage");
271
- usageByStep.set(`${String(turn)}/${String(step)}`, usage);
304
+ const key = `${String(turn)}/${String(step)}`;
305
+ usageByStep.set(key, usage);
306
+ if (activeModel !== void 0 && !usageModelByStep.has(key)) {
307
+ usageModelByStep.set(key, activeModel);
308
+ }
272
309
  }
273
310
  break;
274
311
  }
275
312
  case "tool/call": {
313
+ const raw = String(get(d, "arguments") ?? "");
314
+ const name = String(get(d, "name") ?? "");
276
315
  const info = {
277
316
  callId: String(get(d, "callId") ?? ""),
278
- name: String(get(d, "name") ?? ""),
279
- argumentsRaw: String(get(d, "arguments") ?? ""),
280
- argumentsPretty: tryPretty(String(get(d, "arguments") ?? "")),
317
+ name,
318
+ argumentsRaw: raw,
319
+ argumentsPretty: tryPretty(raw),
320
+ summary: summarizeToolArguments(name, raw),
281
321
  callSeq: event.seq,
282
322
  callTime: event.time
283
323
  };
@@ -290,7 +330,7 @@ function buildTimeline(session) {
290
330
  turn,
291
331
  step,
292
332
  type: event.type,
293
- label: `${info.name}(${truncate(info.argumentsRaw, 48)})`,
333
+ label: info.summary === name ? name : `${name} \xB7 ${info.summary}`,
294
334
  toolCall: info,
295
335
  event
296
336
  });
@@ -302,7 +342,7 @@ function buildTimeline(session) {
302
342
  const callId = String(source?.["callId"] ?? "");
303
343
  const info = toolCallsBySeq.get(callId);
304
344
  const block = message?.content?.[0];
305
- const resultText = typeof get(block, "text") === "string" ? get(block, "text") : contentText({ content: get(block, "content") });
345
+ const resultText = contentText(message) || (typeof get(block, "text") === "string" ? get(block, "text") : contentText({ content: get(block, "content") }));
306
346
  const error = get(d, "error");
307
347
  if (info) {
308
348
  info.resultSeq = event.seq;
@@ -324,9 +364,44 @@ function buildTimeline(session) {
324
364
  event
325
365
  });
326
366
  break;
367
+ case "system/message": {
368
+ const message = get(d, "message");
369
+ const text = contentText(message);
370
+ push({
371
+ kind: "meta",
372
+ seq: event.seq,
373
+ time: event.time,
374
+ turn: typeof turn === "number" ? turn : void 0,
375
+ step: typeof step === "number" ? step : void 0,
376
+ type: event.type,
377
+ label: "system prompt",
378
+ text,
379
+ event
380
+ });
381
+ break;
382
+ }
383
+ case "model/selection": {
384
+ const model = get(d, "model");
385
+ if (typeof model === "string" && model !== "") {
386
+ activeModel = model;
387
+ models.add(model);
388
+ push({
389
+ kind: "meta",
390
+ seq: event.seq,
391
+ time: event.time,
392
+ type: event.type,
393
+ label: `model selected: ${model}`,
394
+ event
395
+ });
396
+ }
397
+ break;
398
+ }
327
399
  case "request/context": {
328
400
  const model = get(d, "model");
329
- if (typeof model === "string") models.add(model);
401
+ if (typeof model === "string") {
402
+ activeModel = model;
403
+ models.add(model);
404
+ }
330
405
  break;
331
406
  }
332
407
  case "session/title": {
@@ -353,13 +428,31 @@ function buildTimeline(session) {
353
428
  }
354
429
  }
355
430
  const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, billedInput: 0 };
431
+ const emptyTotals = () => ({
432
+ input: 0,
433
+ output: 0,
434
+ cacheRead: 0,
435
+ cacheWrite: 0,
436
+ reasoning: 0,
437
+ billedInput: 0
438
+ });
356
439
  for (const [key, usage] of usageByStep) {
357
440
  const [turnStr, stepStr] = key.split("/");
441
+ const billed = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
358
442
  totals.input += usage.inputTokens;
359
443
  totals.output += usage.outputTokens;
360
444
  totals.cacheRead += usage.cacheReadTokens ?? 0;
361
445
  totals.cacheWrite += usage.cacheWriteTokens ?? 0;
362
446
  totals.reasoning += usage.reasoningTokens ?? 0;
447
+ const modelName = usageModelByStep.get(key) ?? activeModel ?? "unknown";
448
+ const modelTotal = modelTotals[modelName] ?? emptyTotals();
449
+ modelTotal.input += usage.inputTokens;
450
+ modelTotal.output += usage.outputTokens;
451
+ modelTotal.cacheRead += usage.cacheReadTokens ?? 0;
452
+ modelTotal.cacheWrite += usage.cacheWriteTokens ?? 0;
453
+ modelTotal.reasoning += usage.reasoningTokens ?? 0;
454
+ modelTotal.billedInput += billed;
455
+ modelTotals[modelName] = modelTotal;
363
456
  const turnModel = turns.get(Number(turnStr));
364
457
  const stepModel = turnModel?.steps.find((s) => s.step === Number(stepStr));
365
458
  if (stepModel) stepModel.usage = usage;
@@ -381,6 +474,7 @@ function buildTimeline(session) {
381
474
  turns: orderedTurns,
382
475
  items,
383
476
  totals,
477
+ modelTotals,
384
478
  usageSamples,
385
479
  toolCalls,
386
480
  durationMs: endTime - startTime,
@@ -393,129 +487,383 @@ function buildTimeline(session) {
393
487
  };
394
488
  }
395
489
 
490
+ // src/core/redact.ts
491
+ var RULES = [
492
+ { kind: "openai-key", re: /\bsk-[A-Za-z0-9_-]{16,}\b/g, replacement: "[REDACTED:openai-key]" },
493
+ {
494
+ kind: "github-token",
495
+ re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
496
+ replacement: "[REDACTED:github-token]"
497
+ },
498
+ { kind: "aws-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED:aws-key]" },
499
+ { kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{20,}\b/g, replacement: "[REDACTED:google-key]" },
500
+ { kind: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replacement: "[REDACTED:slack-token]" },
501
+ {
502
+ kind: "private-key",
503
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
504
+ replacement: "[REDACTED:private-key]"
505
+ },
506
+ {
507
+ kind: "jwt",
508
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
509
+ replacement: "[REDACTED:jwt]"
510
+ },
511
+ {
512
+ kind: "env-assignment",
513
+ re: /(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL)[A-Z0-9_]*\s*=\s*)(["']?)([^\s"'\\]{4,})\2/g,
514
+ replacement: (_m, prefix, quote) => `${prefix}${quote}[REDACTED]${quote}`
515
+ },
516
+ {
517
+ kind: "key-value",
518
+ re: /(["']?(?:api[_-]?key|secret|token|password|passwd|authorization|jwt[_-]?secret)["']?\s*[:=]\s*["']?)([^"'\s,}]{4,})/gi,
519
+ replacement: (_m, prefix) => `${prefix}[REDACTED]`
520
+ },
521
+ { kind: "bearer", re: /(\bBearer\s+)[A-Za-z0-9._~+/-]{8,}/gi, replacement: "$1[REDACTED]" },
522
+ {
523
+ kind: "basic-auth-url",
524
+ re: /(https?:\/\/[^/\s:@]+:)([^@\s/]+)(@)/g,
525
+ replacement: "$1[REDACTED]$3"
526
+ },
527
+ { kind: "windows-user", re: /([A-Za-z]:\\Users\\)([^\\\s"']+)/g, replacement: "$1[user]" },
528
+ { kind: "unix-home", re: /(\/(?:home|Users)\/)([^/\s"']+)/g, replacement: "$1[user]" },
529
+ { kind: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, replacement: "[email]" },
530
+ { kind: "session-id", re: /\bsession-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/gi, replacement: "session-[id]" }
531
+ ];
532
+ var CREDENTIAL_KINDS = /* @__PURE__ */ new Set([
533
+ "openai-key",
534
+ "github-token",
535
+ "aws-key",
536
+ "google-key",
537
+ "slack-token",
538
+ "private-key",
539
+ "jwt",
540
+ "env-assignment",
541
+ "key-value",
542
+ "bearer",
543
+ "basic-auth-url"
544
+ ]);
545
+ function redactText(input) {
546
+ let text = input;
547
+ let count = 0;
548
+ const kinds = /* @__PURE__ */ new Set();
549
+ for (const rule of RULES) {
550
+ rule.re.lastIndex = 0;
551
+ const matches = text.match(rule.re);
552
+ if (matches === null) continue;
553
+ count += matches.length;
554
+ kinds.add(rule.kind);
555
+ rule.re.lastIndex = 0;
556
+ text = text.replace(rule.re, rule.replacement);
557
+ }
558
+ return { text, count, kinds: [...kinds] };
559
+ }
560
+ function containsSecret(input) {
561
+ for (const rule of RULES) {
562
+ if (!CREDENTIAL_KINDS.has(rule.kind)) continue;
563
+ rule.re.lastIndex = 0;
564
+ const hit = rule.re.test(input);
565
+ rule.re.lastIndex = 0;
566
+ if (hit) return true;
567
+ }
568
+ return false;
569
+ }
570
+ function redactValue(value, depth = 0) {
571
+ if (depth > 20) return { value, count: 0 };
572
+ if (typeof value === "string") {
573
+ const result = redactText(value);
574
+ return { value: result.text, count: result.count };
575
+ }
576
+ if (Array.isArray(value)) {
577
+ let count = 0;
578
+ const next = value.map((item) => {
579
+ const result = redactValue(item, depth + 1);
580
+ count += result.count;
581
+ return result.value;
582
+ });
583
+ return { value: next, count };
584
+ }
585
+ if (typeof value === "object" && value !== null) {
586
+ let count = 0;
587
+ const next = {};
588
+ for (const [key, item] of Object.entries(value)) {
589
+ const result = redactValue(item, depth + 1);
590
+ count += result.count;
591
+ next[key] = result.value;
592
+ }
593
+ return { value: next, count };
594
+ }
595
+ return { value, count: 0 };
596
+ }
597
+ function redactSession(session) {
598
+ let count = 0;
599
+ const kinds = /* @__PURE__ */ new Set();
600
+ const header = { ...session.header };
601
+ for (const key of ["cwd", "title"]) {
602
+ const value = header[key];
603
+ if (typeof value === "string") {
604
+ const result = redactText(value);
605
+ header[key] = result.text;
606
+ count += result.count;
607
+ for (const kind of result.kinds) kinds.add(kind);
608
+ }
609
+ }
610
+ const events = session.events.map((event) => {
611
+ const data = redactValue(event.data);
612
+ count += data.count;
613
+ if (data.count > 0) {
614
+ for (const rule of RULES) {
615
+ rule.re.lastIndex = 0;
616
+ if (rule.re.test(JSON.stringify(event.data))) kinds.add(rule.kind);
617
+ rule.re.lastIndex = 0;
618
+ }
619
+ }
620
+ return { ...event, data: data.value };
621
+ });
622
+ return { session: { ...session, header, events }, count, kinds: [...kinds] };
623
+ }
624
+
396
625
  // src/core/audit.ts
397
626
  function get2(obj, key) {
398
627
  return typeof obj === "object" && obj !== null ? obj[key] : void 0;
399
628
  }
400
629
  function str(value) {
401
- return typeof value === "string" ? value : JSON.stringify(value) ?? "";
630
+ if (typeof value === "string") return value;
631
+ try {
632
+ return JSON.stringify(value) ?? "";
633
+ } catch {
634
+ return "";
635
+ }
636
+ }
637
+ function clipped(value, max = 500) {
638
+ return redactText(value).text.slice(0, max);
402
639
  }
403
- var SHELL_TOOLS = /^(bash|shell|exec|run_command|terminal|subprocess)/iu;
404
- var WRITE_TOOLS = /^(write|edit|create_file|str_replace|apply_patch|notebook_edit|fs_write|mv|rm|delete)/iu;
405
- var NETWORK_TOOLS = /^(fetch|web_fetch|http|curl|download|web_search|browser)/iu;
640
+ function toolName(event) {
641
+ return str(get2(event.data, "name"));
642
+ }
643
+ function argsOf(event) {
644
+ return str(get2(event.data, "arguments"));
645
+ }
646
+ function parseArgs(raw) {
647
+ try {
648
+ const value = JSON.parse(raw);
649
+ return typeof value === "object" && value !== null ? value : void 0;
650
+ } catch {
651
+ return void 0;
652
+ }
653
+ }
654
+ var PATH_KEYS = [
655
+ "path",
656
+ "file_path",
657
+ "filePath",
658
+ "target",
659
+ "destination",
660
+ "dest",
661
+ "filename",
662
+ "file",
663
+ "name"
664
+ ];
665
+ function pathOf(raw) {
666
+ const args = parseArgs(raw);
667
+ if (args === void 0) return void 0;
668
+ for (const key of PATH_KEYS) {
669
+ const value = args[key];
670
+ if (typeof value === "string" && value !== "" && /[\\/.]/u.test(value)) return value;
671
+ }
672
+ return void 0;
673
+ }
674
+ var SHELL_TOOLS = /^(bash|shell|exec|run_command|terminal|subprocess|pwsh|powershell|cmd|run_code|code_execution)/iu;
675
+ var WRITE_TOOLS = /^(write|write_file|edit|edit_file|create_file|str_replace|multi_edit|apply_patch|notebook_edit|fs_write|mv|move|rename|copy|rm|remove|delete|del|remove_item|rmdir|rd)/iu;
676
+ var DESTRUCTIVE_TOOLS = /^(rm|remove|delete|del|remove_item|rmdir|rd|mv|move|rename)/iu;
677
+ var READ_TOOLS = /^(read|read_file|view|open|cat|type|get-content|grep|search|glob|list|find|head|tail|less|more|strings|xxd)/iu;
678
+ var NETWORK_TOOLS = /^(fetch|web_fetch|http|curl|download|web_search|browser|invoke-webrequest|iwr|invoke-restmethod|start-bitstransfer)/iu;
406
679
  var DANGEROUS_SHELL = [
407
- { re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/iu, why: "recursive/forced delete", key: "rm-rf" },
408
- { re: /\bsudo\b/u, why: "privilege escalation", key: "sudo" },
409
- { re: /\bchmod\s+([0-7]{3,4}|\+x)/u, why: "permission change", key: "chmod" },
410
- { re: /\b(curl|wget)\b[^|;&]*\|\s*(ba)?sh\b/iu, why: "pipe remote script to shell", key: "curl-pipe" },
411
- { re: /\bgit\s+push\s+.*--force/iu, why: "force push", key: "force-push" },
412
- { re: /\b(dd|mkfs|fdisk)\b/u, why: "raw disk operation", key: "disk" },
413
- { re: /(\bssh\b|\bscp\b|\brsync\b.*@)/u, why: "remote host access", key: "remote" },
414
- { re: /\b(export|echo)\b.*\b(API_?KEY|TOKEN|SECRET|PASSWORD)\b/iu, why: "credential material in command", key: "creds" }
680
+ // --- destructive deletes -------------------------------------------------
681
+ { re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/iu, why: "recursive/forced delete", key: "rm-rf", severity: "critical" },
682
+ { re: /\bremove-item\b[^\n]*(?:-recurse|-force)/iu, why: "recursive/forced delete", key: "rm-rf", severity: "critical" },
683
+ { re: /\b(?:del|erase)\b[^\n]*\/[sq]/iu, why: "forced delete", key: "rm-rf", severity: "critical" },
684
+ { re: /\b(?:rd|rmdir)\b[^\n]*\/s/iu, why: "recursive directory delete", key: "rm-rf", severity: "critical" },
685
+ // --- privilege / system --------------------------------------------------
686
+ { re: /\bsudo\b/iu, why: "privilege escalation", key: "sudo", severity: "critical" },
687
+ { re: /\b(?:takeown|icacls)\b[^\n]*(?:grant|\/grant|\/f|\/reset)/iu, why: "ownership/permission change", key: "chmod", severity: "serious" },
688
+ { re: /\bchmod\s+([0-7]{3,4}|\+x)/u, why: "permission change", key: "chmod", severity: "serious" },
689
+ { re: /\bset-executionpolicy\b/iu, why: "script execution policy change", key: "exec-policy", severity: "serious" },
690
+ { re: /\b(?:bcdedit|vssadmin\s+delete|wevtutil\s+cl)\b/iu, why: "boot/shadow/log tampering", key: "system-tamper", severity: "critical" },
691
+ { re: /\b(?:reg\s+delete|schtasks\b[^\n]*\/create|sc\s+create|net\s+user|net\s+localgroup)\b/iu, why: "persistence or account change", key: "persistence", severity: "serious" },
692
+ // --- disk / remote code --------------------------------------------------
693
+ { re: /\b(?:dd|mkfs|fdisk|diskpart|format)\b/iu, why: "raw disk operation", key: "disk", severity: "critical" },
694
+ { re: /\b(?:iwr|invoke-webrequest|curl|wget|start-bitstransfer)\b[^\n|;]*(?:\||;)\s*(?:iex|invoke-expression)\b/iu, why: "pipe remote script to shell", key: "curl-pipe", severity: "critical" },
695
+ { re: /\b(?:downloadstring|downloadfile)\b/iu, why: "download and execute", key: "curl-pipe", severity: "critical" },
696
+ { re: /\b(?:powershell|pwsh)\b[^\n]*-e(?:nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{40,}/iu, why: "encoded PowerShell command", key: "encoded-shell", severity: "critical" },
697
+ // --- credentials / exfiltration -----------------------------------------
698
+ { re: /\b(?:export|\$env:|\bset\b|\becho\b)[^\n]*(?:api[_-]?key|token|secret|password|passwd)/iu, why: "credential material in command", key: "creds", severity: "serious" },
699
+ { re: /\b(?:get-content|type|cat)\b[^\n]*(?:\.env\b|id_rsa|\.ssh[\\/]|credentials)/iu, why: "credential file read", key: "creds", severity: "serious" },
700
+ { re: /\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod)\b[^\n]*(?:-d\b|--data|--upload-file|-f\b|\.env\b|secret|token|password)/iu, why: "possible credential exfiltration", key: "exfil", severity: "critical" },
701
+ { re: /\b(?:ssh|scp|rsync)\b[^\n]*(?:@|\bremote\b)/iu, why: "remote host access", key: "remote", severity: "warning" },
702
+ { re: /\b(?:invoke-command|enter-pssession|winrs)\b/iu, why: "remote execution", key: "remote", severity: "serious" },
703
+ // --- supply chain / repo ------------------------------------------------
704
+ { re: /\bgit\s+push\s+[^\n]*--force/iu, why: "force push", key: "force-push", severity: "serious" },
705
+ { re: /\b(?:npm|pnpm|yarn)\s+publish\b/iu, why: "package publish", key: "publish", severity: "warning" },
706
+ { re: /\bgh\s+release\s+create\b/iu, why: "release publish", key: "publish", severity: "warning" }
415
707
  ];
416
- var SENSITIVE_PATH = /(\.env\b|\.ssh\/|id_rsa|\.aws\/|credentials|\.npmrc|\.netrc|secrets?\.|\.pem\b)/iu;
708
+ var SENSITIVE_PATH = /(?:^|[\\/])(?:\.env(?:\.[\w-]+)?(?:[\\/]|$)|\.ssh(?:[\\/]|$)|id_rsa|id_ed25519|\.aws(?:[\\/]|$)|\.kube(?:[\\/]|$)|\.docker(?:[\\/]config\.json|$)|credentials|\.npmrc|\.netrc|secrets?\.|\.pem|\.pfx|\.p12|ntuser\.dat|\.git[\\/]config|\.config[\\/]gh[\\/]hosts\.yml)/iu;
709
+ var NETWORK_RISK = /(?:--data|-d\s|--upload-file|-f\s|body\s*[:=]|\.env\b|secret|token|password|passwd|private[_-]?key|authorization|api[_-]?key)/iu;
710
+ function messageText(message) {
711
+ const content = get2(message, "content");
712
+ if (!Array.isArray(content)) return "";
713
+ return content.map((block) => typeof get2(block, "text") === "string" ? get2(block, "text") : "").join("");
714
+ }
715
+ function findDanger(event) {
716
+ if (event.type !== "tool/call") return void 0;
717
+ const name = toolName(event);
718
+ if (!SHELL_TOOLS.test(name)) return void 0;
719
+ const args = argsOf(event);
720
+ return DANGEROUS_SHELL.find((pattern) => pattern.re.test(args));
721
+ }
417
722
  var DEFAULT_RULES = [
418
723
  {
419
724
  id: "shell-exec",
420
- severity: (event) => {
421
- const args = str(get2(event.data, "arguments"));
422
- return DANGEROUS_SHELL.some((p) => p.re.test(args)) ? "critical" : "warning";
423
- },
725
+ category: "shell",
726
+ severity: (event) => findDanger(event)?.severity ?? "warning",
424
727
  match: (event) => {
425
728
  if (event.type !== "tool/call") return void 0;
426
- const name = str(get2(event.data, "name"));
729
+ const name = toolName(event);
427
730
  if (!SHELL_TOOLS.test(name)) return void 0;
428
- const args = str(get2(event.data, "arguments"));
429
- const hit = DANGEROUS_SHELL.find((p) => p.re.test(args));
731
+ const args = argsOf(event);
732
+ const hit = DANGEROUS_SHELL.find((pattern) => pattern.re.test(args));
733
+ if (hit === void 0) return void 0;
430
734
  return {
431
- title: hit ? `shell: ${hit.why}` : `shell command via ${name}`,
432
- detail: args.slice(0, 400),
433
- messageKey: hit ? "f.shell.dangerous" : "f.shell.plain",
434
- params: hit ? { whyKey: hit.key, why: hit.why } : { name }
735
+ title: `shell: ${hit.why}`,
736
+ detail: clipped(args),
737
+ messageKey: "f.shell.dangerous",
738
+ params: { whyKey: hit.key, why: hit.why, name }
435
739
  };
436
740
  }
437
741
  },
438
742
  {
439
743
  id: "file-write",
440
- severity: (event) => SENSITIVE_PATH.test(str(get2(event.data, "arguments"))) ? "critical" : "info",
744
+ category: "file",
745
+ severity: (event) => {
746
+ const args = argsOf(event);
747
+ const path = pathOf(args) ?? args;
748
+ return SENSITIVE_PATH.test(path) || containsSecret(args) ? "critical" : "warning";
749
+ },
441
750
  match: (event) => {
442
751
  if (event.type !== "tool/call") return void 0;
443
- const name = str(get2(event.data, "name"));
752
+ const name = toolName(event);
444
753
  if (!WRITE_TOOLS.test(name)) return void 0;
445
- const args = str(get2(event.data, "arguments"));
446
- const sensitive = SENSITIVE_PATH.test(args);
754
+ const args = argsOf(event);
755
+ const path = pathOf(args) ?? args;
756
+ const sensitive = SENSITIVE_PATH.test(path);
757
+ const secret = containsSecret(args);
758
+ if (!sensitive && !secret && !DESTRUCTIVE_TOOLS.test(name)) return void 0;
759
+ const reason = sensitive ? "file write touches a sensitive path" : secret ? "credential material written to a file" : `destructive file operation via ${name}`;
447
760
  return {
448
- title: sensitive ? `file write touches sensitive path` : `file mutation via ${name}`,
449
- detail: args.slice(0, 400),
450
- messageKey: sensitive ? "f.file.sensitive" : "f.file.write",
451
- params: { name }
761
+ title: reason,
762
+ detail: clipped(args),
763
+ messageKey: sensitive ? "f.file.sensitive" : secret ? "f.file.secret" : "f.file.destructive",
764
+ params: { name, ...path !== args ? { path } : {} }
452
765
  };
453
766
  }
454
767
  },
455
768
  {
456
769
  id: "sensitive-read",
770
+ category: "file",
457
771
  severity: "serious",
458
772
  match: (event) => {
459
773
  if (event.type !== "tool/call") return void 0;
460
- const args = str(get2(event.data, "arguments"));
461
- const name = str(get2(event.data, "name"));
462
- if (WRITE_TOOLS.test(name) || SHELL_TOOLS.test(name)) return void 0;
463
- if (!SENSITIVE_PATH.test(args)) return void 0;
774
+ const name = toolName(event);
775
+ if (WRITE_TOOLS.test(name) || SHELL_TOOLS.test(name) || NETWORK_TOOLS.test(name)) return void 0;
776
+ if (!READ_TOOLS.test(name)) return void 0;
777
+ const args = argsOf(event);
778
+ const path = pathOf(args) ?? args;
779
+ if (!SENSITIVE_PATH.test(path)) return void 0;
464
780
  return {
465
781
  title: `access to sensitive path via ${name}`,
466
- detail: args.slice(0, 400),
782
+ detail: clipped(args),
467
783
  messageKey: "f.read.sensitive",
468
- params: { name }
784
+ params: { name, ...path !== args ? { path } : {} }
469
785
  };
470
786
  }
471
787
  },
472
788
  {
473
789
  id: "network",
474
- severity: "info",
790
+ category: "network",
791
+ severity: (event) => {
792
+ const args = argsOf(event);
793
+ return containsSecret(args) || SENSITIVE_PATH.test(args) ? "critical" : "warning";
794
+ },
475
795
  match: (event) => {
476
796
  if (event.type !== "tool/call") return void 0;
477
- const name = str(get2(event.data, "name"));
797
+ const name = toolName(event);
478
798
  if (!NETWORK_TOOLS.test(name)) return void 0;
799
+ const args = argsOf(event);
800
+ if (!NETWORK_RISK.test(args)) return void 0;
479
801
  return {
480
- title: `network access via ${name}`,
481
- detail: str(get2(event.data, "arguments")).slice(0, 400),
802
+ title: `network request may carry sensitive data via ${name}`,
803
+ detail: clipped(args),
482
804
  messageKey: "f.network",
483
805
  params: { name }
484
806
  };
485
807
  }
486
808
  },
809
+ {
810
+ id: "secret-output",
811
+ category: "secret",
812
+ severity: "critical",
813
+ match: (event) => {
814
+ if (event.type !== "tool/result") return void 0;
815
+ const message = get2(event.data, "message");
816
+ const text = messageText(message) || str(message) || str(get2(event.data, "error"));
817
+ if (text === "" || !containsSecret(text)) return void 0;
818
+ return {
819
+ title: "tool output contains credential material",
820
+ detail: clipped(text, 300),
821
+ messageKey: "f.output.secret"
822
+ };
823
+ }
824
+ },
487
825
  {
488
826
  id: "approval-denied",
827
+ category: "approval",
489
828
  severity: "warning",
490
829
  match: (event) => {
491
830
  if (event.type !== "approval/decided") return void 0;
492
831
  const outcome = str(get2(event.data, "outcome") ?? get2(event.data, "decision"));
493
- if (!/den|reject|refus/iu.test(outcome)) return void 0;
494
- return { title: "user denied an approval", detail: outcome, messageKey: "f.approval.denied" };
832
+ if (!/deny|denied|reject|refus/iu.test(outcome)) return void 0;
833
+ return { title: "user denied an approval", detail: clipped(outcome), messageKey: "f.approval.denied" };
495
834
  }
496
835
  },
497
836
  {
498
837
  id: "sandbox-change",
499
- severity: "serious",
500
- match: (event) => event.type === "sandbox/mode" ? { title: "sandbox mode changed", detail: JSON.stringify(event.data), messageKey: "f.sandbox.change" } : void 0
838
+ category: "config",
839
+ severity: (event) => {
840
+ const mode = str(get2(event.data, "mode"));
841
+ return /danger|full/iu.test(mode) ? "critical" : "serious";
842
+ },
843
+ match: (event) => event.type === "sandbox/mode" ? {
844
+ title: `sandbox mode changed to ${str(get2(event.data, "mode")) || "unknown"}`,
845
+ detail: clipped(JSON.stringify(event.data)),
846
+ messageKey: "f.sandbox.change",
847
+ params: { mode: str(get2(event.data, "mode")) }
848
+ } : void 0
501
849
  },
502
850
  {
503
851
  id: "permission-change",
504
- severity: "serious",
505
- match: (event) => event.type === "permission/preset" ? { title: "permission preset changed", detail: JSON.stringify(event.data), messageKey: "f.permission.change" } : void 0
506
- },
507
- {
508
- id: "subagent",
509
- severity: "info",
510
- match: (event) => event.type === "subagent/descriptor" ? {
511
- title: `subagent spawned (${str(get2(event.data, "mode"))})`,
512
- detail: str(get2(event.data, "persona") ?? get2(event.data, "label") ?? ""),
513
- messageKey: "f.subagent",
514
- params: { mode: str(get2(event.data, "mode")) }
852
+ category: "config",
853
+ severity: (event) => {
854
+ const preset = str(get2(event.data, "preset"));
855
+ return /danger|full/iu.test(preset) ? "critical" : "serious";
856
+ },
857
+ match: (event) => event.type === "permission/preset" ? {
858
+ title: `permission preset changed to ${str(get2(event.data, "preset")) || "unknown"}`,
859
+ detail: clipped(JSON.stringify(event.data)),
860
+ messageKey: "f.permission.change",
861
+ params: { preset: str(get2(event.data, "preset")) }
515
862
  } : void 0
516
863
  },
517
864
  {
518
865
  id: "tool-error",
866
+ category: "error",
519
867
  severity: "warning",
520
868
  match: (event) => {
521
869
  if (event.type !== "tool/result") return void 0;
@@ -523,32 +871,62 @@ var DEFAULT_RULES = [
523
871
  if (error === void 0) return void 0;
524
872
  return {
525
873
  title: `tool failed: ${str(get2(error, "name"))}`,
526
- detail: str(get2(error, "code")),
874
+ detail: clipped(str(get2(error, "code"))),
527
875
  messageKey: "f.tool.error",
528
- params: { name: str(get2(error, "name")) }
876
+ params: { name: str(get2(error, "name")), code: str(get2(error, "code")) }
529
877
  };
530
878
  }
531
879
  },
532
880
  {
533
881
  id: "turn-error",
882
+ category: "error",
534
883
  severity: "warning",
535
884
  match: (event) => {
536
885
  if (event.type !== "turn/end") return void 0;
537
886
  const kind = str(get2(get2(event.data, "reason"), "kind"));
538
- if (kind === "completed" || kind === "") return void 0;
887
+ if (kind === "" || kind === "completed" || kind === "aborted") return void 0;
539
888
  return { title: `turn ended abnormally: ${kind}`, messageKey: "f.turn.error", params: { kind } };
540
889
  }
541
890
  }
542
891
  ];
543
892
  var SEVERITY_ORDER = ["critical", "serious", "warning", "info"];
893
+ var CONFIG_RULES = /* @__PURE__ */ new Set(["sandbox-change", "permission-change"]);
544
894
  function auditSession(session, rules = DEFAULT_RULES) {
895
+ const lastConfig = /* @__PURE__ */ new Map();
896
+ const changedConfig = /* @__PURE__ */ new Set();
897
+ let suppressed = 0;
898
+ for (const event of session.events) {
899
+ if (event.type !== "permission/preset" && event.type !== "sandbox/mode" && event.type !== "approval/policy") {
900
+ continue;
901
+ }
902
+ const value = JSON.stringify(event.data);
903
+ if (!lastConfig.has(event.type)) {
904
+ lastConfig.set(event.type, value);
905
+ suppressed++;
906
+ continue;
907
+ }
908
+ if (lastConfig.get(event.type) === value) {
909
+ suppressed++;
910
+ continue;
911
+ }
912
+ lastConfig.set(event.type, value);
913
+ changedConfig.add(event.seq);
914
+ }
545
915
  const findings = [];
546
916
  for (const event of session.events) {
547
917
  for (const rule of rules) {
918
+ if (CONFIG_RULES.has(rule.id) && !changedConfig.has(event.seq)) continue;
548
919
  const hit = rule.match(event);
549
920
  if (hit === void 0) continue;
550
921
  const severity = typeof rule.severity === "function" ? rule.severity(event) : rule.severity;
551
- findings.push({ ruleId: rule.id, severity, seq: event.seq, time: event.time, ...hit });
922
+ findings.push({
923
+ ruleId: rule.id,
924
+ severity,
925
+ category: hit.category ?? rule.category,
926
+ seq: event.seq,
927
+ time: event.time,
928
+ ...hit
929
+ });
552
930
  }
553
931
  }
554
932
  findings.sort(
@@ -556,7 +934,7 @@ function auditSession(session, rules = DEFAULT_RULES) {
556
934
  );
557
935
  const counts = { critical: 0, serious: 0, warning: 0, info: 0 };
558
936
  for (const finding of findings) counts[finding.severity]++;
559
- return { findings, counts };
937
+ return { findings, counts, scanned: session.events.length, rules: rules.length, suppressed };
560
938
  }
561
939
 
562
940
  // src/core/forktree.ts
@@ -654,54 +1032,139 @@ function stats(session, timeline, title) {
654
1032
  function eventFingerprint(event) {
655
1033
  return `${event.type}\0${JSON.stringify(event.data)}`;
656
1034
  }
657
- function compareSessions(a, b, titles) {
1035
+ function relatedBy(a, b, parentOf) {
1036
+ if (a.header.id === b.header.id) return "same";
1037
+ if (b.header.parentSession === a.header.id) return "a-parent-of-b";
1038
+ if (a.header.parentSession === b.header.id) return "b-parent-of-a";
1039
+ const ancestorsOfB = /* @__PURE__ */ new Set();
1040
+ let cursor = b.header.parentSession;
1041
+ while (cursor !== void 0 && !ancestorsOfB.has(cursor)) {
1042
+ ancestorsOfB.add(cursor);
1043
+ cursor = parentOf(cursor);
1044
+ }
1045
+ if (ancestorsOfB.has(a.header.id)) return "a-parent-of-b";
1046
+ const ancestorsOfA = /* @__PURE__ */ new Set();
1047
+ cursor = a.header.parentSession;
1048
+ while (cursor !== void 0 && !ancestorsOfA.has(cursor)) {
1049
+ ancestorsOfA.add(cursor);
1050
+ cursor = parentOf(cursor);
1051
+ }
1052
+ if (ancestorsOfA.has(b.header.id)) return "b-parent-of-a";
1053
+ if (a.header.parentSession !== void 0 && a.header.parentSession === b.header.parentSession) {
1054
+ return "siblings";
1055
+ }
1056
+ return "none";
1057
+ }
1058
+ function compareSessions(a, b, titles, parentOf = () => void 0) {
658
1059
  const timelineA = buildTimeline(a);
659
1060
  const timelineB = buildTimeline(b);
660
1061
  const statsA = stats(a, timelineA, titles?.a);
661
1062
  const statsB = stats(b, timelineB, titles?.b);
662
1063
  const toolNames = [.../* @__PURE__ */ new Set([...Object.keys(statsA.toolMix), ...Object.keys(statsB.toolMix)])].sort();
663
- const related = a.header.parentSession === b.header.id || b.header.parentSession === a.header.id || a.header.parentSession !== void 0 && a.header.parentSession === b.header.parentSession;
1064
+ const relation = relatedBy(a, b, parentOf);
1065
+ const related = relation !== "none";
664
1066
  let divergenceSeq;
1067
+ let divergenceA;
1068
+ let divergenceB;
665
1069
  const shared = Math.min(a.events.length, b.events.length);
666
1070
  for (let i = 0; i < shared; i++) {
667
1071
  if (a.events[i].type === "session/end-seed" || b.events[i].type === "session/end-seed") {
668
1072
  if (a.events[i].type !== b.events[i].type) {
669
1073
  divergenceSeq = Math.min(a.events[i].seq, b.events[i].seq);
1074
+ divergenceA = a.events[i];
1075
+ divergenceB = b.events[i];
670
1076
  break;
671
1077
  }
672
1078
  continue;
673
1079
  }
674
1080
  if (eventFingerprint(a.events[i]) !== eventFingerprint(b.events[i])) {
675
1081
  divergenceSeq = a.events[i].seq;
1082
+ divergenceA = a.events[i];
1083
+ divergenceB = b.events[i];
676
1084
  break;
677
1085
  }
678
1086
  }
679
1087
  if (divergenceSeq === void 0 && a.events.length !== b.events.length) {
680
1088
  divergenceSeq = shared > 0 ? (a.events[shared - 1]?.seq ?? 0) + 1 : 0;
1089
+ divergenceA = a.events[shared];
1090
+ divergenceB = b.events[shared];
681
1091
  }
682
- return { a: statsA, b: statsB, toolNames, divergenceSeq, related };
1092
+ return { a: statsA, b: statsB, toolNames, divergenceSeq, divergenceA, divergenceB, related, relation };
683
1093
  }
684
1094
 
685
1095
  // src/core/cost.ts
686
1096
  var DEFAULT_PRICING = [
687
- { match: /deepseek.*pro/iu, rate: { input: 1.1, cacheRead: 0.27, output: 4.4 }, label: "DeepSeek Pro" },
688
- { match: /deepseek/iu, rate: { input: 0.27, cacheRead: 0.07, output: 1.1 }, label: "DeepSeek" },
689
- { match: /claude|anthropic/iu, rate: { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 }, label: "Claude" },
690
- { match: /gpt|^o\d|openai|codex/iu, rate: { input: 2.5, cacheRead: 1.25, output: 10 }, label: "OpenAI" },
691
- { match: /gemini/iu, rate: { input: 1.25, cacheRead: 0.31, output: 10 }, label: "Gemini" }
1097
+ {
1098
+ match: /deepseek.*pro/iu,
1099
+ rate: { input: 1.1, cacheRead: 0.27, output: 4.4 },
1100
+ label: "DeepSeek Pro",
1101
+ updatedAt: "2026-08-18",
1102
+ source: "provider list price (estimate)"
1103
+ },
1104
+ {
1105
+ match: /deepseek/iu,
1106
+ rate: { input: 0.27, cacheRead: 0.07, output: 1.1 },
1107
+ label: "DeepSeek",
1108
+ updatedAt: "2026-08-18",
1109
+ source: "provider list price (estimate)"
1110
+ },
1111
+ {
1112
+ match: /claude|anthropic/iu,
1113
+ rate: { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 },
1114
+ label: "Claude",
1115
+ updatedAt: "2026-08-18",
1116
+ source: "provider list price (estimate)"
1117
+ },
1118
+ {
1119
+ match: /gpt|^o\d|openai|codex/iu,
1120
+ rate: { input: 2.5, cacheRead: 1.25, output: 10 },
1121
+ label: "OpenAI",
1122
+ updatedAt: "2026-08-18",
1123
+ source: "provider list price (estimate)"
1124
+ },
1125
+ {
1126
+ match: /gemini/iu,
1127
+ rate: { input: 1.25, cacheRead: 0.31, output: 10 },
1128
+ label: "Gemini",
1129
+ updatedAt: "2026-08-18",
1130
+ source: "provider list price (estimate)"
1131
+ }
692
1132
  ];
1133
+ function costOf(totals, rate) {
1134
+ return (totals.input * rate.input + totals.cacheRead * rate.cacheRead + totals.cacheWrite * (rate.cacheWrite ?? rate.input) + totals.output * rate.output) / 1e6;
1135
+ }
693
1136
  function estimateCost(totals, models, pricing = DEFAULT_PRICING) {
694
1137
  const entry = pricing.find((p) => models.some((m) => p.match.test(m)));
695
1138
  if (entry === void 0) return void 0;
696
- const { rate } = entry;
697
- const usd = (totals.input * rate.input + totals.cacheRead * rate.cacheRead + totals.cacheWrite * (rate.cacheWrite ?? rate.input) + totals.output * rate.output) / 1e6;
698
- return { usd, label: entry.label };
1139
+ return { usd: costOf(totals, entry.rate), label: entry.label };
1140
+ }
1141
+ function estimateCostDetailed(modelTotals, pricing = DEFAULT_PRICING) {
1142
+ if (modelTotals === void 0 || modelTotals === null) return void 0;
1143
+ const parts = [];
1144
+ for (const [model, totals] of Object.entries(modelTotals)) {
1145
+ const entry = pricing.find((p) => p.match.test(model));
1146
+ if (entry === void 0) continue;
1147
+ parts.push({ model, label: entry.label, usd: costOf(totals, entry.rate), totals });
1148
+ }
1149
+ if (parts.length === 0) return void 0;
1150
+ const usd = parts.reduce((sum, part) => sum + part.usd, 0);
1151
+ const labels = [...new Set(parts.map((part) => part.label))];
1152
+ return { usd, label: labels.length === 1 ? labels[0] : `Mixed (${labels.length} models)`, parts };
699
1153
  }
700
1154
  function formatUsd(usd) {
701
1155
  if (usd >= 1) return `$${usd.toFixed(2)}`;
702
1156
  if (usd >= 0.01) return `$${usd.toFixed(3)}`;
703
1157
  return `$${usd.toFixed(4)}`;
704
1158
  }
1159
+ function formatCny(usd, rate = 7.2) {
1160
+ const cny = usd * rate;
1161
+ if (cny >= 1) return `\xA5${cny.toFixed(2)}`;
1162
+ if (cny >= 0.01) return `\xA5${cny.toFixed(3)}`;
1163
+ return `\xA5${cny.toFixed(4)}`;
1164
+ }
1165
+ function formatMoney(usd, currency, rate = 7.2) {
1166
+ return currency === "CNY" ? formatCny(usd, rate) : formatUsd(usd);
1167
+ }
705
1168
  export {
706
1169
  DEFAULT_PRICING,
707
1170
  DEFAULT_RULES,
@@ -709,12 +1172,20 @@ export {
709
1172
  buildForkForest,
710
1173
  buildTimeline,
711
1174
  compareSessions,
1175
+ containsSecret,
712
1176
  decodeStorageRecord,
713
1177
  estimateCost,
1178
+ estimateCostDetailed,
714
1179
  expandChunkRow,
1180
+ formatCny,
1181
+ formatMoney,
715
1182
  formatUsd,
716
1183
  fromInspection,
717
1184
  isChunkRow,
718
1185
  lineageOf,
719
- parseSessionJsonl
1186
+ parseSessionJsonl,
1187
+ redactSession,
1188
+ redactText,
1189
+ redactValue,
1190
+ summarizeToolArguments
720
1191
  };