@townco/debugger 0.1.23 → 0.1.25

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.
@@ -1,24 +1,13 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { Button } from "@/components/ui/button";
3
- import {
4
- Card,
5
- CardContent,
6
- CardDescription,
7
- CardHeader,
8
- CardTitle,
9
- } from "@/components/ui/card";
10
3
  import { DebuggerLayout } from "../components/DebuggerLayout";
11
4
  import type { Session } from "../types";
12
5
 
13
6
  function formatDuration(startNano: number, endNano: number): string {
14
7
  const ms = (endNano - startNano) / 1_000_000;
15
- if (ms < 1000) return `${ms.toFixed(2)}ms`;
16
- if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
17
- return `${(ms / 60000).toFixed(2)}m`;
18
- }
19
-
20
- function formatTimestamp(nanoseconds: number): string {
21
- return new Date(nanoseconds / 1_000_000).toLocaleString();
8
+ if (ms < 1000) return `${ms.toFixed(0)}ms`;
9
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
10
+ return `${(ms / 60000).toFixed(1)}m`;
22
11
  }
23
12
 
24
13
  function formatRelativeTime(nanoseconds: number): string {
@@ -35,21 +24,59 @@ function formatRelativeTime(nanoseconds: number): string {
35
24
  return "just now";
36
25
  }
37
26
 
27
+ interface SessionWithMessage extends Session {
28
+ firstMessage?: string;
29
+ }
30
+
38
31
  export function SessionList() {
39
- const [sessions, setSessions] = useState<Session[]>([]);
32
+ const [sessions, setSessions] = useState<SessionWithMessage[]>([]);
33
+ const [comparisonSessionIds, setComparisonSessionIds] = useState<Set<string>>(
34
+ new Set(),
35
+ );
40
36
  const [loading, setLoading] = useState(true);
41
37
  const [error, setError] = useState<string | null>(null);
42
38
 
43
- // Fetch sessions function
39
+ // Fetch sessions, comparison session IDs, and first messages
44
40
  const fetchSessions = useCallback(() => {
45
41
  setLoading(true);
46
- fetch("/api/sessions")
47
- .then((res) => {
42
+ Promise.all([
43
+ fetch("/api/sessions").then((res) => {
48
44
  if (!res.ok) throw new Error("Failed to fetch sessions");
49
45
  return res.json();
50
- })
51
- .then((data) => {
52
- setSessions(data);
46
+ }),
47
+ fetch("/api/comparison-session-ids").then((res) => {
48
+ if (!res.ok) return { sessionIds: [] };
49
+ return res.json();
50
+ }),
51
+ ])
52
+ .then(async ([sessionsData, comparisonData]) => {
53
+ const comparisonIds = new Set<string>(comparisonData.sessionIds || []);
54
+ setComparisonSessionIds(comparisonIds);
55
+
56
+ // Filter sessions first, then fetch first messages only for filtered sessions
57
+ const filtered = (sessionsData as Session[]).filter(
58
+ (s) => !comparisonIds.has(s.session_id),
59
+ );
60
+
61
+ // Fetch first messages for all filtered sessions in parallel
62
+ const sessionsWithMessages = await Promise.all(
63
+ filtered.map(async (session) => {
64
+ try {
65
+ const res = await fetch(
66
+ `/api/session-first-message/${session.session_id}`,
67
+ );
68
+ if (res.ok) {
69
+ const data = await res.json();
70
+ return { ...session, firstMessage: data.message };
71
+ }
72
+ } catch {
73
+ // Ignore errors fetching first message
74
+ }
75
+ return session;
76
+ }),
77
+ );
78
+
79
+ setSessions(sessionsWithMessages);
53
80
  setLoading(false);
54
81
  })
55
82
  .catch((err) => {
@@ -63,6 +90,11 @@ export function SessionList() {
63
90
  fetchSessions();
64
91
  }, [fetchSessions]);
65
92
 
93
+ // Filter out comparison sessions (already filtered during fetch, but keep for safety)
94
+ const filteredSessions = sessions.filter(
95
+ (session) => !comparisonSessionIds.has(session.session_id),
96
+ );
97
+
66
98
  if (loading) {
67
99
  return (
68
100
  <DebuggerLayout title="Sessions" showNav>
@@ -85,53 +117,42 @@ export function SessionList() {
85
117
 
86
118
  return (
87
119
  <DebuggerLayout title="Sessions" showNav>
88
- <div className="container mx-auto p-8">
89
- <div className="flex gap-2 mb-4">
120
+ <div className="h-[calc(100vh-4rem)] flex flex-col p-8">
121
+ <div className="flex gap-2 mb-4 shrink-0">
90
122
  <Button variant="outline" onClick={fetchSessions} disabled={loading}>
91
123
  Refresh
92
124
  </Button>
93
125
  </div>
94
126
 
95
- {sessions.length === 0 ? (
127
+ {filteredSessions.length === 0 ? (
96
128
  <div className="text-muted-foreground">No sessions found</div>
97
129
  ) : (
98
- <div className="space-y-2">
99
- {sessions.map((session) => (
130
+ <div className="space-y-1 overflow-y-auto flex-1">
131
+ {filteredSessions.map((session) => (
100
132
  <a
101
133
  key={session.session_id}
102
134
  href={`/sessions/${session.session_id}`}
103
135
  className="block"
104
136
  >
105
- <Card className="hover:bg-muted/50 transition-colors cursor-pointer">
106
- <CardHeader className="py-3">
107
- <div className="flex items-center justify-between">
108
- <CardTitle className="text-base font-medium font-mono">
109
- {session.session_id}
110
- </CardTitle>
111
- <span className="text-sm text-muted-foreground">
112
- {formatRelativeTime(session.last_trace_time)}
113
- </span>
114
- </div>
115
- <CardDescription className="flex items-center gap-4">
116
- <span>{session.trace_count} traces</span>
117
- <span>
118
- Duration:{" "}
119
- {formatDuration(
120
- session.first_trace_time,
121
- session.last_trace_time,
122
- )}
123
- </span>
124
- </CardDescription>
125
- </CardHeader>
126
- <CardContent className="py-2 pt-0">
127
- <div className="text-xs text-muted-foreground">
128
- Started: {formatTimestamp(session.first_trace_time)}
129
- </div>
130
- <div className="text-xs text-muted-foreground">
131
- Last activity: {formatTimestamp(session.last_trace_time)}
132
- </div>
133
- </CardContent>
134
- </Card>
137
+ <div className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/50 transition-colors cursor-pointer border">
138
+ <code className="text-xs text-muted-foreground shrink-0">
139
+ {session.session_id.slice(0, 8)}
140
+ </code>
141
+ <span className="text-sm truncate flex-1">
142
+ {session.firstMessage || "No message"}
143
+ </span>
144
+ <span className="text-xs text-muted-foreground shrink-0">
145
+ {session.trace_count}{" "}
146
+ {session.trace_count === 1 ? "trace" : "traces"} ·{" "}
147
+ {formatDuration(
148
+ session.first_trace_time,
149
+ session.last_trace_time,
150
+ )}
151
+ </span>
152
+ <span className="text-xs text-muted-foreground shrink-0">
153
+ {formatRelativeTime(session.last_trace_time)}
154
+ </span>
155
+ </div>
135
156
  </a>
136
157
  ))}
137
158
  </div>
@@ -1,76 +1,15 @@
1
- import { useCallback, useState } from "react";
2
1
  import { DebuggerLayout } from "../components/DebuggerLayout";
3
- import { SessionTraceList } from "../components/SessionTraceList";
4
- import { TraceDetailContent } from "../components/TraceDetailContent";
5
-
6
- function getInitialTraceId(): string | null {
7
- const params = new URLSearchParams(window.location.search);
8
- return params.get("traceId");
9
- }
2
+ import { SessionTimelineView } from "../components/SessionTimelineView";
10
3
 
11
4
  interface SessionViewProps {
12
5
  sessionId: string;
13
6
  }
14
7
 
15
8
  export function SessionView({ sessionId }: SessionViewProps) {
16
- const [selectedTraceId, setSelectedTraceId] = useState<string | null>(
17
- getInitialTraceId,
18
- );
19
- const [autoSelectDone, setAutoSelectDone] = useState(
20
- () => getInitialTraceId() !== null,
21
- );
22
-
23
- const handleSelectTrace = (traceId: string) => {
24
- setSelectedTraceId(traceId);
25
-
26
- // Update URL without navigation
27
- const url = new URL(window.location.href);
28
- url.searchParams.set("traceId", traceId);
29
- window.history.replaceState({}, "", url);
30
- };
31
-
32
- const handleTracesLoaded = useCallback(
33
- (traces: { trace_id: string }[]) => {
34
- // Auto-select most recent trace if none selected yet
35
- // Most recent is now last in array due to ASC ordering
36
- const mostRecent = traces[traces.length - 1];
37
- if (!autoSelectDone && mostRecent) {
38
- setSelectedTraceId(mostRecent.trace_id);
39
- setAutoSelectDone(true);
40
-
41
- // Update URL
42
- const url = new URL(window.location.href);
43
- url.searchParams.set("traceId", mostRecent.trace_id);
44
- window.history.replaceState({}, "", url);
45
- }
46
- },
47
- [autoSelectDone],
48
- );
49
-
50
9
  return (
51
10
  <DebuggerLayout title={`Session: ${sessionId}`} showBackButton backHref="/">
52
- {/* Split pane container */}
53
- <div className="flex flex-1 overflow-hidden h-full">
54
- {/* Left pane - trace list */}
55
- <div className="w-[480px] border-r overflow-y-auto shrink-0">
56
- <SessionTraceList
57
- sessionId={sessionId}
58
- selectedTraceId={selectedTraceId}
59
- onSelectTrace={handleSelectTrace}
60
- onTracesLoaded={handleTracesLoaded}
61
- />
62
- </div>
63
-
64
- {/* Right pane - trace detail */}
65
- <div className="flex-1 overflow-y-auto">
66
- {selectedTraceId ? (
67
- <TraceDetailContent traceId={selectedTraceId} compact />
68
- ) : (
69
- <div className="p-8 text-muted-foreground">
70
- Select a trace to view details
71
- </div>
72
- )}
73
- </div>
11
+ <div className="flex-1 overflow-y-auto">
12
+ <SessionTimelineView sessionId={sessionId} />
74
13
  </div>
75
14
  </DebuggerLayout>
76
15
  );
@@ -0,0 +1,406 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@/components/ui/card";
10
+ import { Label } from "@/components/ui/label";
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "@/components/ui/select";
18
+ import { Textarea } from "@/components/ui/textarea";
19
+ import { DebuggerLayout } from "../components/DebuggerLayout";
20
+ import type {
21
+ AgentConfig,
22
+ ComparisonConfig,
23
+ ComparisonDimension,
24
+ Session,
25
+ } from "../types";
26
+
27
+ function formatDuration(startNano: number, endNano: number): string {
28
+ const ms = (endNano - startNano) / 1_000_000;
29
+ if (ms < 1000) return `${ms.toFixed(2)}ms`;
30
+ if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
31
+ return `${(ms / 60000).toFixed(2)}m`;
32
+ }
33
+
34
+ function formatRelativeTime(nanoseconds: number): string {
35
+ const ms = Date.now() - nanoseconds / 1_000_000;
36
+ const seconds = Math.floor(ms / 1000);
37
+ const minutes = Math.floor(seconds / 60);
38
+ const hours = Math.floor(minutes / 60);
39
+ const days = Math.floor(hours / 24);
40
+
41
+ if (days > 0) return `${days}d ago`;
42
+ if (hours > 0) return `${hours}h ago`;
43
+ if (minutes > 0) return `${minutes}m ago`;
44
+ if (seconds > 5) return `${seconds}s ago`;
45
+ return "just now";
46
+ }
47
+
48
+ export function TownHall() {
49
+ const [sessions, setSessions] = useState<Session[]>([]);
50
+ const [loading, setLoading] = useState(true);
51
+ const [error, setError] = useState<string | null>(null);
52
+
53
+ // Comparison config state
54
+ const [dimension, setDimension] = useState<ComparisonDimension | null>(null);
55
+ const [availableModels, setAvailableModels] = useState<string[]>([]);
56
+ const [agentConfig, setAgentConfig] = useState<AgentConfig | null>(null);
57
+
58
+ // Variant values
59
+ const [variantModel, setVariantModel] = useState<string | null>(null);
60
+ const [variantSystemPrompt, setVariantSystemPrompt] = useState<string>("");
61
+ const [variantTools, setVariantTools] = useState<string[]>([]);
62
+
63
+ // Running comparison state
64
+ const [runningComparison, setRunningComparison] = useState<string | null>(
65
+ null,
66
+ );
67
+
68
+ // Fetch sessions
69
+ const fetchSessions = useCallback(() => {
70
+ setLoading(true);
71
+ fetch("/api/sessions")
72
+ .then((res) => {
73
+ if (!res.ok) throw new Error("Failed to fetch sessions");
74
+ return res.json();
75
+ })
76
+ .then((data) => {
77
+ setSessions(data);
78
+ setLoading(false);
79
+ })
80
+ .catch((err) => {
81
+ setError(err.message);
82
+ setLoading(false);
83
+ });
84
+ }, []);
85
+
86
+ // Fetch available models and agent config
87
+ useEffect(() => {
88
+ fetch("/api/available-models")
89
+ .then((res) => res.json())
90
+ .then((data) => setAvailableModels(data.models || []))
91
+ .catch(console.error);
92
+
93
+ fetch("/api/agent-config")
94
+ .then((res) => res.json())
95
+ .then((data) => {
96
+ setAgentConfig(data);
97
+ // Initialize variant system prompt with current
98
+ if (data.systemPrompt) {
99
+ setVariantSystemPrompt(data.systemPrompt);
100
+ }
101
+ // Initialize variant tools with current
102
+ if (data.tools) {
103
+ setVariantTools(data.tools.map((t: { name: string }) => t.name));
104
+ }
105
+ })
106
+ .catch(console.error);
107
+ }, []);
108
+
109
+ // Fetch sessions on mount
110
+ useEffect(() => {
111
+ fetchSessions();
112
+ }, [fetchSessions]);
113
+
114
+ // Check if comparison is ready
115
+ const isComparisonReady = () => {
116
+ if (!dimension) return false;
117
+
118
+ switch (dimension) {
119
+ case "model":
120
+ return !!variantModel && variantModel !== agentConfig?.model;
121
+ case "system_prompt":
122
+ return (
123
+ variantSystemPrompt !== "" &&
124
+ variantSystemPrompt !== agentConfig?.systemPrompt
125
+ );
126
+ case "tools":
127
+ const currentToolNames =
128
+ agentConfig?.tools?.map((t) => t.name).sort() || [];
129
+ const variantToolNames = [...variantTools].sort();
130
+ return (
131
+ JSON.stringify(currentToolNames) !== JSON.stringify(variantToolNames)
132
+ );
133
+ default:
134
+ return false;
135
+ }
136
+ };
137
+
138
+ // Start comparison
139
+ const startComparison = async (sessionId: string) => {
140
+ if (!dimension || !isComparisonReady()) return;
141
+
142
+ setRunningComparison(sessionId);
143
+
144
+ try {
145
+ // Create comparison config
146
+ const config: Partial<ComparisonConfig> = {
147
+ dimension,
148
+ ...(dimension === "model" && { controlModel: agentConfig?.model }),
149
+ ...(dimension === "model" && variantModel && { variantModel }),
150
+ ...(dimension === "system_prompt" &&
151
+ variantSystemPrompt && {
152
+ variantSystemPrompt,
153
+ }),
154
+ ...(dimension === "tools" &&
155
+ variantTools.length > 0 && { variantTools }),
156
+ };
157
+
158
+ // Save config
159
+ const configRes = await fetch("/api/comparison-config", {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify(config),
163
+ });
164
+ const { id: configId } = await configRes.json();
165
+
166
+ // Start comparison run
167
+ const runRes = await fetch("/api/run-comparison", {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ sessionId, configId }),
171
+ });
172
+ const { runId } = await runRes.json();
173
+
174
+ // Navigate to comparison view
175
+ window.location.href = `/town-hall/compare/${runId}`;
176
+ } catch (err) {
177
+ console.error("Failed to start comparison:", err);
178
+ setRunningComparison(null);
179
+ }
180
+ };
181
+
182
+ if (loading) {
183
+ return (
184
+ <DebuggerLayout title="Town Hall" showNav>
185
+ <div className="container mx-auto p-8">
186
+ <div className="text-muted-foreground">Loading sessions...</div>
187
+ </div>
188
+ </DebuggerLayout>
189
+ );
190
+ }
191
+
192
+ if (error) {
193
+ return (
194
+ <DebuggerLayout title="Town Hall" showNav>
195
+ <div className="container mx-auto p-8">
196
+ <div className="text-red-500">Error: {error}</div>
197
+ </div>
198
+ </DebuggerLayout>
199
+ );
200
+ }
201
+
202
+ return (
203
+ <DebuggerLayout title="Town Hall" showNav>
204
+ <div className="container mx-auto p-8">
205
+ {/* Comparison Config Bar */}
206
+ <Card className="mb-6">
207
+ <CardHeader className="py-4">
208
+ <CardTitle className="text-lg">Comparison Configuration</CardTitle>
209
+ <CardDescription>
210
+ Select what dimension to compare and configure the variant
211
+ </CardDescription>
212
+ </CardHeader>
213
+ <CardContent className="space-y-4">
214
+ {/* Dimension selector */}
215
+ <div className="space-y-2">
216
+ <Label>Compare by</Label>
217
+ <Select
218
+ value={dimension || ""}
219
+ onValueChange={(v) =>
220
+ setDimension(v as ComparisonDimension | null)
221
+ }
222
+ >
223
+ <SelectTrigger className="w-[200px]">
224
+ <SelectValue placeholder="Select dimension" />
225
+ </SelectTrigger>
226
+ <SelectContent>
227
+ <SelectItem value="model">Model</SelectItem>
228
+ <SelectItem value="system_prompt">System Prompt</SelectItem>
229
+ <SelectItem value="tools">Tools</SelectItem>
230
+ </SelectContent>
231
+ </Select>
232
+ </div>
233
+
234
+ {/* Variant configuration */}
235
+ {dimension === "model" && (
236
+ <div className="space-y-2">
237
+ <Label>
238
+ Variant Model{" "}
239
+ <span className="text-muted-foreground text-xs">
240
+ (Control: {agentConfig?.model || "unknown"})
241
+ </span>
242
+ </Label>
243
+ <Select
244
+ value={variantModel || ""}
245
+ onValueChange={setVariantModel}
246
+ >
247
+ <SelectTrigger className="w-[300px]">
248
+ <SelectValue placeholder="Select variant model" />
249
+ </SelectTrigger>
250
+ <SelectContent>
251
+ {availableModels.map((model) => (
252
+ <SelectItem
253
+ key={model}
254
+ value={model}
255
+ disabled={model === agentConfig?.model}
256
+ >
257
+ {model}
258
+ {model === agentConfig?.model && " (current)"}
259
+ </SelectItem>
260
+ ))}
261
+ </SelectContent>
262
+ </Select>
263
+ </div>
264
+ )}
265
+
266
+ {dimension === "system_prompt" && (
267
+ <div className="space-y-2">
268
+ <Label>
269
+ Variant System Prompt{" "}
270
+ <span className="text-muted-foreground text-xs">
271
+ (Edit to create variant)
272
+ </span>
273
+ </Label>
274
+ <Textarea
275
+ value={variantSystemPrompt}
276
+ onChange={(e) => setVariantSystemPrompt(e.target.value)}
277
+ className="min-h-[150px] font-mono text-sm"
278
+ placeholder="Enter variant system prompt..."
279
+ />
280
+ </div>
281
+ )}
282
+
283
+ {dimension === "tools" && agentConfig?.tools && (
284
+ <div className="space-y-2">
285
+ <Label>
286
+ Variant Tools{" "}
287
+ <span className="text-muted-foreground text-xs">
288
+ (Select tools to enable)
289
+ </span>
290
+ </Label>
291
+ <div className="flex flex-wrap gap-2">
292
+ {agentConfig.tools.map((tool) => (
293
+ <Button
294
+ key={tool.name}
295
+ variant={
296
+ variantTools.includes(tool.name) ? "default" : "outline"
297
+ }
298
+ size="sm"
299
+ onClick={() => {
300
+ if (variantTools.includes(tool.name)) {
301
+ setVariantTools(
302
+ variantTools.filter((t) => t !== tool.name),
303
+ );
304
+ } else {
305
+ setVariantTools([...variantTools, tool.name]);
306
+ }
307
+ }}
308
+ >
309
+ {tool.name}
310
+ </Button>
311
+ ))}
312
+ </div>
313
+ </div>
314
+ )}
315
+
316
+ {/* Status indicator */}
317
+ {dimension && (
318
+ <div className="text-sm">
319
+ {isComparisonReady() ? (
320
+ <span className="text-green-600 dark:text-green-400">
321
+ Ready to compare. Click "Compare" on a session below.
322
+ </span>
323
+ ) : (
324
+ <span className="text-yellow-600 dark:text-yellow-400">
325
+ Configure a different variant to enable comparison.
326
+ </span>
327
+ )}
328
+ </div>
329
+ )}
330
+ </CardContent>
331
+ </Card>
332
+
333
+ {/* Session list */}
334
+ <div className="flex gap-2 mb-4">
335
+ <Button variant="outline" onClick={fetchSessions} disabled={loading}>
336
+ Refresh
337
+ </Button>
338
+ </div>
339
+
340
+ {sessions.length === 0 ? (
341
+ <div className="text-muted-foreground">No sessions found</div>
342
+ ) : (
343
+ <div className="space-y-2">
344
+ {sessions.map((session) => (
345
+ <Card
346
+ key={session.session_id}
347
+ className="hover:bg-muted/50 transition-colors"
348
+ >
349
+ <CardHeader className="py-3">
350
+ <div className="flex items-center justify-between">
351
+ <div className="flex-1">
352
+ <CardTitle className="text-base font-medium font-mono">
353
+ {session.session_id}
354
+ </CardTitle>
355
+ <CardDescription className="flex items-center gap-4 mt-1">
356
+ <span>{session.trace_count} traces</span>
357
+ <span>
358
+ Duration:{" "}
359
+ {formatDuration(
360
+ session.first_trace_time,
361
+ session.last_trace_time,
362
+ )}
363
+ </span>
364
+ <span>
365
+ {formatRelativeTime(session.last_trace_time)}
366
+ </span>
367
+ </CardDescription>
368
+ </div>
369
+ <div className="flex items-center gap-2">
370
+ <Button
371
+ variant="outline"
372
+ size="sm"
373
+ onClick={() =>
374
+ (window.location.href = `/sessions/${session.session_id}`)
375
+ }
376
+ >
377
+ View
378
+ </Button>
379
+ <Button
380
+ size="sm"
381
+ disabled={
382
+ !isComparisonReady() ||
383
+ runningComparison === session.session_id
384
+ }
385
+ onClick={() => startComparison(session.session_id)}
386
+ title={
387
+ !isComparisonReady()
388
+ ? "Configure comparison first"
389
+ : "Start comparison"
390
+ }
391
+ >
392
+ {runningComparison === session.session_id
393
+ ? "Starting..."
394
+ : "Compare"}
395
+ </Button>
396
+ </div>
397
+ </div>
398
+ </CardHeader>
399
+ </Card>
400
+ ))}
401
+ </div>
402
+ )}
403
+ </div>
404
+ </DebuggerLayout>
405
+ );
406
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+
3
+ export const VariantToolsSchema = z.array(z.string());
4
+
5
+ export const SessionMetricsSchema = z.object({
6
+ durationMs: z.number(),
7
+ inputTokens: z.number(),
8
+ outputTokens: z.number(),
9
+ totalTokens: z.number(),
10
+ estimatedCost: z.number(),
11
+ toolCallCount: z.number(),
12
+ });
13
+
14
+ export type VariantTools = z.infer<typeof VariantToolsSchema>;
15
+ export type SessionMetrics = z.infer<typeof SessionMetricsSchema>;