Metadata-Version: 2.5
Name: transcript-viewer
Version: 0.15.0
Summary: Browse agent transcripts in a local, dependency-free web viewer.
License: MIT
Requires-Python: >=3.12
Requires-Dist: atif-make>=0.8.0
Provides-Extra: ai
Requires-Dist: anthropic>=0.40; extra == 'ai'
Provides-Extra: parquet
Requires-Dist: atif-make[parquet]>=0.8.0; extra == 'parquet'
Description-Content-Type: text/markdown

# transcript-viewer

Browse agent transcripts in a local web viewer — what Claude Code, Codex and
Copilot write as they work.

Logs are converted on the way in to
[ATIF v1.7](https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md)
by [`atif-make`](https://github.com/jammastergirish/atif-make), which this package depends on and which does
all of the reading. What is added here is only the browser interface, so a
format this cannot open is a parser missing from `atif-make` rather than
anything to change in the viewer.

## Install

```sh
uv tool install transcript-viewer                    # pulls atif-make automatically
uv tool install "transcript-viewer[parquet]"         # + datasets published as Parquet
uv tool install "transcript-viewer[ai,parquet]"      # + the optional Claude features
```

The extras are only needed for what they name: `parquet` for a dataset that
ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
viewer works without either.

```sh
transcript-viewer                        # the library, empty on a first run
transcript-viewer path/to/session.jsonl  # one log
transcript-viewer bundle.zip             # a bundle someone sent you
transcript-viewer --port 8080 --no-open
```

Nothing appears on a first run. Sessions arrive two ways:

- **⟳ on the Local folder** finds what Claude Code, Codex and Copilot have
  already written here. Deliberate rather than automatic: a first run should not
  index someone's entire history of every agent because a library happened to be
  empty. It is the same refresh remote folders get, in the same place.
- **Add…** in the top bar brings things in from elsewhere — a file, a folder or
  an archive from this machine, or a URL.

Nothing downloads on the first press. A link to a single file is confirmed and
fetched; anything larger opens a **picker** — the folder tree, one level at a
time, with sizes and checkboxes — so what gets downloaded is chosen rather than
accepted. Folders open as they are clicked rather than up front: the bucket this
was built against holds 118,801 objects, and listing all of them to draw a tree
would cost more than most of the downloads it exists to avoid. Ticking a folder
takes everything under it, and the total is measured rather than estimated: the
server lists inside what was ticked and answers with a real count and size, so
"180 files · 3.4 GB" is what it says, not "1+ files". **All** and **None** tick
the top level; **Open all** expands the tree when the shape of a place matters.

A couple of **examples** sit above the field and fill it in. The index is refreshed before anything
cosmetic happens, so a failure after the download cannot cost you the rows you
just paid for.

Files go to `./transcript-downloads/<owner--name>/` beside where the viewer was
launched — one place, not a choice to make each time — so a dataset you pull is
somewhere you can reach rather than buried in a dot-directory. Because those files are yours
rather than the viewer's, removing a session from the library forgets the entry
without deleting them.

What arrives keeps the shape it had where it came from — a fetch from
`…/attacks/model_priors` reads back as
`Remote/Hugging Face/sleightbench/SLEIGHT-Bench/attacks/model_priors` — because
a repository's own folders are the organisation its author chose, and two
hundred rows in a flat list are no use to anyone.

Any host may be fetched, but the name is resolved first and refused if it lands
on a loopback, link-local, private or reserved address — checked again after
redirects, since a redirect is a second request to a second host. That is not
caution for its own sake: the page hands a URL to a server running as you, on
your network, so an unguarded fetcher is a way to reach your router, a cloud
metadata endpoint, or something bound to localhost. Only Hugging Face and GitHub
are understood well enough to list a repository; every other host is a link to
one file.

`s3://bucket/prefix` works too, read through the **aws CLI** rather than a
library. Archives are fetched as readily as loose logs and unpacked on arrival — a
folder of them read off this machine works the same way — because a bucket of
agent runs is far more likely to hold one zip per session than bare JSONL — and what comes out of an archive is placed in the tree by
where the archive came from, not by whatever the archive calls its own folders. That is deliberate: the CLI already owns the SSO session, the profile
configuration and the refresh logic, so shelling out to it means this never
holds an AWS credential and never has to renew one. Sign in yourself —

```sh
aws sso login --profile rw-eng
```

— and the viewer reuses the session. Naming a profile is only needed when a
machine has several: with one configured, or `AWS_PROFILE` set, there is nothing
to disambiguate and nothing to type. Where there are several, Settings offers
them as a list read from the CLI rather than asking anyone to remember one. It never
runs the login: that is an interactive, browser-based act belonging to the person
at the keyboard. When there is no usable session it says so and gives the exact
command, because the CLI's own advice (`aws login`) is not the SSO one.

Arguments reach the CLI as a list with no shell, and the bucket, prefix and
profile are checked against strict patterns first — a profile called `--profile`
would otherwise be read as a flag.

Gated repositories need a token — see Settings below.

A first run opens empty. **Add…** brings transcripts in, and the **Local**
folder's ⟳ finds what this machine has already written; naming a path on the
command line views it without touching the library.

## The library

Sessions are organised by where they came from, and the tree is derived rather
than filed by hand:

```
Local                      Remote
  Claude Code                Hugging Face
    transcript-viewer                  sleightbench
    PaperParser                  SLEIGHT-Bench
  Codex                            attacks/model_priors/…
    …                        GitHub
```

Both sides have the same shape: what produced the session, then the unit of work
— a project on this machine, a repository on the web. There is no filing step
and nothing lands unfiled, because the tree reads a fact rather than a decision.
Collections were tried first and removed: a second way to organise laid over one
that already existed, which the two could disagree about. **Tags** remain for
grouping that cuts across the tree, and a session can still be renamed and
starred.

A remote node links back to the folder it mirrors.

**Look on this machine** in the Add dialog can be left running: tick *keep
watching* and it looks again every minute, so a session started after the viewer
opened turns up on its own. It runs only while the viewer is open — repeatedly
reading someone's whole working history is not a thing to leave going out of
sight — and a scan nobody asked for never redraws the table, since a minute
later is not a moment anyone chose to be interrupted. It says so quietly when
something new arrives.

Indexing has no cut-off: it takes whatever Claude Code and Codex still have on
disk, which is as far back as they keep it rather than a window this chooses.

Removing a node removes the sessions under it — a node cannot be deleted on its
own, since it describes where something came from rather than a choice anyone
made. Removing a session takes everything kept about it: title, tags, stars,
starred steps and any cached AI summaries, all of which live in one record.
Files are deleted only where they are the viewer's own copy under
`~/.transcript-viewer/opened/`; a session found on this machine, or downloaded into a folder
of yours, keeps its file.

Some datasets ship as a single file holding many runs — ATBench publishes a
thousand agent trajectories as one JSON array, METR's as Parquet shards
(install with `uv tool install "transcript-viewer[parquet]"` to read those),
and an Inspect `.eval` log holds one run per sample.

A fetch takes at most 32 GB at once. That is a backstop, not a judgement:
published corpora are routinely several gigabytes, and what actually guards
against an accident is the picker, which measures what you have ticked and
shows it before anything downloads. Those are opened rather than
listed: the file is split into a transcript apiece and each is indexed on its
own, so a download of one file becomes a thousand sessions you can read. The
pieces are kept beside what they came from, so removing the folder removes them
too.

This tool used to be called `atif-view` and kept all of that under `~/.atif`.
If that directory is still there, the first run moves it to
`~/.transcript-viewer` — index, annotations and stored keys together — and says
so. Nothing is left behind and nothing is copied twice.

A run that delegated shows what it farmed out, above its steps, as a
**timeline** or a **list**. The timeline leads: a row is shared by everything
that did not overlap, so a second row exists only because two things ran at
once. Thirty subagents in one session pack into eight rows, and a session of
fifteen packs into one, which is the honest picture of a run that never did two
things at a time.

The list is one chip away, and carries what a chart cannot: the task, the kind,
the steps, how long it took and the tools it reached for, sorted however you
ask. Whichever you pick is remembered. Either way a subagent is called what it was asked to
do — "Review backend for bugs and DRY" rather than "general-purpose" — wherever
that was written down, which is 50 of the 53 Claude Code subagents here; the
kind is the fallback, and reads as a handle rather than as prose so the
difference is visible. It exists for the one thing a nested box
cannot say — that two subagents were running at the same moment, which 25 pairs
in the corpus this was built against were. Two bars in the same column of the
chart is what says it; overlap gets no colour of its own, since a second
encoding of the same fact is noise.

A subagent can delegate in turn, and in some harnesses that does not stop at two
levels. One that did gets a row to itself, labelled, with the number it spawned
and a control that opens them: its children appear beneath it, indented, packed
among themselves rather than against the rest of the run. The list does the same
with indented rows. Shut by default and remembered per subagent, so a run where
nothing nested — which is every run in the library this was built against — is
drawn exactly as it was before. The header says how deep the delegation went
only when it went further than one hop.

Under the chart is the shape of the delegation as numbers: every link in it
sorted into the three kinds it can be — from the main thread, from another
subagent, between subagents — to scale, with the counts and the ratio. A run is
not described by its subagent count alone; five spawned by the main thread is a
fan-out and five where two of them spawned the rest is a hierarchy, and the two
read identically until the links are counted. Each kind is counted rather than
assumed, so a format that starts recording one starts showing it here. Across
the library this was built against the ratio is 100 / 0 / 0 — 52 links in 17
transcripts, every one a call from the main thread — and 92 further subagents
that nothing links at all, which is the fact the line exists to surface. Beside
it: how many delegated in turn, the widest fan-out, and how many were spawned by
nothing.

Clicking a lane opens the box the transcript already has for it rather than
showing the same thing twice, drawing the run out far enough to reach it first:
in one real session every delegation happens after step 1,231. A nested subagent
is drawn inside its orchestrator's box, so it is the orchestrator's step that
has to be painted and the boxes above it are unfolded on the way.

A long run paints its first 250 steps at once and fills the rest in behind
itself, so there is nothing to press and nothing to wait for. The largest run
here is 8,445 steps and 34 MB of markup, which is a second of a blank page if
it is drawn in one go, so the rest arrives in the gaps between frames and the
reader keeps their place.

Where a session spans days but holds hours of work — one runs 10.3 days and
contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
so the work is legible, and both the count and the total skipped are stated on
the axis. A short subagent keeps a clickable minimum width, so where a bar is at
that floor its width is not its duration; the card carries the real one. A
transcript with no timestamps lays the same lanes out by step instead and says
so, and one that delegated nothing shows no timeline at all.

A transcript has an address: opening one puts its key in the URL, so a refresh
comes back to it and a link can be sent to someone with the same transcript.
The key is the hash of the file's contents, so it is the same key on another
machine holding the same file, and back and forward move between the library
and a run.

**Clear library** above the tree forgets every session at once, after saying
how many and what it will touch. Files are left alone by the same rule as a
single removal — only copies the viewer made itself are deleted — and stored
tokens are settings rather than library, so they stay.

Claude Code names a session's directory by replacing every `/` in the working
directory with `-`, which cannot be undone by reading the string: `atif-make`
and `atif/make` escape identically. The path is rebuilt by asking the filesystem
which one exists, so a project appears under its real name.

## Asking Claude about a transcript

Two optional AI features, both off until you press something:

- **explain this call** — on any tool call, summarises what it tried to do and
  what came back. The summary is kept, so you pay for it once — and once it
  exists the call simply shows it, with no button to press.
- **Ask Claude** — a collapsible panel holding a conversation about the
  session. Most sessions go to the model whole; only one too large for the
  budget is sampled, and then by scoring each step against the question. Of 81
  sessions here, 58 are sent entire and 23 sampled.
  Follow-ups carry the earlier questions and answers, but not their step dumps:
  those are already digested into the answers, and replaying a page of
  transcript per turn would make a long conversation quadratic. An answer
  reports what it read — "read all 62 steps", or "read 40 of 312 steps" — so a
  partial view is visible rather than implied, and the step numbers it cites are
  links into the transcript.

When sampling is needed, steps are scored by how many of the question's words
they mention, longer words counting for more, with a long word also scoring at a
discount on its first four characters so "authentication" finds `test_auth.py`.
Candidates are trimmed to the budget in score order and then read back
chronologically, so what survives is the most useful rather than the earliest.
A question that matches nothing falls back to the steps the previous answer
read, or, on a first question, to the closing steps.

Both stream: text appears as it is written rather than after the call finishes.
The response is newline-delimited JSON read with `fetch`, not server-sent
events — `EventSource` reconnects when the connection closes, which would
silently repeat a paid call. The framing makes no difference to how finely
tokens arrive; SSE would stream exactly the same. `X-Content-Type-Options:
nosniff` is set, or the browser withholds the opening bytes while it sniffs the
type.

Measured, rather than assumed: over loopback the server delivers 202 of 202
frames individually at a 3.8 ms median gap, with `TCP_NODELAY` making no
difference either way, and building the HTML for a 30,000-character answer costs
under a millisecond. What remains is how coarsely the API itself emits text.

Thinking is reported separately from text. A model that thinks for twenty
seconds before its first word is indistinguishable from a hang, so the panel
says "Thinking…" while that is what is happening — the deliberation itself is
never sent to the page. A call summary skips thinking entirely: two sentences
about one tool call are delayed by it, not improved.

A conversation lives in the page, not the library — switching transcripts
starts a fresh one. Call summaries are cached and do persist.

Nothing is sent on load, on hover, or in the background. Every request is one
click, of yours.

```sh
uv tool install "transcript-viewer[ai]"    # brings in the anthropic SDK
```

The SDK and a credential are two separate requirements, and Settings names
whichever is missing — a key saved with no SDK installed still reports as saved,
rather than looking like the save failed. Running from a checkout, the extra is
not implied:

```sh
uv run --extra ai transcript-viewer
```

Then either export `ANTHROPIC_API_KEY`, or paste a key into **Settings** in the
top bar. Settings holds three credentials — an Anthropic key for the AI
features, and Hugging Face and GitHub tokens for **From URL…** — each with the
same treatment described here, and each falling back to its usual environment
variable (`HF_TOKEN`, `GITHUB_TOKEN`) when nothing is stored. A key set in Settings is stored at `~/.transcript-viewer/config.json`, mode `0600`
inside a `0700` directory; it is sent to the Anthropic API and nowhere else, and
is never read back into the page — the page only ever sees its last four
characters. With a key saved, the field shows dots and that tail, so it is
obvious at a glance that one is there; they are a placeholder rather than a
value, since a value could be submitted back and stored as the key. A key in your keychain or password manager is safer than one in a
file, so prefer the environment variable if you have the choice. Settings shows
which of the two is in use, and **Remove** clears the stored one.

With no key configured, every AI control is hidden and the endpoint refuses.

Each transcript also has its own **With AI support** switch. Turn it off and
that session's controls disappear — useful when a transcript holds something that should not
leave the machine. The server enforces it too, so a switched-off transcript is
refused even if a request is made directly.

## Themes

Three, from Diwan: `paper`, `cool` (both light) and `dark`. The control in the
top bar cycles them, as Diwan's own header does, and the choice is remembered
per browser. Note that "light and dark" is really three modes here — two of
Diwan's palettes are light.

## What it shows

```sh
transcript-viewer view              # everything in the index
transcript-viewer view path/to/log  # a single file
transcript-viewer view --port 8080 --no-open
```

**Stack**: Python's standard-library `http.server` and a single self-contained
HTML page — vanilla JS and CSS, no framework, no build step, no CDN. That is the
whole point of the zero-dependency rule: the viewer is one file you can read.

Binds `127.0.0.1` only — session logs routinely contain source code and tool
output, and must not be reachable off-host. Trajectories convert on demand and
cache in memory, so opening a large corpus is cheap.

**Links.** URLs and absolute filesystem paths are both linkified in one pass —
in prose, inside JSON argument values, and in tool output. Clicking a path
reveals it in Finder (`open -R`, which selects the item rather than launching
it, so clicking a path in a log can never execute anything); a path that no
longer exists is struck through instead. `file://` links cannot be used for this
because Chrome refuses to follow them from an http page, so the link calls back
to the local server. Path detection requires a plausible root (`~`, `/Users`,
`/opt`, …) so prose like "and/or", "3/4" and "2026/08/20" is left alone; inside
a JSON string the quotes bound the value, so paths with spaces work there.

Code spans and fenced blocks are linkified too. Standard Markdown leaves them
literal, but in agent transcripts a path or URL is usually written in backticks
— on one real corpus, 131 of 181 linkable targets sat inside code, so honouring
the convention would have hidden most of them.

**Markdown.** Agent messages are written in Markdown — headings, lists, code
fences, tables — so the viewer renders them as such. The renderer is ~60 lines
of vanilla JS inlined in the page: it escapes the source *first* and only then
applies transforms, so no markup from a log can reach the DOM, and only
`http(s)` links become anchors. A `raw text` toggle shows the unrendered string
when you need to see exactly what the model emitted.

Images are served from memory at `/api/image` and rendered inline, so a session
with screenshots is browsable without writing anything to disk.

**Opening things.** `Open…` in the sidebar takes a normal file dialog, and
files can be dropped anywhere on the window. Either way the upload goes to the
same `corpus.scan()` the CLI uses, so the button and `transcript-viewer <path>` can
never disagree about what counts as openable — logs, converted trajectories and
archives all work. A client-supplied filename is reduced to a leaf before
anything is written, and uploads live in a temporary directory for the session.

**Duration.** Each trajectory reports how long it actually ran, from the first
step's timestamp to the last, in whatever unit fits — these range from seconds
to `63h` across two and a half days.

**Favourites.** Star individual steps inside a transcript — the star sits in
the gutter beside the step number, visible at rest rather than on hover — and the `Favourited` lens filters to them.
Stars are keyed the same way the step anchors are, so one set inside a subagent
(whose ids restart at 1) cannot land on the wrong step. Rename a transcript from
its own heading by double-clicking it, or from the table; both write the same
record.

**Expand all.** Tool calls and branches open collapsed so a long transcript is
readable; one control opens or closes every one of them. It acts on what is
already on screen rather than repainting, so you keep your place.

**Finding things.** A run of several thousand steps needs more than scrolling.
`Search this run` matches across message text, reasoning, tool names, tool
arguments and observation output — the things a reader can actually see. Filter
lenses (`All / User / Agent / System / Tools / Reasoning / Branches`) carry live
counts for the whole run, so you can tell at a glance that a session is 7,628
tool turns and 451 user messages before filtering to any of them.

**Provenance and sources.** Three tabs: `Trajectory` renders the run;
`Raw` shows the head of the original log, so you can see what was converted
rather than trusting the conversion; `Files` lists everything that travelled
with the session — subagent traces, sidecar manifests, bundled images — each
revealable in the file manager. A details strip records the schema version,
detected source format, model, session id and transcript size.

**Reading a trajectory.** Every step is a tinted card — one colour per role, so
user, agent and system turns are distinguishable without reading labels — with
its ATIF `step_id` in the gutter to the left of the timeline. The number is also
an anchor, so you can link someone to a specific step. Subagent steps are
numbered independently (ATIF restarts them at 1) and scoped so their anchors
cannot collide with the parent's. Tool-call arguments are syntax-coloured, and
tool output is coloured only when it really parses as JSON, so ordinary command
output is left as plain text. The session list collapses with the button at the
top left, or the `\\` key.

**Branching.** A delegated subagent is a complete trajectory in its own right, so
the viewer renders it as one: collapsed under the tool call that spawned it,
labelled with the agent type, its task, and its step count. Expanding it shows
that agent's own steps — and because ATIF nests arbitrarily deep, a subagent that
delegates further renders the same way, with depth marked. Where a ref points at
an external file (`--split-subagents`) rather than an embedded trajectory, the
viewer says so instead of silently showing nothing.

Because branches sit anywhere in a trajectory that may run to thousands of
steps, there is an "only branches" filter. There was also a jump list above the
run, until the delegation box came to say everything it said and more — and to
disagree with it, since the list counted top-level calls while the box counts
every subagent at every depth. Steps render 250 at a time so
a 8,000-step session stays responsive.

## Developing alongside atif-make

The two packages are developed together — a viewer feature usually needs the
parser to record the thing first — so `atif-make` resolves from the directory
next door rather than from the index:

```toml
[tool.uv.sources]
atif-make = { path = "../atif-make", editable = true }
```

`uv run transcript-viewer` therefore runs both working copies, with no extra
flags. uv drops that declaration when the package is built, so what is published
still depends on the version range; a checkout without `atif-make` beside it can
pass `--no-sources`.

To make the installed command live too:

```sh
uv tool install --force --editable .
```

Reinstall from the index (`uv tool install --force transcript-viewer`) when you
want to test what users actually get.

**The page is re-read on every load; the converter is not.** `page.html` is
stat'ed per request, so editing it shows on a refresh. Python modules are
imported once and a converted trajectory is cached in memory for the life of the
run, so a parser change needs the server restarted. A fix that made a session's
delegation three levels deep looked like it had done nothing for exactly this
reason: the page was new, the parser in memory was not, and the viewer drew what
it was handed.

## Layout

```
src/transcript_viewer/
  page.html    the whole front end — 2,232 lines of HTML, CSS and JavaScript
  viewer.py    the HTTP server: sixteen endpoints over the page and the library
  corpus.py    the index — what is on this machine, and where it came from
  library.py   what you decide about a session: title, tags, stars, summaries
  fetch.py     bringing transcripts in from Hugging Face, GitHub, S3 or a link
  ai.py        the optional Claude-backed features
  config.py    settings and tokens
  store.py     small JSON files under ~/.transcript-viewer, written so a crash cannot truncate them
  cli.py       the command line
  page.html    the whole interface: one page, no build step, no dependencies
```

The page is a file rather than a string inside `viewer.py`, which is where it
used to live. Two thirds of that module was CSS and JavaScript typed as though
it were Python — a template expression once shipped inside static HTML and
rendered its own source, which is harder to miss in a file that knows what it
is. It is re-read whenever it changes, so editing the interface and reloading
shows the edit — twice it did not, and read as an edit that had failed. The
cost is a stat per page load, and none on what the page then calls; for an
installed copy the file never changes and the stat always says so. The wheel
carries it.

## Tests

```sh
uv run pytest
```

While changing `atif-make` at the same time, run against the local converter or
the tests will resolve the published one:

```sh
uv run --with-editable ../atif-make pytest
```

Add `--extra ai` to either command to exercise the AI paths against a real SDK;
the tests stub the model call, so this never contacts the API. `--extra parquet`
does the same for the Parquet path, which is otherwise skipped.

The suite starts a real server on an ephemeral port and exercises the endpoints,
including that it binds loopback only. It is isolated from your own library,
index, settings and opened-file store — a frozen default argument once let it
write to them, so there is a test for that too.

Most of the viewer's behaviour is browser JavaScript, which pytest cannot
reach — two real breaks shipped that way, a row click that did nothing and a
trajectory pane stuck on "Converting…". Those checks live in
`tests/page.test.js`, load the real page script against a stub DOM, and run from
`tests/test_page.py` as part of the same suite (skipped without node).

The AI tests never call the API. Most stub the model call and check the part
that matters when it is wrong — that nothing is sent unasked, that a summary is
paid for once, that a stored key never reaches a response, and that a transcript
switched off is refused by the server rather than merely hidden.

`tests/test_stream.py` is the exception: it drives the one function that does
touch the SDK, using a fake client but the SDK's real exception classes, so a
rename or re-parenting fails here instead of on someone's first paid call. It
found one already — the SDK moved from `httpx` to `httpx2` at 1.0. CI installs
the extra and fails on any skipped test, since a silently skipped test is worse
than no test.

`tests/test_readme.py` checks this file against the code, pairing each claim
with the marker that makes it true. Prose drifts quietly: a documented behaviour
outlived the code twice here, once because an edit matched nothing and reported
success anyway.
