agenthub-python 0.1.0__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.
agenthub/tracer.py ADDED
@@ -0,0 +1,722 @@
1
+ # Copyright 2025 Prism Shadow. and/or its affiliates
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Conversation tracer module for saving and viewing conversation history.
17
+
18
+ This module provides functionality to save conversation history to local files
19
+ and serve them via a web interface for real-time monitoring.
20
+ """
21
+
22
+ import base64
23
+ import json
24
+ import os
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from flask import Flask, Response, render_template_string
30
+
31
+ from .types import UniMessage
32
+
33
+
34
+ class Tracer:
35
+ """
36
+ Tracer for saving conversation history to local files.
37
+
38
+ This class handles saving conversation history to files in a cache directory
39
+ and provides a web server for browsing and viewing the saved conversations.
40
+ """
41
+
42
+ def __init__(self, cache_dir: str | None = None):
43
+ """
44
+ Initialize the tracer.
45
+
46
+ Args:
47
+ cache_dir: Directory to store conversation history files
48
+ """
49
+ cache_dir = cache_dir or os.getenv("AGENTHUB_CACHE_DIR", "cache")
50
+ self.cache_dir = Path(cache_dir).absolute()
51
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ def _serialize_for_json(self, obj: Any) -> Any:
54
+ """
55
+ Recursively serialize objects for JSON, converting bytes to base64.
56
+
57
+ Args:
58
+ obj: Object to serialize
59
+
60
+ Returns:
61
+ JSON-serializable object
62
+ """
63
+ if isinstance(obj, bytes):
64
+ return base64.b64encode(obj).decode("utf-8")
65
+ elif isinstance(obj, dict):
66
+ return {k: self._serialize_for_json(v) for k, v in obj.items()}
67
+ elif isinstance(obj, list):
68
+ return [self._serialize_for_json(item) for item in obj]
69
+ return obj
70
+
71
+ def save_history(self, history: list[UniMessage], file_id: str, config: dict[str, Any]) -> None:
72
+ """
73
+ Save conversation history to files.
74
+
75
+ Args:
76
+ history: List of UniMessage objects representing the conversation
77
+ file_id: File identifier without extension (e.g., "agent1/00001")
78
+ config: The UniConfig used for this conversation
79
+ """
80
+ # Create directory if needed
81
+ file_path_base = self.cache_dir / file_id
82
+ file_path_base.parent.mkdir(parents=True, exist_ok=True)
83
+
84
+ # Save as JSON
85
+ json_path = file_path_base.with_suffix(".json")
86
+ json_data = {
87
+ "history": self._serialize_for_json(history),
88
+ "config": self._serialize_for_json(config),
89
+ "timestamp": datetime.now().isoformat(),
90
+ }
91
+ with open(json_path, "w", encoding="utf-8") as f:
92
+ json.dump(json_data, f, indent=2, ensure_ascii=False)
93
+
94
+ # Save as human-readable text
95
+ txt_path = file_path_base.with_suffix(".txt")
96
+ formatted_content = self._format_history(history, config)
97
+ with open(txt_path, "w", encoding="utf-8") as f:
98
+ f.write(formatted_content)
99
+
100
+ def _format_history(self, history: list[UniMessage], config: dict[str, Any]) -> str:
101
+ """
102
+ Format conversation history in a readable text format.
103
+
104
+ Args:
105
+ history: List of UniMessage objects
106
+ config: The UniConfig used for this conversation
107
+
108
+ Returns:
109
+ Formatted string representation of the conversation
110
+ """
111
+ lines = []
112
+ lines.append("=" * 80)
113
+ lines.append(f"Conversation History - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
114
+ lines.append("=" * 80)
115
+ lines.append("")
116
+
117
+ # Add config information
118
+ lines.append("Configuration:")
119
+ for key, value in config.items():
120
+ if key != "trace_id": # Don't include trace_id itself
121
+ lines.append(f" {key}: {value}")
122
+ lines.append("")
123
+
124
+ for i, message in enumerate(history, 1):
125
+ role = message["role"].upper()
126
+ lines.append(f"[{i}] {role}:")
127
+ lines.append("-" * 80)
128
+
129
+ for item in message["content_items"]:
130
+ if item["type"] == "text":
131
+ lines.append(f"Text: {item['text']}")
132
+ elif item["type"] == "thinking":
133
+ lines.append(f"Thinking: {item['thinking']}")
134
+ elif item["type"] == "image_url":
135
+ lines.append(f"Image URL: {item['image_url']}")
136
+ elif item["type"] == "tool_call":
137
+ lines.append(f"Tool Call: {item['name']}")
138
+ lines.append(f" Arguments: {json.dumps(item['argument'], indent=2)}")
139
+ lines.append(f" Tool Call ID: {item['tool_call_id']}")
140
+ elif item["type"] == "tool_result":
141
+ lines.append(f"Tool Result (ID: {item['tool_call_id']}): {item['result']}")
142
+
143
+ # Add usage metadata if available
144
+ if "usage_metadata" in message and message["usage_metadata"]:
145
+ metadata = message["usage_metadata"]
146
+ lines.append("\nUsage Metadata:")
147
+ if metadata.get("prompt_tokens"):
148
+ lines.append(f" Prompt Tokens: {metadata['prompt_tokens']}")
149
+ if metadata.get("thoughts_tokens"):
150
+ lines.append(f" Thoughts Tokens: {metadata['thoughts_tokens']}")
151
+ if metadata.get("response_tokens"):
152
+ lines.append(f" Response Tokens: {metadata['response_tokens']}")
153
+
154
+ # Add finish reason if available
155
+ if "finish_reason" in message:
156
+ lines.append(f"\nFinish Reason: {message['finish_reason']}")
157
+
158
+ lines.append("")
159
+
160
+ return "\n".join(lines)
161
+
162
+ def create_web_app(self) -> Flask:
163
+ """
164
+ Create a Flask web application for browsing conversation files.
165
+
166
+ Returns:
167
+ Flask application instance
168
+ """
169
+ app = Flask(__name__)
170
+
171
+ # HTML template for directory listing
172
+ DIRECTORY_TEMPLATE = """
173
+ <!DOCTYPE html>
174
+ <html>
175
+ <head>
176
+ <title>Tracer</title>
177
+ <meta charset="utf-8">
178
+ <style>
179
+ * { margin: 0; padding: 0; box-sizing: border-box; }
180
+ body {
181
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
182
+ max-width: 1000px;
183
+ margin: 0 auto;
184
+ padding: 24px;
185
+ background-color: #fafafa;
186
+ color: #24292f;
187
+ }
188
+ h1 {
189
+ font-size: 24px;
190
+ font-weight: 600;
191
+ margin-bottom: 16px;
192
+ color: #1f2937;
193
+ }
194
+ .breadcrumb {
195
+ margin-bottom: 20px;
196
+ padding: 12px;
197
+ background-color: #fff;
198
+ border-radius: 6px;
199
+ border: 1px solid #d0d7de;
200
+ font-size: 14px;
201
+ }
202
+ .breadcrumb a {
203
+ color: #0969da;
204
+ text-decoration: none;
205
+ }
206
+ .breadcrumb a:hover { text-decoration: underline; }
207
+ .file-list {
208
+ background-color: #fff;
209
+ border-radius: 6px;
210
+ border: 1px solid #d0d7de;
211
+ overflow: hidden;
212
+ }
213
+ .file-item, .dir-item {
214
+ padding: 12px 16px;
215
+ border-bottom: 1px solid #d0d7de;
216
+ display: flex;
217
+ align-items: center;
218
+ justify-content: space-between;
219
+ transition: background-color 0.1s;
220
+ }
221
+ .file-item:last-child, .dir-item:last-child { border-bottom: none; }
222
+ .file-item:hover, .dir-item:hover { background-color: #f6f8fa; }
223
+ .file-item a, .dir-item a {
224
+ color: #0969da;
225
+ text-decoration: none;
226
+ flex-grow: 1;
227
+ font-size: 14px;
228
+ }
229
+ .dir-item a::before { content: "📁 "; margin-right: 8px; }
230
+ .file-item a::before { content: "📄 "; margin-right: 8px; }
231
+ .file-size {
232
+ color: #656d76;
233
+ font-size: 12px;
234
+ margin-left: 12px;
235
+ }
236
+ .empty {
237
+ color: #656d76;
238
+ font-style: italic;
239
+ padding: 32px;
240
+ text-align: center;
241
+ }
242
+ </style>
243
+ </head>
244
+ <body>
245
+ <h1>Tracer</h1>
246
+ <div class="breadcrumb">
247
+ <strong>Path:</strong> {{ breadcrumb|safe }}
248
+ </div>
249
+ <div class="file-list">
250
+ {% if items %}
251
+ {% for item in items %}
252
+ {% if item.is_dir %}
253
+ <div class="dir-item">
254
+ <a href="{{ item.url }}">{{ item.name }}</a>
255
+ </div>
256
+ {% else %}
257
+ <div class="file-item">
258
+ <a href="{{ item.url }}">{{ item.name }}</a>
259
+ <span class="file-size">{{ item.size }}</span>
260
+ </div>
261
+ {% endif %}
262
+ {% endfor %}
263
+ {% else %}
264
+ <div class="empty">No files or directories found.</div>
265
+ {% endif %}
266
+ </div>
267
+ </body>
268
+ </html>
269
+ """
270
+
271
+ # HTML template for JSON conversation viewing
272
+ JSON_VIEWER_TEMPLATE = """
273
+ <!DOCTYPE html>
274
+ <html>
275
+ <head>
276
+ <title>{{ filename }}</title>
277
+ <meta charset="utf-8">
278
+ <style>
279
+ * { margin: 0; padding: 0; box-sizing: border-box; }
280
+ body {
281
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
282
+ max-width: 1000px;
283
+ margin: 0 auto;
284
+ padding: 24px;
285
+ background-color: #fafafa;
286
+ color: #24292f;
287
+ }
288
+ h1 {
289
+ font-size: 24px;
290
+ font-weight: 600;
291
+ margin-bottom: 8px;
292
+ color: #1f2937;
293
+ }
294
+ .breadcrumb {
295
+ margin-bottom: 20px;
296
+ padding: 12px;
297
+ background-color: #fff;
298
+ border-radius: 6px;
299
+ border: 1px solid #d0d7de;
300
+ font-size: 14px;
301
+ }
302
+ .breadcrumb a {
303
+ color: #0969da;
304
+ text-decoration: none;
305
+ }
306
+ .breadcrumb a:hover { text-decoration: underline; }
307
+ .back-button {
308
+ display: inline-block;
309
+ margin-bottom: 20px;
310
+ padding: 8px 16px;
311
+ background-color: #f6f8fa;
312
+ color: #24292f;
313
+ text-decoration: none;
314
+ border-radius: 6px;
315
+ border: 1px solid #d0d7de;
316
+ font-size: 14px;
317
+ }
318
+ .back-button:hover {
319
+ background-color: #e7ebef;
320
+ }
321
+ .config-box {
322
+ background-color: #fff;
323
+ border-radius: 6px;
324
+ border: 1px solid #d0d7de;
325
+ padding: 16px;
326
+ margin-bottom: 20px;
327
+ font-size: 14px;
328
+ }
329
+ .config-box h2 {
330
+ font-size: 16px;
331
+ font-weight: 600;
332
+ margin-bottom: 12px;
333
+ color: #1f2937;
334
+ }
335
+ .config-item {
336
+ padding: 6px 0;
337
+ color: #656d76;
338
+ }
339
+ .config-item strong {
340
+ color: #24292f;
341
+ }
342
+ .message-card {
343
+ background-color: #fff;
344
+ border-radius: 6px;
345
+ border: 1px solid #d0d7de;
346
+ margin-bottom: 16px;
347
+ overflow: hidden;
348
+ }
349
+ .message-header {
350
+ padding: 12px 16px;
351
+ background-color: #f6f8fa;
352
+ border-bottom: 1px solid #d0d7de;
353
+ display: flex;
354
+ justify-content: space-between;
355
+ align-items: center;
356
+ cursor: pointer;
357
+ user-select: none;
358
+ }
359
+ .message-header:hover {
360
+ background-color: #e7ebef;
361
+ }
362
+ .message-role {
363
+ font-weight: 600;
364
+ font-size: 14px;
365
+ text-transform: uppercase;
366
+ }
367
+ .role-user { color: #0969da; }
368
+ .role-assistant { color: #1a7f37; }
369
+ .message-metadata {
370
+ font-size: 12px;
371
+ color: #656d76;
372
+ }
373
+ .message-content {
374
+ padding: 16px;
375
+ display: none;
376
+ }
377
+ .message-content.expanded {
378
+ display: block;
379
+ }
380
+ .content-item {
381
+ margin-bottom: 12px;
382
+ padding-bottom: 12px;
383
+ border-bottom: 1px solid #f6f8fa;
384
+ }
385
+ .content-item:last-child {
386
+ border-bottom: none;
387
+ margin-bottom: 0;
388
+ padding-bottom: 0;
389
+ }
390
+ .content-type {
391
+ font-size: 11px;
392
+ font-weight: 600;
393
+ color: #656d76;
394
+ text-transform: uppercase;
395
+ letter-spacing: 0.5px;
396
+ margin-bottom: 6px;
397
+ }
398
+ .content-text {
399
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
400
+ font-size: 13px;
401
+ line-height: 1.6;
402
+ white-space: pre-wrap;
403
+ word-wrap: break-word;
404
+ background-color: #f6f8fa;
405
+ padding: 12px;
406
+ border-radius: 4px;
407
+ color: #24292f;
408
+ }
409
+ .tool-call, .tool-result {
410
+ background-color: #fff8c5;
411
+ padding: 12px;
412
+ border-radius: 4px;
413
+ border-left: 3px solid #d4a72c;
414
+ }
415
+ .thinking {
416
+ background-color: #ddf4ff;
417
+ padding: 12px;
418
+ border-radius: 4px;
419
+ border-left: 3px solid #0969da;
420
+ }
421
+ .usage-box {
422
+ margin-top: 12px;
423
+ padding: 12px;
424
+ background-color: #f6f8fa;
425
+ border-radius: 4px;
426
+ font-size: 12px;
427
+ color: #656d76;
428
+ text-align: right;
429
+ }
430
+ .toggle-icon {
431
+ transition: transform 0.2s;
432
+ display: inline-block;
433
+ }
434
+ .toggle-icon.expanded {
435
+ transform: rotate(90deg);
436
+ }
437
+ </style>
438
+ </head>
439
+ <body>
440
+ <h1>{{ filename }}</h1>
441
+ <div class="breadcrumb">
442
+ <strong>Path:</strong> {{ breadcrumb|safe }}
443
+ </div>
444
+ <a href="{{ back_url }}" class="back-button">← Back to Directory</a>
445
+
446
+ {% if config %}
447
+ <div class="config-box">
448
+ <h2>Configuration</h2>
449
+ {% for key, value in config.items() %}
450
+ {% if key != 'trace_id' %}
451
+ <div class="config-item">
452
+ <strong>{{ key|e }}:</strong>
453
+ {% if key == 'tools' and value is iterable and value is not string %}
454
+ <pre style="margin: 4px 0 0 0; padding: 8px; background-color: #f6f8fa; border-radius: 4px; font-size: 12px; overflow-x: auto;">{{ value|tojson(indent=2)|e }}</pre>
455
+ {% else %}
456
+ {{ value|e }}
457
+ {% endif %}
458
+ </div>
459
+ {% endif %}
460
+ {% endfor %}
461
+ </div>
462
+ {% endif %}
463
+
464
+ {% for msg_idx, message in enumerate(history) %}
465
+ <div class="message-card">
466
+ <div class="message-header" onclick="toggleMessage({{ msg_idx }})">
467
+ <div>
468
+ <span class="message-role role-{{ message.role }}">{{ message.role }}</span>
469
+ <span class="message-metadata"> • {{ message.content_items|length }} item(s)</span>
470
+ </div>
471
+ <span class="toggle-icon" id="icon-{{ msg_idx }}">▶</span>
472
+ </div>
473
+ <div class="message-content" id="content-{{ msg_idx }}">
474
+ {% for item in message.content_items %}
475
+ <div class="content-item">
476
+ <div class="content-type">{{ item.type|e }}</div>
477
+ {% if item.type == 'text' %}
478
+ <div class="content-text">{{ item.text|e }}</div>
479
+ {% elif item.type == 'thinking' %}
480
+ <div class="content-text thinking">{{ item.thinking|e }}</div>
481
+ {% elif item.type == 'tool_call' %}
482
+ <div class="tool-call">
483
+ <div class="content-text">{{ item.name|e }}({% for key, value in item.argument.items() %}{{ key|e }}={{ value|e|tojson }}{% if not loop.last %}, {% endif %}{% endfor %})</div>
484
+ </div>
485
+ {% elif item.type == 'tool_result' %}
486
+ <div class="tool-result">
487
+ <strong>Result:</strong> {{ item.result|e }}<br>
488
+ <strong>Call ID:</strong> {{ item.tool_call_id|e }}
489
+ </div>
490
+ {% elif item.type == 'image_url' %}
491
+ <div class="content-text">
492
+ <img src="{{ item.image_url|e }}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" alt="Preview">
493
+ </div>
494
+ {% endif %}
495
+ </div>
496
+ {% endfor %}
497
+
498
+ {% if message.usage_metadata or message.finish_reason %}
499
+ <div class="usage-box">
500
+ {% if message.usage_metadata %}
501
+ {% if message.usage_metadata.prompt_tokens %}
502
+ Prompt: {{ message.usage_metadata.prompt_tokens }} tokens
503
+ {% endif %}
504
+ {% if message.usage_metadata.thoughts_tokens %}
505
+ • Thoughts: {{ message.usage_metadata.thoughts_tokens }} tokens
506
+ {% endif %}
507
+ {% if message.usage_metadata.response_tokens %}
508
+ • Response: {{ message.usage_metadata.response_tokens }} tokens
509
+ {% endif %}
510
+ {% endif %}
511
+ {% if message.finish_reason %}
512
+ {% if message.usage_metadata %} • {% endif %}Finish: {{ message.finish_reason|e }}
513
+ {% endif %}
514
+ </div>
515
+ {% endif %}
516
+ </div>
517
+ </div>
518
+ {% endfor %}
519
+
520
+ <script>
521
+ function toggleMessage(idx) {
522
+ const content = document.getElementById('content-' + idx);
523
+ const icon = document.getElementById('icon-' + idx);
524
+ content.classList.toggle('expanded');
525
+ icon.classList.toggle('expanded');
526
+ }
527
+ // Expand all messages by default
528
+ const numMessages = {{ history|length }};
529
+ for (let i = 0; i < numMessages; i++) {
530
+ if (document.getElementById('content-' + i)) {
531
+ toggleMessage(i);
532
+ }
533
+ }
534
+ </script>
535
+ </body>
536
+ </html>
537
+ """
538
+
539
+ # HTML template for text file viewing
540
+ TEXT_VIEWER_TEMPLATE = """
541
+ <!DOCTYPE html>
542
+ <html>
543
+ <head>
544
+ <title>{{ filename }}</title>
545
+ <meta charset="utf-8">
546
+ <style>
547
+ * { margin: 0; padding: 0; box-sizing: border-box; }
548
+ body {
549
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
550
+ max-width: 1000px;
551
+ margin: 0 auto;
552
+ padding: 24px;
553
+ background-color: #fafafa;
554
+ color: #24292f;
555
+ }
556
+ h1 {
557
+ font-size: 24px;
558
+ font-weight: 600;
559
+ margin-bottom: 8px;
560
+ color: #1f2937;
561
+ }
562
+ .breadcrumb {
563
+ margin-bottom: 20px;
564
+ padding: 12px;
565
+ background-color: #fff;
566
+ border-radius: 6px;
567
+ border: 1px solid #d0d7de;
568
+ font-size: 14px;
569
+ }
570
+ .breadcrumb a {
571
+ color: #0969da;
572
+ text-decoration: none;
573
+ }
574
+ .breadcrumb a:hover { text-decoration: underline; }
575
+ .back-button {
576
+ display: inline-block;
577
+ margin-bottom: 20px;
578
+ padding: 8px 16px;
579
+ background-color: #f6f8fa;
580
+ color: #24292f;
581
+ text-decoration: none;
582
+ border-radius: 6px;
583
+ border: 1px solid #d0d7de;
584
+ font-size: 14px;
585
+ }
586
+ .back-button:hover { background-color: #e7ebef; }
587
+ .file-content {
588
+ background-color: #fff;
589
+ border-radius: 6px;
590
+ border: 1px solid #d0d7de;
591
+ padding: 20px;
592
+ white-space: pre-wrap;
593
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
594
+ font-size: 13px;
595
+ line-height: 1.6;
596
+ overflow-x: auto;
597
+ }
598
+ </style>
599
+ </head>
600
+ <body>
601
+ <h1>{{ filename }}</h1>
602
+ <div class="breadcrumb">
603
+ <strong>Path:</strong> {{ breadcrumb|safe }}
604
+ </div>
605
+ <a href="{{ back_url|e }}" class="back-button">← Back to Directory</a>
606
+ <div class="file-content">{{ content|e }}</div>
607
+ </body>
608
+ </html>
609
+ """
610
+
611
+ @app.route("/")
612
+ @app.route("/<path:subpath>")
613
+ def browse(subpath: str = "") -> str | Response:
614
+ """Browse files and directories in the cache folder."""
615
+ full_path = self.cache_dir / subpath
616
+ full_path = full_path.resolve()
617
+
618
+ # Security check: ensure path is within cache_dir
619
+ if not str(full_path).startswith(str(self.cache_dir.resolve())):
620
+ return "Access denied", 403
621
+
622
+ # If path doesn't exist
623
+ if not full_path.exists():
624
+ return "Path not found", 404
625
+
626
+ # If it's a file, display its content
627
+ if full_path.is_file():
628
+ try:
629
+ # Build breadcrumb
630
+ parts = subpath.split("/") if subpath else []
631
+ breadcrumb_parts = ['<a href="/">cache</a>']
632
+ for i, part in enumerate(parts[:-1]):
633
+ path_to_part = "/".join(parts[: i + 1])
634
+ breadcrumb_parts.append(f'<a href="/{path_to_part}">{part}</a>')
635
+ breadcrumb_parts.append(f"<strong>{parts[-1]}</strong>" if parts else "")
636
+ breadcrumb = " / ".join(breadcrumb_parts)
637
+
638
+ # Determine back URL
639
+ back_url = "/" + "/".join(parts[:-1]) if len(parts) > 1 else "/"
640
+
641
+ # If it's a JSON file, render with the JSON viewer
642
+ if full_path.suffix == ".json":
643
+ with open(full_path, "r", encoding="utf-8") as f:
644
+ data = json.load(f)
645
+
646
+ return render_template_string(
647
+ JSON_VIEWER_TEMPLATE,
648
+ filename=full_path.name,
649
+ breadcrumb=breadcrumb,
650
+ back_url=back_url,
651
+ history=data.get("history", []),
652
+ config=data.get("config", {}),
653
+ enumerate=enumerate,
654
+ )
655
+ else:
656
+ # For text files, use simple viewer
657
+ with open(full_path, "r", encoding="utf-8") as f:
658
+ content = f.read()
659
+
660
+ return render_template_string(
661
+ TEXT_VIEWER_TEMPLATE,
662
+ filename=full_path.name,
663
+ content=content,
664
+ breadcrumb=breadcrumb,
665
+ back_url=back_url,
666
+ )
667
+ except Exception as e:
668
+ return f"Error reading file: {str(e)}", 500
669
+
670
+ # If it's a directory, list its contents
671
+ items = []
672
+ try:
673
+ for entry in sorted(full_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)):
674
+ # Calculate relative path from cache_dir
675
+ try:
676
+ relative_path = entry.resolve().relative_to(self.cache_dir.resolve())
677
+ except ValueError:
678
+ # If relative_to fails, skip this entry for security
679
+ continue
680
+ item_info: dict[str, Any] = {
681
+ "name": entry.name,
682
+ "is_dir": entry.is_dir(),
683
+ "url": f"/{relative_path}",
684
+ }
685
+ if entry.is_file():
686
+ size = entry.stat().st_size
687
+ if size < 1024:
688
+ item_info["size"] = f"{size} B"
689
+ elif size < 1024 * 1024:
690
+ item_info["size"] = f"{size / 1024:.1f} KB"
691
+ else:
692
+ item_info["size"] = f"{size / (1024 * 1024):.1f} MB"
693
+ items.append(item_info)
694
+ except Exception as e:
695
+ return f"Error listing directory: {str(e)}", 500
696
+
697
+ # Build breadcrumb
698
+ parts = subpath.split("/") if subpath else []
699
+ breadcrumb_parts = ['<a href="/">cache</a>']
700
+ for i, part in enumerate(parts):
701
+ if part:
702
+ path_to_part = "/".join(parts[: i + 1])
703
+ breadcrumb_parts.append(f'<a href="/{path_to_part}">{part}</a>')
704
+ breadcrumb = " / ".join(breadcrumb_parts)
705
+
706
+ return render_template_string(DIRECTORY_TEMPLATE, items=items, breadcrumb=breadcrumb)
707
+
708
+ return app
709
+
710
+ def start_web_server(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False) -> None:
711
+ """
712
+ Start the web server for browsing conversation files.
713
+
714
+ Args:
715
+ host: Host address to bind to
716
+ port: Port number to listen on
717
+ debug: Enable debug mode
718
+ """
719
+ app = self.create_web_app()
720
+ print(f"Starting tracer web server at http://{host}:{port}")
721
+ print(f"Cache directory: {self.cache_dir.resolve()}")
722
+ app.run(host=host, port=port, debug=debug)