htmlgraph 0.26.21__py3-none-any.whl → 0.26.23__py3-none-any.whl

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,727 @@
1
+ """HtmlGraph CLI - Session report commands.
2
+
3
+ Commands for generating "What Did Claude Do?" reports:
4
+ - report: Show chronological timeline of tool calls in a session
5
+
6
+ THE killer feature that differentiates HtmlGraph - complete observability
7
+ of AI agent activities with cost attribution and tool usage analysis.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sqlite3
14
+ from datetime import datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from rich.console import Console
19
+
20
+ from htmlgraph.cli.base import BaseCommand, CommandError, CommandResult
21
+ from htmlgraph.cli.constants import DEFAULT_GRAPH_DIR
22
+
23
+ if TYPE_CHECKING:
24
+ from argparse import _SubParsersAction
25
+
26
+ console = Console()
27
+
28
+
29
+ def register_report_commands(subparsers: _SubParsersAction) -> None:
30
+ """Register report commands."""
31
+ report_parser = subparsers.add_parser(
32
+ "report", help="Generate 'What Did Claude Do?' session report"
33
+ )
34
+ report_parser.add_argument(
35
+ "--session",
36
+ help="Session ID (or 'latest', 'today')",
37
+ default="latest",
38
+ )
39
+ report_parser.add_argument(
40
+ "--report-format",
41
+ choices=["terminal", "html", "markdown"],
42
+ default="terminal",
43
+ help="Report output format (terminal=rich formatting, html=self-contained HTML, markdown=markdown file)",
44
+ )
45
+ report_parser.add_argument(
46
+ "--detail",
47
+ choices=["basic", "full"],
48
+ default="basic",
49
+ help="Detail level (basic=summary, full=inputs/outputs)",
50
+ )
51
+ report_parser.add_argument(
52
+ "--output", "-o", help="Output file path (for html/markdown formats)"
53
+ )
54
+ report_parser.add_argument(
55
+ "--graph-dir", "-g", default=DEFAULT_GRAPH_DIR, help="Graph directory"
56
+ )
57
+ report_parser.set_defaults(func=SessionReportCommand.from_args)
58
+
59
+
60
+ class SessionReportCommand(BaseCommand):
61
+ """Generate 'What Did Claude Do?' session report."""
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ session: str,
67
+ report_format: str,
68
+ detail: str,
69
+ output: str | None,
70
+ ) -> None:
71
+ super().__init__()
72
+ self.session = session
73
+ self.report_format = report_format
74
+ self.detail = detail
75
+ self.output = output
76
+
77
+ @classmethod
78
+ def from_args(cls, args: argparse.Namespace) -> SessionReportCommand:
79
+ return cls(
80
+ session=getattr(args, "session", "latest"),
81
+ report_format=getattr(args, "report_format", "terminal"),
82
+ detail=getattr(args, "detail", "basic"),
83
+ output=getattr(args, "output", None),
84
+ )
85
+
86
+ def execute(self) -> CommandResult:
87
+ """Generate and display session report."""
88
+ if not self.graph_dir:
89
+ raise CommandError("Graph directory not specified")
90
+
91
+ graph_dir = Path(self.graph_dir)
92
+ db_path = graph_dir / "htmlgraph.db"
93
+
94
+ if not db_path.exists():
95
+ console.print(
96
+ f"[yellow]No database found at {db_path}[/yellow]\n"
97
+ "Run some work to generate reports!"
98
+ )
99
+ raise CommandError("No database found", exit_code=1)
100
+
101
+ # Resolve session ID
102
+ conn = sqlite3.connect(str(db_path))
103
+ conn.row_factory = sqlite3.Row
104
+ try:
105
+ session_id = self._resolve_session_id(conn, self.session)
106
+ if not session_id:
107
+ console.print(f"[red]Session not found: {self.session}[/red]")
108
+ raise CommandError("Session not found", exit_code=1)
109
+
110
+ # Get session data
111
+ session_data = self._get_session_data(conn, session_id)
112
+ events = self._get_session_events(conn, session_id)
113
+
114
+ if not events:
115
+ console.print(
116
+ f"[yellow]No events found for session {session_id}[/yellow]"
117
+ )
118
+ # Still return success, just no events to report
119
+ return CommandResult(text="")
120
+
121
+ # Generate report in requested format
122
+ if self.report_format == "terminal":
123
+ self._render_terminal_report(session_data, events)
124
+ elif self.report_format == "html":
125
+ self._render_html_report(session_data, events)
126
+ elif self.report_format == "markdown":
127
+ self._render_markdown_report(session_data, events)
128
+
129
+ # Return empty result to prevent default formatter output
130
+ # (report commands handle their own output)
131
+ return CommandResult(text="")
132
+
133
+ finally:
134
+ conn.close()
135
+
136
+ def _resolve_session_id(self, conn: sqlite3.Connection, session: str) -> str | None:
137
+ """Resolve session identifier to actual session_id."""
138
+ cursor = conn.cursor()
139
+
140
+ if session == "latest":
141
+ # Get most recent session
142
+ cursor.execute(
143
+ """
144
+ SELECT session_id FROM sessions
145
+ ORDER BY created_at DESC
146
+ LIMIT 1
147
+ """
148
+ )
149
+ row = cursor.fetchone()
150
+ return row[0] if row else None
151
+
152
+ elif session == "today":
153
+ # Get all sessions from today and combine them
154
+ # For now, just return the most recent today
155
+ today_start = (
156
+ datetime.now()
157
+ .replace(hour=0, minute=0, second=0, microsecond=0)
158
+ .isoformat()
159
+ )
160
+ cursor.execute(
161
+ """
162
+ SELECT session_id FROM sessions
163
+ WHERE created_at >= ?
164
+ ORDER BY created_at DESC
165
+ LIMIT 1
166
+ """,
167
+ (today_start,),
168
+ )
169
+ row = cursor.fetchone()
170
+ return row[0] if row else None
171
+
172
+ else:
173
+ # Assume it's a session ID (or partial match)
174
+ cursor.execute(
175
+ """
176
+ SELECT session_id FROM sessions
177
+ WHERE session_id LIKE ?
178
+ LIMIT 1
179
+ """,
180
+ (f"%{session}%",),
181
+ )
182
+ row = cursor.fetchone()
183
+ return row[0] if row else None
184
+
185
+ def _get_session_data(
186
+ self, conn: sqlite3.Connection, session_id: str
187
+ ) -> dict[str, Any]:
188
+ """Get session metadata."""
189
+ cursor = conn.cursor()
190
+ cursor.execute(
191
+ """
192
+ SELECT
193
+ session_id,
194
+ agent_assigned,
195
+ created_at,
196
+ completed_at,
197
+ total_events,
198
+ total_tokens_used,
199
+ status
200
+ FROM sessions
201
+ WHERE session_id = ?
202
+ """,
203
+ (session_id,),
204
+ )
205
+
206
+ row = cursor.fetchone()
207
+ if not row:
208
+ return {}
209
+
210
+ data = dict(row)
211
+
212
+ # Calculate duration (handle both timezone-aware and naive datetimes)
213
+ if data.get("created_at") and data.get("completed_at"):
214
+ start = datetime.fromisoformat(data["created_at"])
215
+ end = datetime.fromisoformat(data["completed_at"])
216
+ # Remove timezone info if present to avoid comparison issues
217
+ if start.tzinfo is not None:
218
+ start = start.replace(tzinfo=None)
219
+ if end.tzinfo is not None:
220
+ end = end.replace(tzinfo=None)
221
+ duration = end - start
222
+ elif data.get("created_at"):
223
+ start = datetime.fromisoformat(data["created_at"])
224
+ # Remove timezone info if present
225
+ if start.tzinfo is not None:
226
+ start = start.replace(tzinfo=None)
227
+ duration = datetime.now() - start
228
+ else:
229
+ duration = timedelta(0)
230
+
231
+ data["duration"] = duration
232
+ return data
233
+
234
+ def _get_session_events(
235
+ self, conn: sqlite3.Connection, session_id: str
236
+ ) -> list[dict[str, Any]]:
237
+ """Get all tool_call events for a session in chronological order."""
238
+ cursor = conn.cursor()
239
+ cursor.execute(
240
+ """
241
+ SELECT
242
+ event_id,
243
+ tool_name,
244
+ timestamp,
245
+ input_summary,
246
+ output_summary,
247
+ status,
248
+ parent_event_id,
249
+ cost_tokens,
250
+ execution_duration_seconds,
251
+ subagent_type
252
+ FROM agent_events
253
+ WHERE session_id = ? AND event_type = 'tool_call'
254
+ ORDER BY timestamp ASC
255
+ """,
256
+ (session_id,),
257
+ )
258
+
259
+ events = []
260
+ for row in cursor.fetchall():
261
+ event = dict(row)
262
+ # Parse timestamp
263
+ if event.get("timestamp"):
264
+ if isinstance(event["timestamp"], str):
265
+ event["timestamp"] = datetime.fromisoformat(event["timestamp"])
266
+ events.append(event)
267
+
268
+ return events
269
+
270
+ def _render_terminal_report(
271
+ self, session_data: dict[str, Any], events: list[dict[str, Any]]
272
+ ) -> None:
273
+ """Render report to terminal using Rich."""
274
+ # Header
275
+ session_id = session_data.get("session_id", "unknown")
276
+ agent = session_data.get("agent_assigned", "unknown")
277
+ duration = session_data.get("duration", timedelta(0))
278
+ total_tokens = session_data.get("total_tokens_used", 0)
279
+
280
+ # Calculate estimated cost (rough approximation)
281
+ # Average: $4.50 per 1M tokens
282
+ est_cost = (total_tokens / 1_000_000) * 4.5 if total_tokens else 0
283
+
284
+ # Format duration
285
+ duration_mins = int(duration.total_seconds() / 60)
286
+ duration_str = f"{duration_mins} minutes" if duration_mins > 0 else "< 1 minute"
287
+
288
+ console.print(f"\n[bold cyan]Session Report: {session_id[:16]}...[/bold cyan]")
289
+ console.print(
290
+ f"[dim]Agent: {agent} | Duration: {duration_str} | "
291
+ f"Tokens: {total_tokens:,} | Est. Cost: ${est_cost:.2f}[/dim]\n"
292
+ )
293
+
294
+ # Timeline
295
+ console.print("[bold]TIMELINE:[/bold]")
296
+ console.print("─" * 80)
297
+
298
+ prev_timestamp = None
299
+ for i, event in enumerate(events, 1):
300
+ timestamp = event.get("timestamp")
301
+ tool_name = event.get("tool_name", "unknown")
302
+ status = event.get("status", "")
303
+ subagent = event.get("subagent_type")
304
+
305
+ # Format timestamp
306
+ time_str = timestamp.strftime("%H:%M:%S") if timestamp else "??:??:??"
307
+
308
+ # Format status indicator
309
+ if status == "completed" or not status:
310
+ status_icon = "✓"
311
+ status_color = "green"
312
+ elif status == "failed":
313
+ status_icon = "✗"
314
+ status_color = "red"
315
+ else:
316
+ status_icon = "○"
317
+ status_color = "yellow"
318
+
319
+ # Calculate time since last event (thinking time)
320
+ think_time = ""
321
+ if prev_timestamp and timestamp:
322
+ delta = (timestamp - prev_timestamp).total_seconds()
323
+ if delta > 5: # Only show if > 5 seconds
324
+ think_time = f" [dim](+{int(delta)}s)[/dim]"
325
+
326
+ prev_timestamp = timestamp
327
+
328
+ # Format tool name with subagent context
329
+ tool_display = tool_name
330
+ if subagent:
331
+ tool_display = f"{tool_name} [dim]({subagent})[/dim]"
332
+
333
+ # Get input/output summaries (sanitized)
334
+ input_summary = self._sanitize_summary(event.get("input_summary", ""))
335
+ output_summary = self._sanitize_summary(event.get("output_summary", ""))
336
+
337
+ # Basic display
338
+ console.print(
339
+ f"{time_str} [{status_color}]{status_icon}[/{status_color}] "
340
+ f"[cyan]{tool_display}[/cyan]{think_time}"
341
+ )
342
+
343
+ # Full detail mode
344
+ if self.detail == "full":
345
+ if input_summary:
346
+ console.print(f" [dim]→ {input_summary[:80]}[/dim]")
347
+ if output_summary:
348
+ console.print(f" [dim]← {output_summary[:80]}[/dim]")
349
+
350
+ console.print("─" * 80)
351
+
352
+ # Summary statistics
353
+ self._render_summary_stats(session_data, events)
354
+
355
+ def _render_summary_stats(
356
+ self, session_data: dict[str, Any], events: list[dict[str, Any]]
357
+ ) -> None:
358
+ """Render summary statistics."""
359
+ console.print("\n[bold]SUMMARY:[/bold]")
360
+
361
+ # Tool usage counts
362
+ tool_counts: dict[str, int] = {}
363
+ total_cost = 0
364
+ for event in events:
365
+ tool = event.get("tool_name", "unknown")
366
+ tool_counts[tool] = tool_counts.get(tool, 0) + 1
367
+ total_cost += event.get("cost_tokens", 0)
368
+
369
+ # Sort by count
370
+ sorted_tools = sorted(tool_counts.items(), key=lambda x: x[1], reverse=True)
371
+
372
+ console.print(
373
+ f"Tools used: {', '.join(f'{tool}({count})' for tool, count in sorted_tools[:5])}"
374
+ )
375
+
376
+ # Files touched (extract from input summaries)
377
+ files_touched = self._extract_files_from_events(events)
378
+ if files_touched:
379
+ console.print(f"Files touched: {', '.join(list(files_touched)[:5])}")
380
+ if len(files_touched) > 5:
381
+ console.print(f" [dim](+{len(files_touched) - 5} more)[/dim]")
382
+
383
+ # Tests run (look for Bash events with pytest/test)
384
+ test_events = [
385
+ e
386
+ for e in events
387
+ if e.get("tool_name") == "Bash"
388
+ and e.get("input_summary")
389
+ and ("pytest" in e["input_summary"] or "test" in e["input_summary"])
390
+ ]
391
+ if test_events:
392
+ console.print(f"Tests run: {len(test_events)}")
393
+
394
+ # Cost breakdown
395
+ if total_cost > 0:
396
+ console.print(f"Total cost: {total_cost:,} tokens")
397
+
398
+ console.print()
399
+
400
+ def _extract_files_from_events(self, events: list[dict[str, Any]]) -> set[str]:
401
+ """Extract file paths from event summaries."""
402
+ files = set()
403
+ for event in events:
404
+ tool = event.get("tool_name", "")
405
+ input_summary = event.get("input_summary", "")
406
+
407
+ # Read/Write/Edit events typically have file paths
408
+ if tool in ["Read", "Write", "Edit"] and input_summary:
409
+ # Extract file path (simple heuristic)
410
+ parts = input_summary.split()
411
+ for part in parts:
412
+ if "/" in part and len(part) > 3:
413
+ # Extract filename only
414
+ files.add(part.split("/")[-1])
415
+ if len(files) >= 10: # Limit collection
416
+ break
417
+
418
+ return files
419
+
420
+ def _sanitize_summary(self, summary: str | None) -> str:
421
+ """Sanitize summary to remove secrets."""
422
+ if not summary:
423
+ return ""
424
+
425
+ # Simple sanitization: remove potential secrets
426
+ # (passwords, tokens, keys)
427
+ sensitive_patterns = [
428
+ "password",
429
+ "token",
430
+ "secret",
431
+ "key",
432
+ "api_key",
433
+ "auth",
434
+ ]
435
+
436
+ for pattern in sensitive_patterns:
437
+ if pattern.lower() in summary.lower():
438
+ return "[REDACTED - contains sensitive data]"
439
+
440
+ return summary
441
+
442
+ def _render_html_report(
443
+ self, session_data: dict[str, Any], events: list[dict[str, Any]]
444
+ ) -> None:
445
+ """Render report to HTML file."""
446
+ html_content = self._generate_html(session_data, events)
447
+
448
+ # Determine output path
449
+ if self.output:
450
+ output_path = Path(self.output)
451
+ else:
452
+ output_path = Path(self.graph_dir or ".") / "session-report.html"
453
+
454
+ output_path.write_text(html_content)
455
+ console.print(f"[green]✓ HTML report saved to: {output_path}[/green]")
456
+
457
+ def _generate_html(
458
+ self, session_data: dict[str, Any], events: list[dict[str, Any]]
459
+ ) -> str:
460
+ """Generate self-contained HTML report."""
461
+ session_id = session_data.get("session_id", "unknown")
462
+ agent = session_data.get("agent_assigned", "unknown")
463
+ duration = session_data.get("duration", timedelta(0))
464
+ total_tokens = session_data.get("total_tokens_used", 0)
465
+ est_cost = (total_tokens / 1_000_000) * 4.5 if total_tokens else 0
466
+
467
+ duration_mins = int(duration.total_seconds() / 60)
468
+
469
+ # Build timeline HTML
470
+ timeline_html = ""
471
+ prev_timestamp = None
472
+
473
+ for event in events:
474
+ timestamp = event.get("timestamp")
475
+ tool_name = event.get("tool_name", "unknown")
476
+ status = event.get("status", "")
477
+ input_summary = self._sanitize_summary(event.get("input_summary", ""))
478
+ output_summary = self._sanitize_summary(event.get("output_summary", ""))
479
+
480
+ time_str = timestamp.strftime("%H:%M:%S") if timestamp else "??:??:??"
481
+
482
+ # Status indicator
483
+ if status == "completed" or not status:
484
+ status_class = "success"
485
+ status_icon = "✓"
486
+ elif status == "failed":
487
+ status_class = "error"
488
+ status_icon = "✗"
489
+ else:
490
+ status_class = "pending"
491
+ status_icon = "○"
492
+
493
+ # Calculate thinking time
494
+ think_time = ""
495
+ if prev_timestamp and timestamp:
496
+ delta = (timestamp - prev_timestamp).total_seconds()
497
+ if delta > 5:
498
+ think_time = f'<span class="think-time">(+{int(delta)}s)</span>'
499
+
500
+ prev_timestamp = timestamp
501
+
502
+ # Build event row
503
+ timeline_html += f"""
504
+ <div class="event">
505
+ <span class="time">{time_str}</span>
506
+ <span class="status {status_class}">{status_icon}</span>
507
+ <span class="tool">{tool_name}</span>
508
+ {think_time}
509
+ """
510
+
511
+ if self.detail == "full":
512
+ if input_summary:
513
+ timeline_html += (
514
+ f'<div class="detail input">→ {input_summary[:100]}</div>'
515
+ )
516
+ if output_summary:
517
+ timeline_html += (
518
+ f'<div class="detail output">← {output_summary[:100]}</div>'
519
+ )
520
+
521
+ timeline_html += "</div>\n"
522
+
523
+ # Complete HTML document
524
+ html = f"""<!DOCTYPE html>
525
+ <html>
526
+ <head>
527
+ <meta charset="UTF-8">
528
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
529
+ <title>Session Report: {session_id[:16]}</title>
530
+ <style>
531
+ body {{
532
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
533
+ max-width: 1200px;
534
+ margin: 0 auto;
535
+ padding: 20px;
536
+ background: #f5f5f5;
537
+ }}
538
+ .header {{
539
+ background: white;
540
+ padding: 20px;
541
+ border-radius: 8px;
542
+ margin-bottom: 20px;
543
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
544
+ }}
545
+ .header h1 {{
546
+ margin: 0 0 10px 0;
547
+ color: #333;
548
+ }}
549
+ .header .meta {{
550
+ color: #666;
551
+ font-size: 14px;
552
+ }}
553
+ .timeline {{
554
+ background: white;
555
+ padding: 20px;
556
+ border-radius: 8px;
557
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
558
+ }}
559
+ .event {{
560
+ padding: 10px;
561
+ border-bottom: 1px solid #eee;
562
+ font-family: 'Courier New', monospace;
563
+ font-size: 14px;
564
+ }}
565
+ .event:last-child {{
566
+ border-bottom: none;
567
+ }}
568
+ .time {{
569
+ color: #666;
570
+ margin-right: 10px;
571
+ }}
572
+ .status {{
573
+ margin-right: 10px;
574
+ font-weight: bold;
575
+ }}
576
+ .status.success {{
577
+ color: #28a745;
578
+ }}
579
+ .status.error {{
580
+ color: #dc3545;
581
+ }}
582
+ .status.pending {{
583
+ color: #ffc107;
584
+ }}
585
+ .tool {{
586
+ color: #007bff;
587
+ font-weight: bold;
588
+ }}
589
+ .think-time {{
590
+ color: #999;
591
+ margin-left: 10px;
592
+ }}
593
+ .detail {{
594
+ margin-left: 120px;
595
+ color: #666;
596
+ font-size: 12px;
597
+ margin-top: 5px;
598
+ }}
599
+ .detail.input {{
600
+ color: #666;
601
+ }}
602
+ .detail.output {{
603
+ color: #28a745;
604
+ }}
605
+ .summary {{
606
+ background: white;
607
+ padding: 20px;
608
+ border-radius: 8px;
609
+ margin-top: 20px;
610
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
611
+ }}
612
+ .summary h2 {{
613
+ margin-top: 0;
614
+ }}
615
+ </style>
616
+ </head>
617
+ <body>
618
+ <div class="header">
619
+ <h1>Session Report: {session_id[:16]}...</h1>
620
+ <div class="meta">
621
+ Agent: {agent} | Duration: {duration_mins} minutes |
622
+ Tokens: {total_tokens:,} | Est. Cost: ${est_cost:.2f}
623
+ </div>
624
+ </div>
625
+
626
+ <div class="timeline">
627
+ <h2>Timeline</h2>
628
+ {timeline_html}
629
+ </div>
630
+
631
+ <div class="summary">
632
+ <h2>Summary</h2>
633
+ <p>Total events: {len(events)}</p>
634
+ <p>Generated by HtmlGraph - "HTML is All You Need"</p>
635
+ </div>
636
+ </body>
637
+ </html>"""
638
+
639
+ return html
640
+
641
+ def _render_markdown_report(
642
+ self, session_data: dict[str, Any], events: list[dict[str, Any]]
643
+ ) -> None:
644
+ """Render report to Markdown file."""
645
+ markdown_content = self._generate_markdown(session_data, events)
646
+
647
+ # Determine output path
648
+ if self.output:
649
+ output_path = Path(self.output)
650
+ else:
651
+ output_path = Path(self.graph_dir or ".") / "session-report.md"
652
+
653
+ output_path.write_text(markdown_content)
654
+ console.print(f"[green]✓ Markdown report saved to: {output_path}[/green]")
655
+
656
+ def _generate_markdown(
657
+ self, session_data: dict[str, Any], events: list[dict[str, Any]]
658
+ ) -> str:
659
+ """Generate Markdown report."""
660
+ session_id = session_data.get("session_id", "unknown")
661
+ agent = session_data.get("agent_assigned", "unknown")
662
+ duration = session_data.get("duration", timedelta(0))
663
+ total_tokens = session_data.get("total_tokens_used", 0)
664
+ est_cost = (total_tokens / 1_000_000) * 4.5 if total_tokens else 0
665
+
666
+ duration_mins = int(duration.total_seconds() / 60)
667
+
668
+ # Build timeline markdown
669
+ timeline_md = ""
670
+ prev_timestamp = None
671
+
672
+ for event in events:
673
+ timestamp = event.get("timestamp")
674
+ tool_name = event.get("tool_name", "unknown")
675
+ status = event.get("status", "")
676
+ input_summary = self._sanitize_summary(event.get("input_summary", ""))
677
+
678
+ time_str = timestamp.strftime("%H:%M:%S") if timestamp else "??:??:??"
679
+
680
+ # Status indicator
681
+ if status == "completed" or not status:
682
+ status_icon = "✓"
683
+ elif status == "failed":
684
+ status_icon = "✗"
685
+ else:
686
+ status_icon = "○"
687
+
688
+ # Calculate thinking time
689
+ think_time = ""
690
+ if prev_timestamp and timestamp:
691
+ delta = (timestamp - prev_timestamp).total_seconds()
692
+ if delta > 5:
693
+ think_time = f" *(+{int(delta)}s)*"
694
+
695
+ prev_timestamp = timestamp
696
+
697
+ timeline_md += f"**{time_str}** {status_icon} `{tool_name}`{think_time}\n"
698
+
699
+ if self.detail == "full" and input_summary:
700
+ timeline_md += f" → {input_summary[:100]}\n"
701
+
702
+ timeline_md += "\n"
703
+
704
+ # Complete markdown document
705
+ markdown = f"""# Session Report: {session_id}
706
+
707
+ **Agent:** {agent}
708
+ **Duration:** {duration_mins} minutes
709
+ **Tokens:** {total_tokens:,}
710
+ **Estimated Cost:** ${est_cost:.2f}
711
+
712
+ ---
713
+
714
+ ## Timeline
715
+
716
+ {timeline_md}
717
+
718
+ ---
719
+
720
+ ## Summary
721
+
722
+ Total events: {len(events)}
723
+
724
+ *Generated by HtmlGraph - "HTML is All You Need"*
725
+ """
726
+
727
+ return markdown