transcript-viewer 0.14.1__tar.gz → 0.16.0__tar.gz

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.
Files changed (56) hide show
  1. transcript_viewer-0.14.1/README.md → transcript_viewer-0.16.0/PKG-INFO +158 -29
  2. transcript_viewer-0.14.1/PKG-INFO → transcript_viewer-0.16.0/README.md +143 -42
  3. transcript_viewer-0.16.0/TODO.md +40 -0
  4. transcript_viewer-0.16.0/postgresql:/postgres:x@localhost/postgres +0 -0
  5. transcript_viewer-0.16.0/pyproject.toml +61 -0
  6. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/ai.py +8 -1
  7. transcript_viewer-0.16.0/src/transcript_viewer/cache.py +115 -0
  8. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/cli.py +72 -2
  9. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/config.py +10 -4
  10. transcript_viewer-0.16.0/src/transcript_viewer/db.py +369 -0
  11. transcript_viewer-0.16.0/src/transcript_viewer/deploy.py +177 -0
  12. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/fetch.py +20 -0
  13. transcript_viewer-0.16.0/src/transcript_viewer/identity.py +54 -0
  14. transcript_viewer-0.16.0/src/transcript_viewer/ingest.py +119 -0
  15. transcript_viewer-0.16.0/src/transcript_viewer/library.py +301 -0
  16. transcript_viewer-0.16.0/src/transcript_viewer/page.html +7208 -0
  17. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/viewer.py +231 -124
  18. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/page.test.js +2011 -188
  19. transcript_viewer-0.16.0/tests/test_authorisation.py +186 -0
  20. transcript_viewer-0.16.0/tests/test_cache.py +85 -0
  21. transcript_viewer-0.16.0/tests/test_db.py +409 -0
  22. transcript_viewer-0.16.0/tests/test_deploy.py +135 -0
  23. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_fetch.py +64 -0
  24. transcript_viewer-0.16.0/tests/test_identity.py +76 -0
  25. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_library.py +56 -21
  26. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_readme.py +5 -1
  27. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_style.py +75 -6
  28. transcript_viewer-0.16.0/tests/test_sync.py +95 -0
  29. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_tidiness.py +24 -0
  30. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_viewer.py +43 -22
  31. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/uv.lock +87 -10
  32. transcript_viewer-0.14.1/pyproject.toml +0 -38
  33. transcript_viewer-0.14.1/src/transcript_viewer/library.py +0 -201
  34. transcript_viewer-0.14.1/src/transcript_viewer/page.html +0 -3463
  35. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/.coverage +0 -0
  36. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/.github/workflows/publish.yml +0 -0
  37. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/.github/workflows/test.yml +0 -0
  38. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/.gitignore +0 -0
  39. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/Anchor.dc.html +0 -0
  40. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/Dense.dc.html +0 -0
  41. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/Hover.dc.html +0 -0
  42. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/Main.dc.html +0 -0
  43. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/Quiet.dc.html +0 -0
  44. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/canvas.json +0 -0
  45. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/design/delegation-timeline.html +0 -0
  46. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/__init__.py +0 -0
  47. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/corpus.py +0 -0
  48. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/src/transcript_viewer/store.py +0 -0
  49. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/fixtures/big-command.json +0 -0
  50. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_ai.py +0 -0
  51. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_cli.py +0 -0
  52. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_config.py +0 -0
  53. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_corpus.py +0 -0
  54. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_page.py +0 -0
  55. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_store.py +0 -0
  56. {transcript_viewer-0.14.1 → transcript_viewer-0.16.0}/tests/test_stream.py +0 -0
@@ -1,3 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: transcript-viewer
3
+ Version: 0.16.0
4
+ Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: atif-make>=0.8.0
8
+ Provides-Extra: ai
9
+ Requires-Dist: anthropic>=0.40; extra == 'ai'
10
+ Provides-Extra: parquet
11
+ Requires-Dist: atif-make[parquet]>=0.8.0; extra == 'parquet'
12
+ Provides-Extra: postgres
13
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
14
+ Description-Content-Type: text/markdown
15
+
1
16
  # transcript-viewer
2
17
 
3
18
  Browse agent transcripts in a local web viewer — what Claude Code, Codex and
@@ -10,7 +25,20 @@ all of the reading. What is added here is only the browser interface, so a
10
25
  format this cannot open is a parser missing from `atif-make` rather than
11
26
  anything to change in the viewer.
12
27
 
13
- ## Install
28
+ ## Run it
29
+
30
+ No install, nothing to undo:
31
+
32
+ ```sh
33
+ uvx transcript-viewer # opens on 127.0.0.1, and that is it
34
+ uvx transcript-viewer path/to/session.jsonl
35
+ ```
36
+
37
+ `uvx` fetches it, runs it, and leaves nothing behind. It is the honest way to
38
+ try a tool that is going to read your agent logs — have a look first, and keep
39
+ it only if it earns the disk.
40
+
41
+ To keep it:
14
42
 
15
43
  ```sh
16
44
  uv tool install transcript-viewer # pulls atif-make automatically
@@ -20,15 +48,23 @@ uv tool install "transcript-viewer[ai,parquet]" # + the optional Claude fea
20
48
 
21
49
  The extras are only needed for what they name: `parquet` for a dataset that
22
50
  ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
23
- viewer works without either.
51
+ viewer works without either. With `uvx`, ask for one with
52
+ `uvx --from "transcript-viewer[parquet]" transcript-viewer`.
24
53
 
25
54
  ```sh
26
55
  transcript-viewer # the library, empty on a first run
27
56
  transcript-viewer path/to/session.jsonl # one log
28
57
  transcript-viewer bundle.zip # a bundle someone sent you
29
58
  transcript-viewer --port 8080 --no-open
59
+ transcript-viewer sync s3://bucket/runs # take what is new, and only what is new
30
60
  ```
31
61
 
62
+ Everything is yours and stays here. It binds `127.0.0.1` and nothing else, the
63
+ library lives in `~/.transcript-viewer`, no account is involved, and nothing
64
+ leaves the machine unless you ask it to — a fetch you typed, or a summary you
65
+ pressed for with your own API key. There is no telemetry to turn off because
66
+ there is none to write.
67
+
32
68
  Nothing appears on a first run. Sessions arrive two ways:
33
69
 
34
70
  - **⟳ on the Local folder** finds what Claude Code, Codex and Copilot have
@@ -185,9 +221,33 @@ in the corpus this was built against were. Two bars in the same column of the
185
221
  chart is what says it; overlap gets no colour of its own, since a second
186
222
  encoding of the same fact is noise.
187
223
 
224
+ A subagent can delegate in turn, and in some harnesses that does not stop at two
225
+ levels. One that did gets a row to itself, labelled, with the number it spawned
226
+ and a control that opens them: its children appear beneath it, indented, packed
227
+ among themselves rather than against the rest of the run. The list does the same
228
+ with indented rows. Shut by default and remembered per subagent, so a run where
229
+ nothing nested — which is every run in the library this was built against — is
230
+ drawn exactly as it was before. The header says how deep the delegation went
231
+ only when it went further than one hop.
232
+
233
+ Under the chart is the shape of the delegation as numbers: every link in it
234
+ sorted into the three kinds it can be — from the main thread, from another
235
+ subagent, between subagents — to scale, with the counts and the ratio. A run is
236
+ not described by its subagent count alone; five spawned by the main thread is a
237
+ fan-out and five where two of them spawned the rest is a hierarchy, and the two
238
+ read identically until the links are counted. Each kind is counted rather than
239
+ assumed, so a format that starts recording one starts showing it here. Across
240
+ the library this was built against the ratio is 100 / 0 / 0 — 52 links in 17
241
+ transcripts, every one a call from the main thread — and 92 further subagents
242
+ that nothing links at all, which is the fact the line exists to surface. Beside
243
+ it: how many delegated in turn, the widest fan-out, and how many were spawned by
244
+ nothing.
245
+
188
246
  Clicking a lane opens the box the transcript already has for it rather than
189
247
  showing the same thing twice, drawing the run out far enough to reach it first:
190
- in one real session every delegation happens after step 1,231.
248
+ in one real session every delegation happens after step 1,231. A nested subagent
249
+ is drawn inside its orchestrator's box, so it is the orchestrator's step that
250
+ has to be painted and the boxes above it are unfolded on the way.
191
251
 
192
252
  A long run paints its first 250 steps at once and fills the rest in behind
193
253
  itself, so there is nothing to press and nothing to wait for. The largest run
@@ -298,17 +358,17 @@ which of the two is in use, and **Remove** clears the stored one.
298
358
 
299
359
  With no key configured, every AI control is hidden and the endpoint refuses.
300
360
 
301
- Each transcript also has its own **With AI support** switch. Turn it off and
302
- that session's controls disappear — useful when a transcript holds something that should not
303
- leave the machine. The server enforces it too, so a switched-off transcript is
304
- refused even if a request is made directly.
305
-
306
361
  ## Themes
307
362
 
308
- Three, from Diwan: `paper`, `cool` (both light) and `dark`. The control in the
309
- top bar cycles them, as Diwan's own header does, and the choice is remembered
310
- per browser. Note that "light and dark" is really three modes here — two of
311
- Diwan's palettes are light.
363
+ Three from Diwan — `paper`, `cool` (both light) and `dark` — and a pair,
364
+ `redwood` and `redwoodDark`, taken from Redwood Research's own design tokens for
365
+ transcripts that end up in a Redwood deck. Both come from that file as written:
366
+ the dark one is Redwood's own dark palette, not the light one dimmed, which is
367
+ why its green is `#43b184` where the light one's is `#1a7d5c`. The control in
368
+ the top bar cycles them, as Diwan's own header does, and the choice is
369
+ remembered per browser; a remembered name this build no longer has falls back to
370
+ `paper` rather than to an unstyled page. Note that "light and dark" is really
371
+ five modes here, three of them light.
312
372
 
313
373
  ## What it shows
314
374
 
@@ -406,44 +466,113 @@ an external file (`--split-subagents`) rather than an embedded trajectory, the
406
466
  viewer says so instead of silently showing nothing.
407
467
 
408
468
  Because branches sit anywhere in a trajectory that may run to thousands of
409
- steps, every session with branches gets a jump list at the top — agent type,
410
- task, step count — and an "only branches" filter. Steps render 250 at a time so
469
+ steps, there is an "only branches" filter. There was also a jump list above the
470
+ run, until the delegation box came to say everything it said and more — and to
471
+ disagree with it, since the list counted top-level calls while the box counts
472
+ every subagent at every depth. Steps render 250 at a time so
411
473
  a 8,000-step session stays responsive.
412
474
 
475
+ ## Running it for more than one person
476
+
477
+ Everything above is the whole tool. This section is for the one case it is not:
478
+ a copy of it serving a team rather than sitting on a laptop.
479
+
480
+ Nothing here is on by default, and a copy told nothing behaves exactly as the
481
+ one you have — loopback, nobody signed in, annotations in SQLite beside the
482
+ index. There is a test that walks every route asserting that, because it is what
483
+ keeps this one program rather than two.
484
+
485
+ A `deploy.toml` beside the library says the rest, and every setting can come
486
+ from the environment instead, which is what a container has:
487
+
488
+ ```toml
489
+ [server]
490
+ host = "0.0.0.0"
491
+
492
+ [identity]
493
+ # The header whatever stands in front puts the signed-in person in. Trusted
494
+ # only because it is named here — a header this was not told to expect is one
495
+ # anybody can send.
496
+ header = "x-amzn-oidc-identity"
497
+ admins = ["you@example.com"]
498
+
499
+ [storage]
500
+ database = "postgresql://…" # needs transcript-viewer[postgres]
501
+
502
+ [secrets]
503
+ mode = "server" # the operator's keys, not each person's
504
+
505
+ [ai]
506
+ enabled = "off" # never send a transcript to a model
507
+
508
+ [sources]
509
+ urls = ["s3://bucket/runs"] # what `transcript-viewer sync` follows
510
+ ```
511
+
512
+ The sign-in is not implemented here and should not be: a load balancer or a mesh
513
+ in front does it, and hands over one header. That is why the header must be
514
+ named — if the server can be reached around whatever authenticated the request,
515
+ anyone can claim to be anyone.
516
+
517
+ `postgres` is for this and only this. Annotations live in SQLite otherwise,
518
+ which is in the standard library and needs nothing installed — but two copies of
519
+ the server with two SQLite files means one person's stars exist on one of them
520
+ and not the other, which is a wrong answer rather than a slow one.
521
+
522
+ Labels are shared and stars are personal: a title, a tag or a note is the team's
523
+ one description of a transcript, and what you starred is yours.
524
+
413
525
  ## Developing alongside atif-make
414
526
 
415
- The two packages are developed together. Installing the viewer editable makes
416
- its own code live:
527
+ The two packages are developed together — a viewer feature usually needs the
528
+ parser to record the thing first — so `atif-make` resolves from the directory
529
+ next door rather than from the index:
417
530
 
418
- ```sh
419
- uv tool install --force --editable .
531
+ ```toml
532
+ [tool.uv.sources]
533
+ atif-make = { path = "../atif-make", editable = true }
420
534
  ```
421
535
 
422
- That alone still resolves `atif-make` from git, so edits to the converter would
423
- not show up. To run with **both** live, add it explicitly:
536
+ `uv run transcript-viewer` therefore runs both working copies, with no extra
537
+ flags. uv drops that declaration when the package is built, so what is published
538
+ still depends on the version range; a checkout without `atif-make` beside it can
539
+ pass `--no-sources`.
540
+
541
+ To make the installed command live too:
424
542
 
425
543
  ```sh
426
- uv run --with-editable ../atif-make transcript-viewer
544
+ uv tool install --force --editable .
427
545
  ```
428
546
 
429
- Use that while changing anything in `atif-make`. Reinstall from the index
430
- (`uv tool install --force transcript-viewer`) when you want to test what users actually
431
- get.
547
+ Reinstall from the index (`uv tool install --force transcript-viewer`) when you
548
+ want to test what users actually get.
549
+
550
+ **The page is re-read on every load; the converter is not.** `page.html` is
551
+ stat'ed per request, so editing it shows on a refresh. Python modules are
552
+ imported once and a converted trajectory is cached in memory for the life of the
553
+ run, so a parser change needs the server restarted. A fix that made a session's
554
+ delegation three levels deep looked like it had done nothing for exactly this
555
+ reason: the page was new, the parser in memory was not, and the viewer drew what
556
+ it was handed.
432
557
 
433
558
  ## Layout
434
559
 
435
560
  ```
436
561
  src/transcript_viewer/
437
- page.html the whole front end — 2,232 lines of HTML, CSS and JavaScript
562
+ page.html the whole interface: one page, no build step, no dependencies
438
563
  viewer.py the HTTP server: sixteen endpoints over the page and the library
439
564
  corpus.py the index — what is on this machine, and where it came from
440
565
  library.py what you decide about a session: title, tags, stars, summaries
566
+ db.py where those decisions are kept — SQLite, or PostgreSQL when shared
567
+ cache.py what has been converted lately, kept only while there is room
441
568
  fetch.py bringing transcripts in from Hugging Face, GitHub, S3 or a link
569
+ ingest.py what happens to them after: unpack, index, record where they came from
442
570
  ai.py the optional Claude-backed features
443
- config.py settings and tokens
571
+ config.py settings and tokens — what a person types in
572
+ deploy.py how this copy is set up — what an operator decided, before anyone arrived
573
+ identity.py who is asking, when anyone is
444
574
  store.py small JSON files under ~/.transcript-viewer, written so a crash cannot truncate them
445
575
  cli.py the command line
446
- page.html the whole interface: one page, no build step, no dependencies
447
576
  ```
448
577
 
449
578
  The page is a file rather than a string inside `viewer.py`, which is where it
@@ -486,8 +615,8 @@ trajectory pane stuck on "Converting…". Those checks live in
486
615
 
487
616
  The AI tests never call the API. Most stub the model call and check the part
488
617
  that matters when it is wrong — that nothing is sent unasked, that a summary is
489
- paid for once, that a stored key never reaches a response, and that a transcript
490
- switched off is refused by the server rather than merely hidden.
618
+ paid for once, that a stored key never reaches a response, and that the endpoint
619
+ refuses outright when no credential is configured.
491
620
 
492
621
  `tests/test_stream.py` is the exception: it drives the one function that does
493
622
  touch the SDK, using a fake client but the SDK's real exception classes, so a
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: transcript-viewer
3
- Version: 0.14.1
4
- Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
- License: MIT
6
- Requires-Python: >=3.12
7
- Requires-Dist: atif-make>=0.7.1
8
- Provides-Extra: ai
9
- Requires-Dist: anthropic>=0.40; extra == 'ai'
10
- Provides-Extra: parquet
11
- Requires-Dist: atif-make[parquet]>=0.5.0; extra == 'parquet'
12
- Description-Content-Type: text/markdown
13
-
14
1
  # transcript-viewer
15
2
 
16
3
  Browse agent transcripts in a local web viewer — what Claude Code, Codex and
@@ -23,7 +10,20 @@ all of the reading. What is added here is only the browser interface, so a
23
10
  format this cannot open is a parser missing from `atif-make` rather than
24
11
  anything to change in the viewer.
25
12
 
26
- ## Install
13
+ ## Run it
14
+
15
+ No install, nothing to undo:
16
+
17
+ ```sh
18
+ uvx transcript-viewer # opens on 127.0.0.1, and that is it
19
+ uvx transcript-viewer path/to/session.jsonl
20
+ ```
21
+
22
+ `uvx` fetches it, runs it, and leaves nothing behind. It is the honest way to
23
+ try a tool that is going to read your agent logs — have a look first, and keep
24
+ it only if it earns the disk.
25
+
26
+ To keep it:
27
27
 
28
28
  ```sh
29
29
  uv tool install transcript-viewer # pulls atif-make automatically
@@ -33,15 +33,23 @@ uv tool install "transcript-viewer[ai,parquet]" # + the optional Claude fea
33
33
 
34
34
  The extras are only needed for what they name: `parquet` for a dataset that
35
35
  ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
36
- viewer works without either.
36
+ viewer works without either. With `uvx`, ask for one with
37
+ `uvx --from "transcript-viewer[parquet]" transcript-viewer`.
37
38
 
38
39
  ```sh
39
40
  transcript-viewer # the library, empty on a first run
40
41
  transcript-viewer path/to/session.jsonl # one log
41
42
  transcript-viewer bundle.zip # a bundle someone sent you
42
43
  transcript-viewer --port 8080 --no-open
44
+ transcript-viewer sync s3://bucket/runs # take what is new, and only what is new
43
45
  ```
44
46
 
47
+ Everything is yours and stays here. It binds `127.0.0.1` and nothing else, the
48
+ library lives in `~/.transcript-viewer`, no account is involved, and nothing
49
+ leaves the machine unless you ask it to — a fetch you typed, or a summary you
50
+ pressed for with your own API key. There is no telemetry to turn off because
51
+ there is none to write.
52
+
45
53
  Nothing appears on a first run. Sessions arrive two ways:
46
54
 
47
55
  - **⟳ on the Local folder** finds what Claude Code, Codex and Copilot have
@@ -198,9 +206,33 @@ in the corpus this was built against were. Two bars in the same column of the
198
206
  chart is what says it; overlap gets no colour of its own, since a second
199
207
  encoding of the same fact is noise.
200
208
 
209
+ A subagent can delegate in turn, and in some harnesses that does not stop at two
210
+ levels. One that did gets a row to itself, labelled, with the number it spawned
211
+ and a control that opens them: its children appear beneath it, indented, packed
212
+ among themselves rather than against the rest of the run. The list does the same
213
+ with indented rows. Shut by default and remembered per subagent, so a run where
214
+ nothing nested — which is every run in the library this was built against — is
215
+ drawn exactly as it was before. The header says how deep the delegation went
216
+ only when it went further than one hop.
217
+
218
+ Under the chart is the shape of the delegation as numbers: every link in it
219
+ sorted into the three kinds it can be — from the main thread, from another
220
+ subagent, between subagents — to scale, with the counts and the ratio. A run is
221
+ not described by its subagent count alone; five spawned by the main thread is a
222
+ fan-out and five where two of them spawned the rest is a hierarchy, and the two
223
+ read identically until the links are counted. Each kind is counted rather than
224
+ assumed, so a format that starts recording one starts showing it here. Across
225
+ the library this was built against the ratio is 100 / 0 / 0 — 52 links in 17
226
+ transcripts, every one a call from the main thread — and 92 further subagents
227
+ that nothing links at all, which is the fact the line exists to surface. Beside
228
+ it: how many delegated in turn, the widest fan-out, and how many were spawned by
229
+ nothing.
230
+
201
231
  Clicking a lane opens the box the transcript already has for it rather than
202
232
  showing the same thing twice, drawing the run out far enough to reach it first:
203
- in one real session every delegation happens after step 1,231.
233
+ in one real session every delegation happens after step 1,231. A nested subagent
234
+ is drawn inside its orchestrator's box, so it is the orchestrator's step that
235
+ has to be painted and the boxes above it are unfolded on the way.
204
236
 
205
237
  A long run paints its first 250 steps at once and fills the rest in behind
206
238
  itself, so there is nothing to press and nothing to wait for. The largest run
@@ -311,17 +343,17 @@ which of the two is in use, and **Remove** clears the stored one.
311
343
 
312
344
  With no key configured, every AI control is hidden and the endpoint refuses.
313
345
 
314
- Each transcript also has its own **With AI support** switch. Turn it off and
315
- that session's controls disappear — useful when a transcript holds something that should not
316
- leave the machine. The server enforces it too, so a switched-off transcript is
317
- refused even if a request is made directly.
318
-
319
346
  ## Themes
320
347
 
321
- Three, from Diwan: `paper`, `cool` (both light) and `dark`. The control in the
322
- top bar cycles them, as Diwan's own header does, and the choice is remembered
323
- per browser. Note that "light and dark" is really three modes here — two of
324
- Diwan's palettes are light.
348
+ Three from Diwan — `paper`, `cool` (both light) and `dark` — and a pair,
349
+ `redwood` and `redwoodDark`, taken from Redwood Research's own design tokens for
350
+ transcripts that end up in a Redwood deck. Both come from that file as written:
351
+ the dark one is Redwood's own dark palette, not the light one dimmed, which is
352
+ why its green is `#43b184` where the light one's is `#1a7d5c`. The control in
353
+ the top bar cycles them, as Diwan's own header does, and the choice is
354
+ remembered per browser; a remembered name this build no longer has falls back to
355
+ `paper` rather than to an unstyled page. Note that "light and dark" is really
356
+ five modes here, three of them light.
325
357
 
326
358
  ## What it shows
327
359
 
@@ -419,44 +451,113 @@ an external file (`--split-subagents`) rather than an embedded trajectory, the
419
451
  viewer says so instead of silently showing nothing.
420
452
 
421
453
  Because branches sit anywhere in a trajectory that may run to thousands of
422
- steps, every session with branches gets a jump list at the top — agent type,
423
- task, step count — and an "only branches" filter. Steps render 250 at a time so
454
+ steps, there is an "only branches" filter. There was also a jump list above the
455
+ run, until the delegation box came to say everything it said and more — and to
456
+ disagree with it, since the list counted top-level calls while the box counts
457
+ every subagent at every depth. Steps render 250 at a time so
424
458
  a 8,000-step session stays responsive.
425
459
 
460
+ ## Running it for more than one person
461
+
462
+ Everything above is the whole tool. This section is for the one case it is not:
463
+ a copy of it serving a team rather than sitting on a laptop.
464
+
465
+ Nothing here is on by default, and a copy told nothing behaves exactly as the
466
+ one you have — loopback, nobody signed in, annotations in SQLite beside the
467
+ index. There is a test that walks every route asserting that, because it is what
468
+ keeps this one program rather than two.
469
+
470
+ A `deploy.toml` beside the library says the rest, and every setting can come
471
+ from the environment instead, which is what a container has:
472
+
473
+ ```toml
474
+ [server]
475
+ host = "0.0.0.0"
476
+
477
+ [identity]
478
+ # The header whatever stands in front puts the signed-in person in. Trusted
479
+ # only because it is named here — a header this was not told to expect is one
480
+ # anybody can send.
481
+ header = "x-amzn-oidc-identity"
482
+ admins = ["you@example.com"]
483
+
484
+ [storage]
485
+ database = "postgresql://…" # needs transcript-viewer[postgres]
486
+
487
+ [secrets]
488
+ mode = "server" # the operator's keys, not each person's
489
+
490
+ [ai]
491
+ enabled = "off" # never send a transcript to a model
492
+
493
+ [sources]
494
+ urls = ["s3://bucket/runs"] # what `transcript-viewer sync` follows
495
+ ```
496
+
497
+ The sign-in is not implemented here and should not be: a load balancer or a mesh
498
+ in front does it, and hands over one header. That is why the header must be
499
+ named — if the server can be reached around whatever authenticated the request,
500
+ anyone can claim to be anyone.
501
+
502
+ `postgres` is for this and only this. Annotations live in SQLite otherwise,
503
+ which is in the standard library and needs nothing installed — but two copies of
504
+ the server with two SQLite files means one person's stars exist on one of them
505
+ and not the other, which is a wrong answer rather than a slow one.
506
+
507
+ Labels are shared and stars are personal: a title, a tag or a note is the team's
508
+ one description of a transcript, and what you starred is yours.
509
+
426
510
  ## Developing alongside atif-make
427
511
 
428
- The two packages are developed together. Installing the viewer editable makes
429
- its own code live:
512
+ The two packages are developed together — a viewer feature usually needs the
513
+ parser to record the thing first — so `atif-make` resolves from the directory
514
+ next door rather than from the index:
430
515
 
431
- ```sh
432
- uv tool install --force --editable .
516
+ ```toml
517
+ [tool.uv.sources]
518
+ atif-make = { path = "../atif-make", editable = true }
433
519
  ```
434
520
 
435
- That alone still resolves `atif-make` from git, so edits to the converter would
436
- not show up. To run with **both** live, add it explicitly:
521
+ `uv run transcript-viewer` therefore runs both working copies, with no extra
522
+ flags. uv drops that declaration when the package is built, so what is published
523
+ still depends on the version range; a checkout without `atif-make` beside it can
524
+ pass `--no-sources`.
525
+
526
+ To make the installed command live too:
437
527
 
438
528
  ```sh
439
- uv run --with-editable ../atif-make transcript-viewer
529
+ uv tool install --force --editable .
440
530
  ```
441
531
 
442
- Use that while changing anything in `atif-make`. Reinstall from the index
443
- (`uv tool install --force transcript-viewer`) when you want to test what users actually
444
- get.
532
+ Reinstall from the index (`uv tool install --force transcript-viewer`) when you
533
+ want to test what users actually get.
534
+
535
+ **The page is re-read on every load; the converter is not.** `page.html` is
536
+ stat'ed per request, so editing it shows on a refresh. Python modules are
537
+ imported once and a converted trajectory is cached in memory for the life of the
538
+ run, so a parser change needs the server restarted. A fix that made a session's
539
+ delegation three levels deep looked like it had done nothing for exactly this
540
+ reason: the page was new, the parser in memory was not, and the viewer drew what
541
+ it was handed.
445
542
 
446
543
  ## Layout
447
544
 
448
545
  ```
449
546
  src/transcript_viewer/
450
- page.html the whole front end — 2,232 lines of HTML, CSS and JavaScript
547
+ page.html the whole interface: one page, no build step, no dependencies
451
548
  viewer.py the HTTP server: sixteen endpoints over the page and the library
452
549
  corpus.py the index — what is on this machine, and where it came from
453
550
  library.py what you decide about a session: title, tags, stars, summaries
551
+ db.py where those decisions are kept — SQLite, or PostgreSQL when shared
552
+ cache.py what has been converted lately, kept only while there is room
454
553
  fetch.py bringing transcripts in from Hugging Face, GitHub, S3 or a link
554
+ ingest.py what happens to them after: unpack, index, record where they came from
455
555
  ai.py the optional Claude-backed features
456
- config.py settings and tokens
556
+ config.py settings and tokens — what a person types in
557
+ deploy.py how this copy is set up — what an operator decided, before anyone arrived
558
+ identity.py who is asking, when anyone is
457
559
  store.py small JSON files under ~/.transcript-viewer, written so a crash cannot truncate them
458
560
  cli.py the command line
459
- page.html the whole interface: one page, no build step, no dependencies
460
561
  ```
461
562
 
462
563
  The page is a file rather than a string inside `viewer.py`, which is where it
@@ -499,8 +600,8 @@ trajectory pane stuck on "Converting…". Those checks live in
499
600
 
500
601
  The AI tests never call the API. Most stub the model call and check the part
501
602
  that matters when it is wrong — that nothing is sent unasked, that a summary is
502
- paid for once, that a stored key never reaches a response, and that a transcript
503
- switched off is refused by the server rather than merely hidden.
603
+ paid for once, that a stored key never reaches a response, and that the endpoint
604
+ refuses outright when no credential is configured.
504
605
 
505
606
  `tests/test_stream.py` is the exception: it drives the one function that does
506
607
  touch the SDK, using a fake client but the SDK's real exception classes, so a
@@ -0,0 +1,40 @@
1
+ # Deliberately not built yet
2
+
3
+ Each of these was considered while building something adjacent and left out on
4
+ purpose. The note says what is missing and what would make it worth doing, so a
5
+ later reader can tell a decision from an oversight.
6
+
7
+ ## Inter-agent communication, drawn as arrows
8
+
9
+ The delegation box counts the three kinds of link and shows the ratio
10
+ (`laneShape`). What it does not do is *draw* a sideways link — a subagent
11
+ addressing one that is not its own child would be an arrow between two bars on
12
+ the timeline, and there is nowhere for one to go in the current layout.
13
+
14
+ Worth building when a transcript has one. Across the whole library today the
15
+ ratio reads 100 / 0 / 0 — 52 links in 17 transcripts, all of them a call from
16
+ the main thread — so an arrow layer would have nothing to draw. The count is
17
+ computed rather than assumed, so the day a format records a sibling message the
18
+ number moves on its own and the arrows become the missing half.
19
+
20
+ ## Summarising what a subagent did
21
+
22
+ The delegation box says how many steps a subagent took, how long it ran, and
23
+ which tools it reached for. It does not say what it concluded. Doing that
24
+ honestly means either reading the final assistant turn — which is often a wall
25
+ of text — or generating a summary, which the AI features could do but which
26
+ would then have to be marked as generated everywhere it appears.
27
+
28
+ ## Fleet scale
29
+
30
+ The timeline is designed for the runs in the library: tens of subagents over
31
+ hours. Reports of runs with **115 agents over ~1,170 minutes** are the case it
32
+ is not designed for. Two things break first:
33
+
34
+ - packed rows stop compressing, because at that many concurrent agents almost
35
+ everything overlaps something;
36
+ - the 7px minimum bar width (`.bar{min-width:7px}`) stops being a floor and
37
+ starts being most of the chart.
38
+
39
+ Both point at the same missing thing: **zoom**, and a way to collapse a group of
40
+ lanes into one summary lane. Neither is a tweak to the current layout.
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "transcript-viewer"
3
+ version = "0.16.0"
4
+ description = "Browse agent transcripts in a local, dependency-free web viewer."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ # The only dependency is the converter. The viewer itself is stdlib http.server
9
+ # plus one self-contained HTML page.
10
+ # The floor is not politeness: this viewer imports atif_make.container, which
11
+ # is how a benchmark published as one JSON array of a thousand runs becomes a
12
+ # thousand sessions. An older atif-make fails to import at all.
13
+ # 0.8.0 is where a Claude Code session that delegated more than one level deep
14
+ # started converting to a tree. Against an older one the chart still draws, and
15
+ # draws a flat row of subagents that nothing spawned — which reads as the
16
+ # viewer being broken rather than the converter being old.
17
+ dependencies = ["atif-make>=0.8.0"]
18
+
19
+ [project.optional-dependencies]
20
+ # Claude-backed explanations are opt-in, so the default install stays
21
+ # dependency-free: pip install "transcript-viewer[ai]"
22
+ # Datasets published as Parquet — METR's transcripts are the one so far. The
23
+ # reader lives in atif-make, which only needs it to split a shard.
24
+ parquet = ["atif-make[parquet]>=0.8.0"]
25
+ ai = ["anthropic>=0.40"]
26
+ # Only for a deployment serving more than one person. Two copies of the server
27
+ # with two SQLite files means one person's stars exist on one and not the other,
28
+ # which is a wrong answer rather than a slow one.
29
+ postgres = ["psycopg[binary]>=3.1"]
30
+
31
+ [project.scripts]
32
+ transcript-viewer = "transcript_viewer.cli:main"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8.0"]
36
+
37
+ # Develop against the converter in the next directory, not the release.
38
+ #
39
+ # The two repos change together — a viewer feature usually needs the parser to
40
+ # record the thing first — and resolving `atif-make` from PyPI meant every such
41
+ # change looked like it had failed. A parser fix that made a session's
42
+ # delegation three levels deep was invisible here for exactly that reason: the
43
+ # page was local, the converter was 0.7.1, and the viewer faithfully drew what
44
+ # the old parser handed it.
45
+ #
46
+ # uv drops `tool.uv.sources` when this package is built, so what is published
47
+ # still depends on the version range above. A checkout without atif-make beside
48
+ # it can pass `--no-sources`.
49
+ [tool.uv.sources]
50
+ atif-make = { path = "../atif-make", editable = true }
51
+
52
+
53
+ [build-system]
54
+ requires = ["hatchling"]
55
+ build-backend = "hatchling.build"
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/transcript_viewer"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
@@ -17,7 +17,7 @@ import re
17
17
  from collections.abc import Iterator
18
18
  from typing import Any
19
19
 
20
- from . import config
20
+ from . import config, deploy
21
21
 
22
22
  MODEL = "claude-opus-5"
23
23
 
@@ -57,7 +57,14 @@ def status() -> tuple[bool, str]:
57
57
 
58
58
  A bare boolean was not enough: a saved key with no SDK installed looks
59
59
  exactly like no key at all, which reads as "the save didn't work".
60
+
61
+ A deployment that has turned this off is answered first and without
62
+ qualification. Everything below is about whether a call *could* be made;
63
+ this is about whether it may be, and the two must not be confused — a
64
+ refusal that reads like a missing dependency invites someone to install it.
60
65
  """
66
+ if deploy.current().ai != "on":
67
+ return False, "this viewer does not send transcripts to a model"
61
68
  try:
62
69
  _client()
63
70
  except Unavailable as exc: