@townco/debugger 0.1.27 → 0.1.29

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.
@@ -0,0 +1,155 @@
1
+ import type { SessionAnalysis } from "../analysis/types";
2
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
3
+
4
+ interface Props {
5
+ open: boolean;
6
+ onClose: () => void;
7
+ analysis: SessionAnalysis;
8
+ }
9
+
10
+ function formatDate(isoString: string): string {
11
+ return new Date(isoString).toLocaleString();
12
+ }
13
+
14
+ function calculateDuration(start: string, end: string): string {
15
+ const startTime = new Date(start).getTime();
16
+ const endTime = new Date(end).getTime();
17
+ const durationMs = endTime - startTime;
18
+
19
+ const seconds = Math.floor(durationMs / 1000);
20
+ const minutes = Math.floor(seconds / 60);
21
+ const hours = Math.floor(minutes / 60);
22
+
23
+ if (hours > 0) {
24
+ return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
25
+ }
26
+ if (minutes > 0) {
27
+ return `${minutes}m ${seconds % 60}s`;
28
+ }
29
+ return `${seconds}s`;
30
+ }
31
+
32
+ function Section({
33
+ title,
34
+ children,
35
+ }: {
36
+ title: string;
37
+ children: React.ReactNode;
38
+ }) {
39
+ return (
40
+ <div className="space-y-3">
41
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
42
+ <div className="space-y-2">{children}</div>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ function Field({ label, value }: { label: string; value: string }) {
48
+ return (
49
+ <div className="space-y-1">
50
+ <div className="text-xs font-medium text-muted-foreground">{label}</div>
51
+ <div className="text-sm text-foreground whitespace-pre-wrap break-words">
52
+ {value}
53
+ </div>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function Badge({ label, value }: { label: string; value: string }) {
59
+ return (
60
+ <div className="space-y-1">
61
+ <div className="text-xs font-medium text-muted-foreground">{label}</div>
62
+ <div className="inline-block px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
63
+ {value}
64
+ </div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function Metric({ label, value }: { label: string; value: number }) {
70
+ return (
71
+ <div className="space-y-1">
72
+ <div className="text-xs font-medium text-muted-foreground">{label}</div>
73
+ <div className="text-2xl font-bold text-foreground">{value}</div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ export function SessionAnalysisDialog({ open, onClose, analysis }: Props) {
79
+ return (
80
+ <Dialog open={open} onOpenChange={onClose}>
81
+ <DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
82
+ <DialogHeader>
83
+ <DialogTitle>Session Analysis</DialogTitle>
84
+ </DialogHeader>
85
+
86
+ <div className="space-y-6">
87
+ {/* Task Section */}
88
+ <Section title="Task">
89
+ <Field label="User Query" value={analysis.task.user_query} />
90
+ <Field label="Summary" value={analysis.task.task_summary} />
91
+ <Badge label="Intent" value={analysis.task.intent_type} />
92
+ </Section>
93
+
94
+ {/* Trajectory Section */}
95
+ <Section title="Trajectory">
96
+ <Field
97
+ label="High Level Plan"
98
+ value={analysis.trajectory.high_level_plan}
99
+ />
100
+ <div className="grid grid-cols-2 gap-4 pt-2">
101
+ <Metric label="Steps" value={analysis.trajectory.num_steps} />
102
+ <Metric
103
+ label="Tool Calls"
104
+ value={analysis.trajectory.num_tool_calls}
105
+ />
106
+ </div>
107
+ {analysis.trajectory.tools_used.length > 0 && (
108
+ <div className="space-y-2 pt-2">
109
+ <div className="text-xs font-medium text-muted-foreground">
110
+ Tools Used
111
+ </div>
112
+ <div className="flex flex-wrap gap-2">
113
+ {analysis.trajectory.tools_used.map((tool) => (
114
+ <span
115
+ key={tool}
116
+ className="px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs font-medium"
117
+ >
118
+ {tool}
119
+ </span>
120
+ ))}
121
+ </div>
122
+ </div>
123
+ )}
124
+ </Section>
125
+
126
+ {/* Outcome Section */}
127
+ <Section title="Outcome">
128
+ <div className="flex gap-4">
129
+ <Badge label="Status" value={analysis.outcome.status} />
130
+ <Badge label="Answer Type" value={analysis.outcome.answer_type} />
131
+ </div>
132
+ <Field label="Assessment" value={analysis.outcome.assessment} />
133
+ </Section>
134
+
135
+ {/* Metadata Section */}
136
+ <Section title="Metadata">
137
+ <div className="grid grid-cols-2 gap-4">
138
+ <Field label="Started" value={formatDate(analysis.started_at)} />
139
+ <Field label="Ended" value={formatDate(analysis.ended_at)} />
140
+ </div>
141
+ <Field
142
+ label="Duration"
143
+ value={calculateDuration(analysis.started_at, analysis.ended_at)}
144
+ />
145
+ <Field label="Agent" value={analysis.agent_name} />
146
+ <Field
147
+ label="Session ID"
148
+ value={analysis.session_id.slice(0, 16)}
149
+ />
150
+ </Section>
151
+ </div>
152
+ </DialogContent>
153
+ </Dialog>
154
+ );
155
+ }
@@ -231,7 +231,7 @@ export function UnifiedTimeline({
231
231
  {/* Timeline container */}
232
232
  <div
233
233
  ref={timelineRef}
234
- className="border border-border rounded-lg overflow-hidden bg-card flex"
234
+ className="border border-border rounded-lg overflow-hidden bg-card flex max-h-[calc(100vh-250px)]"
235
235
  onWheel={handleWheel}
236
236
  >
237
237
  {/* Fixed left column - processes labels */}
@@ -245,7 +245,7 @@ export function UnifiedTimeline({
245
245
  </div>
246
246
 
247
247
  {/* Span labels */}
248
- <div className="flex-1 bg-white dark:bg-gray-900">
248
+ <div className="flex-1 bg-white dark:bg-gray-900 overflow-y-auto">
249
249
  {flatSpans.map(({ node, depth }) => {
250
250
  const span = spans.find((s) => s.span_id === node.span_id);
251
251
  // This should never be null now due to filtering
@@ -310,7 +310,7 @@ export function UnifiedTimeline({
310
310
 
311
311
  {/* Scrollable right side - message bubbles + timeline */}
312
312
  <div
313
- className="flex-1 overflow-x-auto overflow-y-hidden"
313
+ className="flex-1 overflow-x-auto overflow-y-auto"
314
314
  style={{ background: "#404040" }}
315
315
  >
316
316
  <div
@@ -0,0 +1,120 @@
1
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
2
+ import { X } from "lucide-react";
3
+ import * as React from "react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Dialog = DialogPrimitive.Root;
8
+
9
+ const DialogTrigger = DialogPrimitive.Trigger;
10
+
11
+ const DialogPortal = DialogPrimitive.Portal;
12
+
13
+ const DialogClose = DialogPrimitive.Close;
14
+
15
+ const DialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <DialogPrimitive.Overlay
20
+ ref={ref}
21
+ className={cn(
22
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
+ className,
24
+ )}
25
+ {...props}
26
+ />
27
+ ));
28
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29
+
30
+ const DialogContent = React.forwardRef<
31
+ React.ElementRef<typeof DialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
33
+ >(({ className, children, ...props }, ref) => (
34
+ <DialogPortal>
35
+ <DialogOverlay />
36
+ <DialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className,
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
46
+ <X className="size-4" />
47
+ <span className="sr-only">Close</span>
48
+ </DialogPrimitive.Close>
49
+ </DialogPrimitive.Content>
50
+ </DialogPortal>
51
+ ));
52
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
53
+
54
+ const DialogHeader = ({
55
+ className,
56
+ ...props
57
+ }: React.HTMLAttributes<HTMLDivElement>) => (
58
+ <div
59
+ className={cn(
60
+ "flex flex-col space-y-1.5 text-center sm:text-left",
61
+ className,
62
+ )}
63
+ {...props}
64
+ />
65
+ );
66
+ DialogHeader.displayName = "DialogHeader";
67
+
68
+ const DialogFooter = ({
69
+ className,
70
+ ...props
71
+ }: React.HTMLAttributes<HTMLDivElement>) => (
72
+ <div
73
+ className={cn(
74
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
75
+ className,
76
+ )}
77
+ {...props}
78
+ />
79
+ );
80
+ DialogFooter.displayName = "DialogFooter";
81
+
82
+ const DialogTitle = React.forwardRef<
83
+ React.ElementRef<typeof DialogPrimitive.Title>,
84
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
85
+ >(({ className, ...props }, ref) => (
86
+ <DialogPrimitive.Title
87
+ ref={ref}
88
+ className={cn(
89
+ "text-lg font-semibold leading-none tracking-tight",
90
+ className,
91
+ )}
92
+ {...props}
93
+ />
94
+ ));
95
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
96
+
97
+ const DialogDescription = React.forwardRef<
98
+ React.ElementRef<typeof DialogPrimitive.Description>,
99
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
100
+ >(({ className, ...props }, ref) => (
101
+ <DialogPrimitive.Description
102
+ ref={ref}
103
+ className={cn("text-sm text-muted-foreground", className)}
104
+ {...props}
105
+ />
106
+ ));
107
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
108
+
109
+ export {
110
+ Dialog,
111
+ DialogPortal,
112
+ DialogOverlay,
113
+ DialogClose,
114
+ DialogTrigger,
115
+ DialogContent,
116
+ DialogHeader,
117
+ DialogFooter,
118
+ DialogTitle,
119
+ DialogDescription,
120
+ };
package/src/db.ts CHANGED
@@ -89,15 +89,16 @@ export class DebuggerDb {
89
89
  getSpansBySessionAttribute(sessionId: string): Span[] {
90
90
  // Use JSON extract to find spans where agent.session_id matches
91
91
  return this.db
92
- .query<Span, [string]>(
92
+ .query<Span, [string, string]>(
93
93
  `
94
94
  SELECT *
95
95
  FROM spans
96
96
  WHERE json_extract(attributes, '$."agent.session_id"') = ?
97
+ OR json_extract(attributes, '$."session.id"') = ?
97
98
  ORDER BY start_time_unix_nano ASC
98
99
  `,
99
100
  )
100
- .all(sessionId);
101
+ .all(sessionId, sessionId);
101
102
  }
102
103
 
103
104
  /**
@@ -1,4 +1,4 @@
1
- import type { SessionMetrics, Span, Trace } from "../types";
1
+ import type { SessionMetrics, Span, ToolCall, Trace } from "../types";
2
2
  import { getModelPricing } from "./pricing";
3
3
 
4
4
  /**
@@ -28,7 +28,7 @@ export function extractMetricsFromSpans(
28
28
  ): Omit<SessionMetrics, "durationMs"> {
29
29
  let inputTokens = 0;
30
30
  let outputTokens = 0;
31
- let toolCallCount = 0;
31
+ const toolCalls: ToolCall[] = [];
32
32
 
33
33
  for (const span of spans) {
34
34
  const attrs = span.attributes ? JSON.parse(span.attributes) : {};
@@ -41,14 +41,109 @@ export function extractMetricsFromSpans(
41
41
  outputTokens += attrs["gen_ai.usage.output_tokens"];
42
42
  }
43
43
 
44
- // Count tool calls
45
- if (span.name.includes("tool_call") || span.name.startsWith("tool:")) {
46
- toolCallCount++;
44
+ // Count tool calls from dedicated tool spans
45
+ const isToolSpan =
46
+ span.name.includes("tool_call") || span.name.startsWith("tool:");
47
+ if (isToolSpan) {
48
+ const name =
49
+ attrs["tool.name"] ||
50
+ attrs["tool.type"] ||
51
+ attrs["gen_ai.tool_call.name"] ||
52
+ span.name;
53
+
54
+ const rawInput =
55
+ attrs["tool.input"] || attrs["gen_ai.tool_call.input"] || null;
56
+ const rawOutput = attrs["tool.output"] || null;
57
+
58
+ const parseMaybeJson = (val: unknown) => {
59
+ if (typeof val !== "string") return val;
60
+ try {
61
+ return JSON.parse(val);
62
+ } catch {
63
+ return val;
64
+ }
65
+ };
66
+
67
+ toolCalls.push({
68
+ name: typeof name === "string" ? name : String(name),
69
+ input: parseMaybeJson(rawInput),
70
+ output: parseMaybeJson(rawOutput),
71
+ startTimeUnixNano: span.start_time_unix_nano,
72
+ endTimeUnixNano: span.end_time_unix_nano,
73
+ });
74
+ }
75
+
76
+ // Also inspect model outputs for declared tool calls (e.g. Anthropic tool_use)
77
+ const messagesRaw =
78
+ attrs["gen_ai.output.messages"] ?? attrs["gen_ai.output.tool_calls"];
79
+ if (messagesRaw) {
80
+ try {
81
+ const messages =
82
+ typeof messagesRaw === "string"
83
+ ? JSON.parse(messagesRaw)
84
+ : messagesRaw;
85
+ if (Array.isArray(messages)) {
86
+ for (const msg of messages) {
87
+ if (msg && typeof msg === "object") {
88
+ const toolCallsArray =
89
+ (msg as Record<string, unknown>).tool_calls ?? [];
90
+ if (Array.isArray(toolCallsArray)) {
91
+ for (const call of toolCallsArray) {
92
+ if (call && typeof call === "object") {
93
+ toolCalls.push({
94
+ name:
95
+ (call as Record<string, unknown>).name?.toString() ||
96
+ "tool_call",
97
+ input: (call as Record<string, unknown>).args,
98
+ output: null,
99
+ startTimeUnixNano: span.start_time_unix_nano,
100
+ endTimeUnixNano: span.end_time_unix_nano,
101
+ });
102
+ }
103
+ }
104
+ }
105
+
106
+ const content = (msg as Record<string, unknown>).content;
107
+ if (Array.isArray(content)) {
108
+ for (const c of content) {
109
+ if (
110
+ c &&
111
+ typeof c === "object" &&
112
+ (c as Record<string, unknown>).type === "tool_use"
113
+ ) {
114
+ toolCalls.push({
115
+ name:
116
+ ((c as Record<string, unknown>).name as
117
+ | string
118
+ | undefined) || "tool_use",
119
+ input: (c as Record<string, unknown>).input,
120
+ output: null,
121
+ startTimeUnixNano: span.start_time_unix_nano,
122
+ endTimeUnixNano: span.end_time_unix_nano,
123
+ });
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ } catch {
131
+ // ignore parse errors
132
+ }
47
133
  }
48
134
  }
49
135
 
50
136
  const totalTokens = inputTokens + outputTokens;
51
137
  const estimatedCost = calculateCost(model, inputTokens, outputTokens);
138
+ // Dedupe tool calls using name + start time to avoid double counting when captured in multiple places
139
+ const deduped = new Map<string, ToolCall>();
140
+ for (const call of toolCalls) {
141
+ const key = `${call.name}-${call.startTimeUnixNano ?? ""}`;
142
+ if (!deduped.has(key)) {
143
+ deduped.set(key, call);
144
+ }
145
+ }
146
+ const toolCallCount = deduped.size;
52
147
 
53
148
  return {
54
149
  inputTokens,
@@ -56,6 +151,7 @@ export function extractMetricsFromSpans(
56
151
  totalTokens,
57
152
  estimatedCost,
58
153
  toolCallCount,
154
+ toolCalls: Array.from(deduped.values()),
59
155
  };
60
156
  }
61
157