transcript-viewer 0.5.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.
@@ -0,0 +1,1000 @@
1
+ """Local trajectory viewer.
2
+
3
+ Serves a single self-contained page on loopback. Trajectories are converted on
4
+ demand and cached in memory, so opening the viewer over a large corpus is cheap
5
+ and only the sessions actually opened pay conversion cost.
6
+
7
+ The server binds 127.0.0.1 only — it reads local session logs, which routinely
8
+ contain source code and credentials in tool output, and must never be reachable
9
+ off-host.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import errno
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import threading
21
+ import webbrowser
22
+ from functools import partial
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from pathlib import Path
25
+ from urllib.parse import parse_qs, unquote, urlparse
26
+
27
+ from atif_make.atif import ContentPart, Trajectory
28
+ from . import corpus
29
+ from atif_make.archive import extract, is_archive
30
+ from atif_make.convert import convert
31
+ from transcript_viewer.corpus import Entry, scan
32
+
33
+ from . import ai, config, fetch, library
34
+
35
+ # The page is a real .html file rather than a string in here: it is 2,232 lines
36
+ # of CSS and JavaScript, which is not Python and should not be typed as though
37
+ # it were. Read once at import, and served from memory.
38
+ PAGE = (Path(__file__).parent / "page.html").read_text(encoding="utf-8")
39
+
40
+
41
+ def _point_images_at_server(trajectory: Trajectory, index: str) -> None:
42
+ """Rewrite relative image paths to a URL this server can answer.
43
+
44
+ On disk an image path resolves next to the trajectory file, but nothing is
45
+ written to disk here — the bytes are in memory, so they need an endpoint.
46
+ """
47
+
48
+ def parts(value):
49
+ return [p for p in value if isinstance(p, ContentPart)] if isinstance(value, list) else []
50
+
51
+ def walk(t: Trajectory) -> None:
52
+ for step in t.steps:
53
+ targets = list(parts(step.message))
54
+ for result in (step.observation.results if step.observation else ()):
55
+ targets += parts(result.content)
56
+ for part in targets:
57
+ if part.type == "image" and part.source and not part.source.path.startswith(
58
+ ("http://", "https://", "data:")
59
+ ):
60
+ name = part.source.path.rsplit("/", 1)[-1]
61
+ part.source.path = f"/api/image?id={index}&name={name}"
62
+ for sub in t.subagent_trajectories or ():
63
+ walk(sub)
64
+
65
+ walk(trajectory)
66
+
67
+
68
+ def _reveal(target: Path) -> bool:
69
+ """Show a path in the OS file manager.
70
+
71
+ Reveal-only, never open: `open -R` selects the item in Finder rather than
72
+ launching whatever application is registered for it, so clicking a path in
73
+ a log cannot execute anything.
74
+ """
75
+ if not target.exists():
76
+ return False
77
+ if sys.platform == "darwin":
78
+ command = ["open", "-R", str(target)]
79
+ elif sys.platform.startswith("linux") and shutil.which("xdg-open"):
80
+ # xdg-open has no reveal equivalent, so open the containing directory.
81
+ command = ["xdg-open", str(target if target.is_dir() else target.parent)]
82
+ else:
83
+ return False
84
+ try:
85
+ subprocess.run(command, check=False, timeout=10)
86
+ except (OSError, subprocess.SubprocessError):
87
+ return False
88
+ return True
89
+
90
+
91
+ # Enough of a log to inspect its shape without shipping a 143 MB file.
92
+ RAW_LIMIT = 512 * 1024
93
+
94
+ # A browser upload crosses loopback, so this is generous; it exists to stop a
95
+ # stray multi-gigabyte archive from filling the disk, not to be restrictive.
96
+ UPLOAD_LIMIT = 2 * 1024 * 1024 * 1024
97
+
98
+ # Where opened files live. atif-make owns the location because it also has to
99
+ # recognise one during a scan; a second constant here could drift from it.
100
+
101
+
102
+ def _groups(rows: list[dict]) -> list[str]:
103
+ """Every node of the tree, implied parents included, in reading order."""
104
+ seen: set[str] = set()
105
+ for row in rows:
106
+ parts = [p for p in row["group"].split("/") if p]
107
+ for depth in range(1, len(parts) + 1):
108
+ seen.add("/".join(parts[:depth]))
109
+ # Local before Remote, then alphabetically within each.
110
+ return sorted(seen, key=lambda path: (not path.startswith("Local"), path))
111
+
112
+
113
+ SITES = {"huggingface.co": "Hugging Face", "github.com": "GitHub"}
114
+ AGENTS = {"claude-code": "Claude Code", "codex": "Codex", "copilot": "Copilot"}
115
+
116
+
117
+ def _group(entry, source: str) -> str:
118
+ """Where a session belongs in the tree.
119
+
120
+ Derived, never stored. Collections were a second way to organise laid over
121
+ one that already existed — where a transcript came from — and the two could
122
+ disagree. This reads the truth instead: local sessions under the project
123
+ that produced them, remote ones under the repository they came from. Tags
124
+ remain for grouping that cuts across both.
125
+ """
126
+ if entry.origin == "fetched":
127
+ if not source:
128
+ return "Remote/Elsewhere"
129
+ if source.startswith("s3://"):
130
+ rest = source[len("s3://") :].strip("/")
131
+ return "/".join(["Remote", "S3", *[p for p in rest.split("/") if p]])
132
+ parsed = urlparse(source)
133
+ site = SITES.get(parsed.netloc, parsed.netloc or "Elsewhere")
134
+ parts = [p for p in parsed.path.split("/") if p]
135
+ if parsed.netloc == "huggingface.co" and parts[:1] == ["datasets"]:
136
+ parts = parts[1:]
137
+ # <owner>/<repo>/tree/<rev>/<inner…>
138
+ inner = parts[4:] if len(parts) > 4 and parts[2:3] in (["tree"], ["blob"]) else []
139
+ return "/".join(["Remote", site, *parts[:2], *inner])
140
+
141
+ # Local mirrors Remote: the thing that produced it, then the unit of work.
142
+ agent = AGENTS.get(entry.agent, entry.agent or "Unknown")
143
+ if entry.origin == "opened":
144
+ return f"Local/Opened/{agent}"
145
+
146
+ # A project is a directory path; only its own name belongs in the tree, or
147
+ # every session would nest one node deep per directory above it.
148
+ project = Path(entry.project).name if entry.project else Path(entry.path).parent.name
149
+ return f"Local/{agent}/{project or 'Elsewhere'}"
150
+
151
+
152
+ def _source_of(entry, home: Path, web: str, unpacked: dict | None = None) -> str:
153
+ """Where a fetched session came from, folder and all.
154
+
155
+ A file that arrived inside an archive is placed by the archive, not by the
156
+ directories the archive happens to contain: a zip whose insides are called
157
+ "transcripts/transcripts" should not read that way in the tree.
158
+ """
159
+ if not web:
160
+ return ""
161
+ here = Path(entry.path).parent
162
+ for root, came_from in (unpacked or {}).items():
163
+ if here == root or root in here.parents:
164
+ here = came_from
165
+ break
166
+ try:
167
+ inner = here.relative_to(home).as_posix()
168
+ except ValueError:
169
+ inner = ""
170
+ return web if inner in (".", "") else f"{web}/{inner}"
171
+
172
+
173
+ def _ai_state() -> dict:
174
+ """What the page may know about credentials: whether, and from where.
175
+
176
+ Never the key itself — only a four-character tail, enough to tell two
177
+ apart. A page that cannot read the key cannot leak it.
178
+ """
179
+ ok, reason = ai.status()
180
+ return {
181
+ "available": ok,
182
+ "reason": reason,
183
+ "source": config.source("anthropic"),
184
+ "hint": config.hint("anthropic"),
185
+ "model": ai.MODEL,
186
+ # Every credential the viewer can hold — names and tails, never values.
187
+ "secrets": config.state(),
188
+ "aws_profile": config.aws_profile(),
189
+ "aws_profiles": fetch._profiles(),
190
+ "hosts": sorted(set(fetch.HOSTS)),
191
+ }
192
+
193
+
194
+ def _safe_name(raw: str) -> str:
195
+ """Reduce a client-supplied filename to a leaf, so it cannot escape."""
196
+ name = Path(unquote(raw or "")).name.strip()
197
+ return name or "upload"
198
+
199
+
200
+ def _associated_files(source: Path) -> list[dict]:
201
+ """Everything that travelled with a session: subagents, sidecars, siblings.
202
+
203
+ A session is rarely one file — Claude Code keeps subagent traces in a
204
+ sibling directory, and a bundle carries images next to the trajectory.
205
+ """
206
+ found: list[dict] = []
207
+ seen: set[Path] = set()
208
+
209
+ def add(path: Path, role: str) -> None:
210
+ resolved = path.resolve()
211
+ if resolved in seen or not path.is_file():
212
+ return
213
+ seen.add(resolved)
214
+ found.append({
215
+ "name": path.name,
216
+ "path": str(path),
217
+ "role": role,
218
+ "size": path.stat().st_size,
219
+ })
220
+
221
+ add(source, "source")
222
+ subagents = source.parent / source.stem / "subagents"
223
+ if subagents.is_dir():
224
+ for file in sorted(subagents.iterdir()):
225
+ add(file, "subagent")
226
+ # A bundle keeps its images and manifest beside the trajectory.
227
+ for sibling in sorted(source.parent.iterdir()):
228
+ if sibling == source:
229
+ continue
230
+ if sibling.is_dir() and sibling.name == "images":
231
+ for image in sorted(sibling.iterdir()):
232
+ add(image, "image")
233
+ elif sibling.is_file() and sibling.suffix in {".json", ".jsonl", ".har", ".md", ".txt"}:
234
+ add(sibling, "sibling")
235
+ return found
236
+
237
+
238
+ class _Handler(BaseHTTPRequestHandler):
239
+ entries: list[Entry] = []
240
+ cache: dict[str, dict] = {}
241
+ media: dict[str, dict] = {}
242
+ lock = threading.Lock()
243
+
244
+ def log_message(self, *args): # keep the console clean
245
+ pass
246
+
247
+ def _send(self, body: bytes, ctype: str) -> None:
248
+ self.send_response(200)
249
+ self.send_header("Content-Type", ctype)
250
+ self.send_header("Content-Length", str(len(body)))
251
+ self.end_headers()
252
+ self.wfile.write(body)
253
+
254
+ def _entry(self, url):
255
+ """Resolve ?id=<content key> to an entry, or None.
256
+
257
+ Addressing by list position broke the moment entries could be added,
258
+ removed or reordered — a stale link would open a different session.
259
+ """
260
+ key = (parse_qs(url.query).get("id") or [""])[0]
261
+ if not key:
262
+ return None
263
+ return next((e for e in self.entries if e.key == key), None)
264
+
265
+ def _forget(self, keys: set[str]) -> int:
266
+ """Drop sessions from the library and the index.
267
+
268
+ Files are removed only where they are the viewer's own. A copy under
269
+ ~/.transcript-viewer/opened exists solely because the viewer made it; a scanned
270
+ session and a URL download are somebody's own files, sitting where they
271
+ chose, and forgetting a row is not permission to delete them.
272
+ """
273
+ removed = 0
274
+ for key in keys:
275
+ entry = next((e for e in self.entries if e.key == key), None)
276
+ library.remove(key)
277
+ if entry is None:
278
+ continue
279
+ removed += 1
280
+ if entry.origin != "opened":
281
+ continue
282
+ source = Path(entry.path)
283
+ if corpus.OPENED_ROOT not in source.parents:
284
+ continue
285
+ # An unpacked archive puts several logs under one directory, so
286
+ # clearing it wholesale would delete the siblings' files and leave
287
+ # their rows pointing at nothing.
288
+ store = corpus.OPENED_ROOT / source.relative_to(corpus.OPENED_ROOT).parts[0]
289
+ shares = any(
290
+ e.key not in keys and store in Path(e.path).parents for e in self.entries
291
+ )
292
+ if shares:
293
+ source.unlink(missing_ok=True)
294
+ else:
295
+ shutil.rmtree(store, ignore_errors=True)
296
+
297
+ with self.lock:
298
+ self.entries = [e for e in self.entries if e.key not in keys]
299
+ _Handler.entries = self.entries
300
+ corpus.save(self.entries)
301
+ return removed
302
+
303
+ def _in_group(self, name: str) -> set[str]:
304
+ """Keys sitting under a node of the tree, nested ones included."""
305
+ return {
306
+ e.key
307
+ for e in self.entries
308
+ if (at := _group(e, library.get(e.key).get("source", "")))
309
+ and (at == name or at.startswith(f"{name}/"))
310
+ }
311
+
312
+ def do_DELETE(self) -> None:
313
+ url = urlparse(self.path)
314
+ query = parse_qs(url.query)
315
+
316
+ if url.path == "/api/group":
317
+ name = (query.get("name") or [""])[0]
318
+ if not name:
319
+ self._json({"error": "a folder is required"}, 400)
320
+ return
321
+ # A node of the tree is a fact about where sessions came from, so it
322
+ # cannot be removed on its own — only what sits under it can.
323
+ self._json({"removed": self._forget(self._in_group(name)), "folder": name})
324
+ return
325
+
326
+ if url.path != "/api/library":
327
+ self.send_error(404)
328
+ return
329
+
330
+ if (query.get("all") or [""])[0] == "1":
331
+ # Everything the library knows. Files are left where they are by the
332
+ # same rule as a single removal: only a copy the viewer made is ours
333
+ # to delete.
334
+ self._json({"removed": self._forget({e.key for e in self.entries})})
335
+ return
336
+
337
+ key = (query.get("id") or [""])[0]
338
+ if not key:
339
+ self._json({"error": "a key is required"}, 400)
340
+ return
341
+ entry = next((e for e in self.entries if e.key == key), None)
342
+ origin = entry.origin if entry else ""
343
+ self._forget({key})
344
+ self._json({
345
+ "removed": True,
346
+ # Say plainly whether anything left the disk.
347
+ "removed_copy": origin == "opened",
348
+ "origin": origin,
349
+ })
350
+
351
+ def do_POST(self) -> None:
352
+ url = urlparse(self.path)
353
+
354
+ if url.path == "/api/scan":
355
+ # Deliberate, not automatic: this indexes every agent session on the
356
+ # machine, which is not something to do because a library is empty.
357
+ try:
358
+ found = scan()
359
+ except (ValueError, OSError) as exc:
360
+ self._json({"error": str(exc)}, 500)
361
+ return
362
+ with self.lock:
363
+ merged = corpus.merge(self.entries, found)
364
+ self.entries = merged
365
+ _Handler.entries = merged
366
+ corpus.save(merged)
367
+ self._json({"found": len(found), "total": len(merged)})
368
+ return
369
+
370
+ if url.path == "/api/refetch":
371
+ try:
372
+ length = int(self.headers.get("Content-Length") or 0)
373
+ body = json.loads(self.rfile.read(length) or b"{}")
374
+ except (ValueError, json.JSONDecodeError):
375
+ self._json({"error": "expected a JSON body"}, 400)
376
+ return
377
+ # A node knows where it came from and where it was put, so refreshing
378
+ # is the original fetch asked again — no new decisions to make.
379
+ node = body.get("node") or ""
380
+ keys = self._in_group(node)
381
+ records = [library.get(k) for k in keys]
382
+ source = next((r["source"] for r in records if r.get("source")), "")
383
+ into = next((r["into"] for r in records if r.get("into")), "")
384
+ if not source:
385
+ self._json({"error": "that folder was not fetched from anywhere"}, 400)
386
+ return
387
+ self._fetch({"url": source, "into": into, "confirm": True})
388
+ return
389
+
390
+ if url.path == "/api/browse":
391
+ try:
392
+ length = int(self.headers.get("Content-Length") or 0)
393
+ body = json.loads(self.rfile.read(length) or b"{}")
394
+ except (ValueError, json.JSONDecodeError):
395
+ self._json({"error": "expected a JSON body"}, 400)
396
+ return
397
+ try:
398
+ nodes = fetch.browse(
399
+ body.get("url") or "", body.get("path") or "", config.tokens()
400
+ )
401
+ except fetch.FetchError as exc:
402
+ self._json({"error": str(exc)}, 400)
403
+ return
404
+ self._json({"nodes": [n._asdict() for n in nodes]})
405
+ return
406
+
407
+ if url.path == "/api/measure":
408
+ try:
409
+ length = int(self.headers.get("Content-Length") or 0)
410
+ body = json.loads(self.rfile.read(length) or b"{}")
411
+ except (ValueError, json.JSONDecodeError):
412
+ self._json({"error": "expected a JSON body"}, 400)
413
+ return
414
+ try:
415
+ self._json(
416
+ fetch.measure(
417
+ body.get("url") or "", body.get("paths") or [], config.tokens()
418
+ )
419
+ )
420
+ except fetch.FetchError as exc:
421
+ self._json({"error": str(exc)}, 400)
422
+ return
423
+
424
+ if url.path == "/api/fetch":
425
+ try:
426
+ length = int(self.headers.get("Content-Length") or 0)
427
+ body = json.loads(self.rfile.read(length) or b"{}")
428
+ except (ValueError, json.JSONDecodeError):
429
+ self._json({"error": "expected a JSON body"}, 400)
430
+ return
431
+ self._fetch(body)
432
+ return
433
+
434
+ if url.path == "/api/settings":
435
+ try:
436
+ length = int(self.headers.get("Content-Length") or 0)
437
+ body = json.loads(self.rfile.read(length) or b"{}")
438
+ except (ValueError, json.JSONDecodeError):
439
+ self._json({"error": "expected a JSON body"}, 400)
440
+ return
441
+ # The key is read out of the body and handed straight to storage:
442
+ # not logged, not echoed, not kept in memory beyond this call.
443
+ try:
444
+ name = body.get("name") or "anthropic"
445
+ if body.get("clear"):
446
+ config.clear_secret(name)
447
+ elif "aws_profile" in body:
448
+ config.set_aws_profile(body.get("aws_profile") or "")
449
+ elif "token" in body or "api_key" in body:
450
+ config.set_secret(name, body.get("token") or body.get("api_key") or "")
451
+ else:
452
+ self._json({"error": "nothing to change"}, 400)
453
+ return
454
+ except ValueError as exc:
455
+ self._json({"error": str(exc)}, 400)
456
+ return
457
+ except OSError as exc:
458
+ self._json({"error": f"could not save settings: {exc.strerror}"}, 500)
459
+ return
460
+ self._json(_ai_state())
461
+ return
462
+
463
+ if url.path == "/api/ai":
464
+ if not ai.available():
465
+ self._json({"error": "AI features are not configured here."}, 501)
466
+ return
467
+ try:
468
+ length = int(self.headers.get("Content-Length") or 0)
469
+ body = json.loads(self.rfile.read(length) or b"{}")
470
+ except (ValueError, json.JSONDecodeError):
471
+ self._json({"error": "expected a JSON body"}, 400)
472
+ return
473
+
474
+ entry = next((e for e in self.entries if e.key == body.get("key")), None)
475
+ if entry is None:
476
+ self._json({"error": "no such session"}, 404)
477
+ return
478
+ if not library.get(entry.key).get("ai", True):
479
+ self._json({"error": "AI is switched off for this transcript."}, 403)
480
+ return
481
+ trajectory = self._trajectory(entry)
482
+ if trajectory is None:
483
+ self._json({"error": "could not read that session"}, 500)
484
+ return
485
+
486
+ try:
487
+ if body.get("what") == "call":
488
+ self._stream_call(body, entry, trajectory)
489
+ elif body.get("what") == "ask":
490
+ question = (body.get("question") or "").strip()
491
+ if not question:
492
+ self._json({"error": "ask what?"}, 400)
493
+ return
494
+ self._stream_ask(
495
+ question, trajectory, body.get("history"), body.get("focus")
496
+ )
497
+ else:
498
+ self._json({"error": "unknown request"}, 400)
499
+ except ai.Unavailable as exc:
500
+ # Raised while building the client, before a byte is written, so
501
+ # a status code is still available.
502
+ self._json({"error": str(exc)}, 503)
503
+ return
504
+
505
+ if url.path == "/api/library":
506
+ try:
507
+ length = int(self.headers.get("Content-Length") or 0)
508
+ body = json.loads(self.rfile.read(length) or b"{}")
509
+ except (ValueError, json.JSONDecodeError):
510
+ self._json({"error": "expected a JSON body"}, 400)
511
+ return
512
+ key = body.get("key")
513
+ if not isinstance(key, str) or not key:
514
+ self._json({"error": "a key is required"}, 400)
515
+ return
516
+ fields = {k: v for k, v in body.items() if k != "key"}
517
+ try:
518
+ record = library.update(key, **fields)
519
+ except ValueError as exc:
520
+ self._json({"error": str(exc)}, 400)
521
+ return
522
+ self._json({"key": key, **record})
523
+ return
524
+
525
+ if url.path != "/api/open":
526
+ self.send_error(404)
527
+ return
528
+
529
+ try:
530
+ length = int(self.headers.get("Content-Length") or 0)
531
+ except ValueError:
532
+ self.send_error(400)
533
+ return
534
+ if length <= 0:
535
+ self._json({"error": "empty upload"}, 400)
536
+ return
537
+ if length > UPLOAD_LIMIT:
538
+ self._json({"error": f"file is larger than {UPLOAD_LIMIT // 1024 ** 3} GB"}, 413)
539
+ return
540
+
541
+ name = _safe_name(self.headers.get("X-Filename", ""))
542
+ staging = Path(tempfile.mkdtemp(prefix="atif-open-"))
543
+ staged = staging / name
544
+ try:
545
+ remaining = length
546
+ with staged.open("wb") as handle:
547
+ while remaining > 0:
548
+ chunk = self.rfile.read(min(1 << 20, remaining))
549
+ if not chunk:
550
+ break
551
+ handle.write(chunk)
552
+ remaining -= len(chunk)
553
+ except OSError as exc:
554
+ shutil.rmtree(staging, ignore_errors=True)
555
+ self._json({"error": f"could not save upload: {exc}"}, 500)
556
+ return
557
+
558
+ # Derive the identity before choosing where it lands, so re-opening the
559
+ # same file updates its place instead of accumulating copies.
560
+ home = corpus.OPENED_ROOT / corpus.content_key(staged)
561
+ try:
562
+ home.mkdir(parents=True, exist_ok=True)
563
+ if is_archive(staged):
564
+ # Keep what is inside, not the container: the logs are what get
565
+ # indexed, and unpacking here keeps their paths stable and any
566
+ # sibling images resolvable. Storing the zip would mean
567
+ # re-extracting to a temp directory on every start.
568
+ unpacked = extract(staged)
569
+ for item in unpacked.iterdir():
570
+ shutil.move(str(item), home / item.name)
571
+ target = home
572
+ else:
573
+ target = home / name
574
+ shutil.move(str(staged), target)
575
+ except (OSError, ValueError) as exc:
576
+ shutil.rmtree(home, ignore_errors=True)
577
+ self._json({"error": f"could not store upload: {exc}"}, 500)
578
+ return
579
+ finally:
580
+ shutil.rmtree(staging, ignore_errors=True)
581
+
582
+ # Exactly what `transcript-viewer <path>` and `atif-make index` do: the CLI and
583
+ # this button must never disagree about what counts as openable.
584
+ try:
585
+ found = scan([target], origin="opened")
586
+ except (ValueError, OSError) as exc:
587
+ shutil.rmtree(home, ignore_errors=True)
588
+ self._json({"error": str(exc)}, 400)
589
+ return
590
+ if not found:
591
+ # Nothing usable in it — do not keep the copy around.
592
+ shutil.rmtree(home, ignore_errors=True)
593
+ self._json({"error": f"nothing convertible in {name}"}, 415)
594
+ return
595
+
596
+ with self.lock:
597
+ merged = corpus.merge(self.entries, found)
598
+ self.entries = merged
599
+ _Handler.entries = merged
600
+ # Persist so an opened file is present next start without a re-scan.
601
+ corpus.save(merged)
602
+ self._json({
603
+ "added": len(found),
604
+ "keys": [e.key for e in found],
605
+ "names": [Path(e.path).name for e in found],
606
+ })
607
+
608
+ def _fetch(self, body: dict) -> None:
609
+ """List what a URL holds, then — only when told twice — download it.
610
+
611
+ Two steps on purpose: a dataset URL can name hundreds of files, and
612
+ nobody should discover that by pressing a button once.
613
+ """
614
+ try:
615
+ picked = body.get("paths")
616
+ plan = (
617
+ fetch.select(body.get("url") or "", picked, config.tokens())
618
+ if picked
619
+ else fetch.plan(body.get("url") or "", config.tokens())
620
+ )
621
+ service, label, files = plan.service, plan.label, plan.files
622
+ into = fetch.destination(body.get("into"))
623
+ except fetch.FetchError as exc:
624
+ self._json({"error": str(exc)}, 400)
625
+ return
626
+
627
+ known = sum(f.size or 0 for f in files)
628
+ home = into / label
629
+ if not body.get("confirm"):
630
+ self._json({
631
+ "plan": True,
632
+ "service": service,
633
+ "label": label,
634
+ "count": len(files),
635
+ "bytes": known,
636
+ "names": [f.name for f in files[:12]],
637
+ "into": str(home),
638
+ })
639
+ return
640
+
641
+ def produce():
642
+ """Frames as the download runs: a still screen reads as a hang."""
643
+ try:
644
+ home.mkdir(parents=True, exist_ok=True)
645
+ done = 0
646
+ for path in fetch.download(service, files, home, config.tokens()):
647
+ done += 1
648
+ yield {"t": "file", "done": done, "total": len(files),
649
+ "name": path.name}
650
+ except fetch.FetchError as exc:
651
+ yield {"t": "error", "error": str(exc)}
652
+ return
653
+ except OSError as exc:
654
+ yield {"t": "error", "error": f"could not store: {exc.strerror}"}
655
+ return
656
+
657
+ # An archive is a container of transcripts, not a transcript, and a
658
+ # bucket of agent runs is mostly zips. Unpack on arrival so a fetch
659
+ # and a dropped archive end up in the same state.
660
+ # Where each unpacked tree came from, so what is inside an archive is
661
+ # placed by the archive's own location rather than by the folder
662
+ # names the archive happens to use internally.
663
+ unpacked_from: dict[Path, Path] = {}
664
+ for item in sorted(home.rglob("*")):
665
+ if not item.is_file() or not is_archive(item):
666
+ continue
667
+ try:
668
+ unpacked = extract(item)
669
+ except (ValueError, OSError):
670
+ continue
671
+ beside = item.with_suffix("")
672
+ beside.mkdir(parents=True, exist_ok=True)
673
+ for inner in unpacked.iterdir():
674
+ shutil.move(str(inner), beside / inner.name)
675
+ unpacked_from[beside] = item.parent
676
+
677
+ # The same path an uploaded file takes, so a URL and a drop cannot
678
+ # disagree about what counts as openable.
679
+ try:
680
+ found = scan([home], origin="fetched")
681
+ except (ValueError, OSError) as exc:
682
+ yield {"t": "error", "error": str(exc)}
683
+ return
684
+ if not found:
685
+ shutil.rmtree(home, ignore_errors=True)
686
+ yield {"t": "error", "error": "nothing convertible was downloaded"}
687
+ return
688
+
689
+ with self.lock:
690
+ # What is new, not what the destination happens to contain: a
691
+ # second fetch into the same folder re-scans everything already
692
+ # there, and reporting that as "added" is a lie.
693
+ known = {e.key for e in self.entries}
694
+ merged = corpus.merge(self.entries, found)
695
+ self.entries = merged
696
+ _Handler.entries = merged
697
+ fresh = sum(1 for e in found if e.key not in known)
698
+ corpus.save(merged)
699
+ for entry in found:
700
+ library.update(
701
+ entry.key,
702
+ source=_source_of(entry, home, plan.web, unpacked_from),
703
+ into=str(into),
704
+ )
705
+ yield {"t": "added", "added": fresh, "into": str(home)}
706
+
707
+ self._frames(produce)
708
+
709
+ def _trajectory(self, entry) -> dict | None:
710
+ """The converted trajectory for an entry, from cache when it is there."""
711
+ with self.lock:
712
+ cached = self.cache.get(entry.key)
713
+ if cached is not None:
714
+ return cached
715
+ try:
716
+ trajectory, _ = convert(Path(entry.path), entry.format)
717
+ except Exception:
718
+ return None
719
+ payload = trajectory.to_dict()
720
+ with self.lock:
721
+ self.cache[entry.key] = payload
722
+ return payload
723
+
724
+ def _find_call(self, trajectory: dict, call_id: str) -> tuple[dict | None, Any]:
725
+ """One tool call and its output, by id."""
726
+ for step in trajectory.get("steps", []):
727
+ for candidate in step.get("tool_calls") or []:
728
+ if candidate.get("tool_call_id") == call_id:
729
+ output = None
730
+ for row in (step.get("observation") or {}).get("results") or []:
731
+ if row.get("source_call_id") == call_id:
732
+ output = row.get("content")
733
+ return candidate, output
734
+ return None, None
735
+
736
+ def _frames(self, produce) -> None:
737
+ """Stream newline-delimited JSON frames as they are produced.
738
+
739
+ Not server-sent events: EventSource reconnects on close, which would
740
+ silently repeat a paid call. A plain streamed response read by fetch
741
+ does not.
742
+ """
743
+ self.send_response(200)
744
+ self.send_header("Content-Type", "application/x-ndjson")
745
+ self.send_header("Cache-Control", "no-store")
746
+ # Without this the browser withholds the first bytes while it sniffs the
747
+ # type, which swallows the beginning of a stream.
748
+ self.send_header("X-Content-Type-Options", "nosniff")
749
+ self.end_headers()
750
+ try:
751
+ for frame in produce():
752
+ self.wfile.write(json.dumps(frame).encode() + b"\n")
753
+ self.wfile.flush()
754
+ except (BrokenPipeError, ConnectionResetError):
755
+ # The reader navigated away. Nothing to report to.
756
+ pass
757
+
758
+ def _stream_call(self, body: dict, entry, trajectory: dict) -> None:
759
+ """Explain one tool call, reusing a summary already paid for."""
760
+ call_id = body.get("call_id")
761
+ stored = library.get(entry.key).get("summaries") or {}
762
+ if call_id in stored and not body.get("again"):
763
+ self._frames(lambda: [{"t": "delta", "text": stored[call_id]}, {"t": "done"}])
764
+ return
765
+
766
+ call, output = self._find_call(trajectory, call_id)
767
+ if call is None:
768
+ self._json({"error": "no such call"}, 404)
769
+ return
770
+
771
+ # Built here so a credential problem is a status code, not a half-stream.
772
+ chunks = ai.summarise_call_stream(call, output)
773
+
774
+ def produce():
775
+ parts: list[str] = []
776
+ try:
777
+ for kind, piece in chunks:
778
+ if kind != "text":
779
+ yield {"t": "thinking"}
780
+ continue
781
+ parts.append(piece)
782
+ yield {"t": "delta", "text": piece}
783
+ except ai.Unavailable as exc:
784
+ yield {"t": "error", "error": str(exc)}
785
+ return
786
+ summary = "".join(parts).strip()
787
+ if summary:
788
+ library.update(entry.key, summaries={**stored, call_id: summary})
789
+ yield {"t": "done"}
790
+
791
+ self._frames(produce)
792
+
793
+ def _stream_ask(
794
+ self, question: str, trajectory: dict, history: Any, focus: Any
795
+ ) -> None:
796
+ """Answer a question, saying which steps it is reading first."""
797
+ turns = history if isinstance(history, list) else []
798
+ earlier = [n for n in focus if isinstance(n, int)] if isinstance(focus, list) else []
799
+ steps = trajectory.get("steps", [])
800
+ used, chunks = ai.ask_stream(question, steps, turns, earlier)
801
+
802
+ def produce():
803
+ # The total matters as much as the count: 40 of 40 means the whole
804
+ # transcript was read, 40 of 3,000 means the answer saw a sliver.
805
+ yield {"t": "steps", "steps": used, "total": len(steps)}
806
+ try:
807
+ for kind, piece in chunks:
808
+ # Thinking is signalled, not shown: the point is that
809
+ # something is happening, not what it says.
810
+ yield {"t": "thinking"} if kind != "text" else {"t": "delta", "text": piece}
811
+ except ai.Unavailable as exc:
812
+ yield {"t": "error", "error": str(exc)}
813
+ return
814
+ yield {"t": "done"}
815
+
816
+ self._frames(produce)
817
+
818
+ def _json(self, payload: dict, status: int = 200) -> None:
819
+ body = json.dumps(payload).encode()
820
+ self.send_response(status)
821
+ self.send_header("Content-Type", "application/json")
822
+ self.send_header("Content-Length", str(len(body)))
823
+ self.end_headers()
824
+ self.wfile.write(body)
825
+
826
+ def do_GET(self) -> None:
827
+ url = urlparse(self.path)
828
+
829
+ if url.path == "/":
830
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
831
+ return
832
+
833
+ if url.path == "/api/index":
834
+ rows = [
835
+ {
836
+ "key": e.key,
837
+ "origin": e.origin,
838
+ "path": e.path,
839
+ "agent": e.agent,
840
+ "format": e.format,
841
+ "session_id": e.session_id,
842
+ "project": e.project,
843
+ "modified": e.modified,
844
+ "size_bytes": e.size_bytes,
845
+ "subagents": e.subagents,
846
+ "session_title": e.session_title,
847
+ "group": _group(e, library.get(e.key).get("source", "")),
848
+ }
849
+ for e in self.entries
850
+ # A downloaded folder the reader deleted, or a drive not mounted
851
+ # right now. Hidden rather than purged: an entry is cheap to
852
+ # keep, and deleting one because a volume is offline is not
853
+ # recoverable.
854
+ if Path(e.path).exists()
855
+ ]
856
+ payload = {
857
+ "ai": _ai_state(),
858
+ "downloads": str(fetch.destination(None)),
859
+ "sessions": library.decorate(rows),
860
+ "groups": _groups(rows),
861
+ "tags": [{"name": t, "count": n} for t, n in library.tags()],
862
+ }
863
+ self._send(json.dumps(payload).encode(), "application/json")
864
+ return
865
+
866
+ if url.path == "/api/reveal":
867
+ raw = (parse_qs(url.query).get("path") or [""])[0]
868
+ if not raw:
869
+ self.send_error(400)
870
+ return
871
+ target = Path(raw).expanduser()
872
+ if _reveal(target):
873
+ self._send(b"ok", "text/plain")
874
+ else:
875
+ self.send_error(404)
876
+ return
877
+
878
+ if url.path == "/api/raw":
879
+ entry = self._entry(url)
880
+ if entry is None:
881
+ self.send_error(404)
882
+ return
883
+ source = Path(entry.path)
884
+ try:
885
+ # A rollout can be hundreds of MB; send a head, not the lot.
886
+ with source.open("rb") as handle:
887
+ body = handle.read(RAW_LIMIT + 1)
888
+ except OSError:
889
+ self.send_error(404)
890
+ return
891
+ truncated = len(body) > RAW_LIMIT
892
+ payload = {
893
+ "path": str(source),
894
+ "size": source.stat().st_size,
895
+ "truncated": truncated,
896
+ "text": body[:RAW_LIMIT].decode("utf-8", errors="replace"),
897
+ }
898
+ self._send(json.dumps(payload).encode(), "application/json")
899
+ return
900
+
901
+ if url.path == "/api/files":
902
+ entry = self._entry(url)
903
+ if entry is None:
904
+ self.send_error(404)
905
+ return
906
+ self._send(json.dumps(_associated_files(Path(entry.path))).encode(),
907
+ "application/json")
908
+ return
909
+
910
+ if url.path == "/api/image":
911
+ query = parse_qs(url.query)
912
+ index = (query.get("id") or [""])[0]
913
+ name = (query.get("name") or [""])[0]
914
+ with self.lock:
915
+ item = self.media.get(index, {}).get(name)
916
+ if item is None:
917
+ self.send_error(404)
918
+ return
919
+ self._send(item.data, item.media_type)
920
+ return
921
+
922
+ if url.path == "/api/trajectory":
923
+ entry = self._entry(url)
924
+ if entry is None:
925
+ self._send(json.dumps({"error": "no such session"}).encode(), "application/json")
926
+ return
927
+ index = entry.key
928
+
929
+ with self.lock:
930
+ cached = self.cache.get(index)
931
+ if cached is None:
932
+ try:
933
+ trajectory, _ = convert(Path(entry.path), entry.format)
934
+ store = trajectory.all_media()
935
+ if store:
936
+ _point_images_at_server(trajectory, index)
937
+ with self.lock:
938
+ self.media[index] = dict(store.items)
939
+ cached = trajectory.to_dict()
940
+ except Exception as exc: # a bad log should not kill the server
941
+ self._send(
942
+ json.dumps({"error": f"{type(exc).__name__}: {exc}"}).encode(),
943
+ "application/json",
944
+ )
945
+ return
946
+ with self.lock:
947
+ self.cache[index] = cached
948
+ self._send(json.dumps(cached).encode(), "application/json")
949
+ return
950
+
951
+ self.send_error(404)
952
+
953
+
954
+ def _bind(port: int, handler, explicit: bool) -> ThreadingHTTPServer:
955
+ """Bind to `port`, or to the next free one when the choice was ours.
956
+
957
+ Viewing a second session while a first is still open is normal, so a busy
958
+ default should not be an error. An explicitly requested port is honoured or
959
+ reported — silently moving it would be worse than failing.
960
+ """
961
+ last: OSError | None = None
962
+ for candidate in range(port, port + (1 if explicit else 20)):
963
+ try:
964
+ return ThreadingHTTPServer(("127.0.0.1", candidate), handler)
965
+ except OSError as exc:
966
+ if exc.errno not in (errno.EADDRINUSE, errno.EACCES):
967
+ raise
968
+ last = exc
969
+ raise SystemExit(
970
+ f"transcript-viewer: port {port} is already in use"
971
+ + ("" if explicit else f" (tried {port}-{port + 19})")
972
+ + ".\nPass --port to choose another, or stop the running viewer."
973
+ ) from last
974
+
975
+
976
+ def serve(
977
+ entries: list[Entry] | None = None,
978
+ port: int = 7433,
979
+ open_browser: bool = True,
980
+ explicit_port: bool = False,
981
+ ) -> None:
982
+ handler = partial(_Handler)
983
+ _Handler.entries = entries if entries is not None else scan()
984
+ _Handler.cache = {}
985
+ _Handler.media = {}
986
+
987
+ # Loopback only: these logs contain source code and tool output.
988
+ server = _bind(port, handler, explicit_port)
989
+ port = server.server_address[1]
990
+ url = f"http://127.0.0.1:{port}/"
991
+ print(f"transcript-viewer: {url} ({len(_Handler.entries)} sessions)")
992
+ print("Ctrl-C to stop.")
993
+ if open_browser:
994
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
995
+ try:
996
+ server.serve_forever()
997
+ except KeyboardInterrupt:
998
+ print("\nstopped.")
999
+ finally:
1000
+ server.server_close()