transcript-viewer 0.5.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.
- transcript_viewer-0.5.0/.coverage +0 -0
- transcript_viewer-0.5.0/.github/workflows/publish.yml +35 -0
- transcript_viewer-0.5.0/.github/workflows/test.yml +33 -0
- transcript_viewer-0.5.0/.gitignore +11 -0
- transcript_viewer-0.5.0/PKG-INFO +440 -0
- transcript_viewer-0.5.0/README.md +429 -0
- transcript_viewer-0.5.0/pyproject.toml +45 -0
- transcript_viewer-0.5.0/src/transcript_viewer/__init__.py +5 -0
- transcript_viewer-0.5.0/src/transcript_viewer/ai.py +374 -0
- transcript_viewer-0.5.0/src/transcript_viewer/cli.py +87 -0
- transcript_viewer-0.5.0/src/transcript_viewer/config.py +193 -0
- transcript_viewer-0.5.0/src/transcript_viewer/corpus.py +340 -0
- transcript_viewer-0.5.0/src/transcript_viewer/fetch.py +771 -0
- transcript_viewer-0.5.0/src/transcript_viewer/library.py +169 -0
- transcript_viewer-0.5.0/src/transcript_viewer/page.html +2248 -0
- transcript_viewer-0.5.0/src/transcript_viewer/store.py +83 -0
- transcript_viewer-0.5.0/src/transcript_viewer/viewer.py +1000 -0
- transcript_viewer-0.5.0/tests/page.test.js +1407 -0
- transcript_viewer-0.5.0/tests/test_ai.py +385 -0
- transcript_viewer-0.5.0/tests/test_cli.py +112 -0
- transcript_viewer-0.5.0/tests/test_config.py +183 -0
- transcript_viewer-0.5.0/tests/test_corpus.py +435 -0
- transcript_viewer-0.5.0/tests/test_fetch.py +782 -0
- transcript_viewer-0.5.0/tests/test_library.py +144 -0
- transcript_viewer-0.5.0/tests/test_page.py +24 -0
- transcript_viewer-0.5.0/tests/test_readme.py +140 -0
- transcript_viewer-0.5.0/tests/test_store.py +49 -0
- transcript_viewer-0.5.0/tests/test_stream.py +206 -0
- transcript_viewer-0.5.0/tests/test_style.py +112 -0
- transcript_viewer-0.5.0/tests/test_tidiness.py +81 -0
- transcript_viewer-0.5.0/tests/test_viewer.py +913 -0
- transcript_viewer-0.5.0/uv.lock +400 -0
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI when a version tag is pushed:
|
|
4
|
+
#
|
|
5
|
+
# git tag v0.1.0 && git push origin v0.1.0
|
|
6
|
+
#
|
|
7
|
+
# Authentication uses PyPI Trusted Publishing (OIDC), so no API token is stored
|
|
8
|
+
# anywhere. GitHub mints a short-lived identity for this workflow and PyPI
|
|
9
|
+
# verifies it against the publisher configured for the project.
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
tags: ["v*"]
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
environment: pypi
|
|
18
|
+
permissions:
|
|
19
|
+
# Required for trusted publishing; nothing else needs write access.
|
|
20
|
+
id-token: write
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: astral-sh/setup-uv@v5
|
|
24
|
+
|
|
25
|
+
- name: Refuse to publish a tag that disagrees with the version
|
|
26
|
+
run: |
|
|
27
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
28
|
+
pkg=$(uv version --short)
|
|
29
|
+
if [ "$tag" != "$pkg" ]; then
|
|
30
|
+
echo "tag $tag does not match project version $pkg" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
- run: uv build
|
|
35
|
+
- run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
with:
|
|
19
|
+
enable-cache: true
|
|
20
|
+
# The page's behaviour is JavaScript; its tests run under node.
|
|
21
|
+
- uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: "22"
|
|
24
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
25
|
+
# With the extra: the tests that drive the SDK boundary skip without it,
|
|
26
|
+
# and a silently skipped test is worse than no test.
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: uv run --python ${{ matrix.python-version }} --extra ai pytest -q
|
|
29
|
+
- name: Fail if any test was skipped
|
|
30
|
+
run: |
|
|
31
|
+
uv run --python ${{ matrix.python-version }} --extra ai pytest -q -rs \
|
|
32
|
+
| tee /tmp/out
|
|
33
|
+
! grep -q "skipped" /tmp/out
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
# Never commit converted transcripts — they contain real session content.
|
|
6
|
+
*.trajectory.json
|
|
7
|
+
dist/
|
|
8
|
+
|
|
9
|
+
# Where "Add… from a URL" puts what it fetches. Someone else's transcripts,
|
|
10
|
+
# and not ours to commit.
|
|
11
|
+
transcript-downloads/
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: transcript-viewer
|
|
3
|
+
Version: 0.5.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.2.0
|
|
8
|
+
Provides-Extra: ai
|
|
9
|
+
Requires-Dist: anthropic>=0.40; extra == 'ai'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# transcript-viewer
|
|
13
|
+
|
|
14
|
+
Browse [ATIF v1.7](https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md)
|
|
15
|
+
trajectories in a local web viewer.
|
|
16
|
+
|
|
17
|
+
Conversion lives in [`atif-make`](https://github.com/jammastergirish/atif-make); this package depends on it and
|
|
18
|
+
adds only the browser interface.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv tool install transcript-viewer # pulls atif-make automatically
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
transcript-viewer # the library, empty on a first run
|
|
28
|
+
transcript-viewer path/to/session.jsonl # one log
|
|
29
|
+
transcript-viewer bundle.zip # a bundle someone sent you
|
|
30
|
+
transcript-viewer --port 8080 --no-open
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Nothing appears on a first run. Sessions arrive two ways:
|
|
34
|
+
|
|
35
|
+
- **⟳ on the Local folder** finds what Claude Code, Codex and Copilot have
|
|
36
|
+
already written here. Deliberate rather than automatic: a first run should not
|
|
37
|
+
index someone's entire history of every agent because a library happened to be
|
|
38
|
+
empty. It is the same refresh remote folders get, in the same place.
|
|
39
|
+
- **Add…** in the top bar brings things in from elsewhere — a file, a folder or
|
|
40
|
+
an archive from this machine, or a URL.
|
|
41
|
+
|
|
42
|
+
Nothing downloads on the first press. A link to a single file is confirmed and
|
|
43
|
+
fetched; anything larger opens a **picker** — the folder tree, one level at a
|
|
44
|
+
time, with sizes and checkboxes — so what gets downloaded is chosen rather than
|
|
45
|
+
accepted. Folders open as they are clicked rather than up front: the bucket this
|
|
46
|
+
was built against holds 118,801 objects, and listing all of them to draw a tree
|
|
47
|
+
would cost more than most of the downloads it exists to avoid. Ticking a folder
|
|
48
|
+
takes everything under it, and the total is measured rather than estimated: the
|
|
49
|
+
server lists inside what was ticked and answers with a real count and size, so
|
|
50
|
+
"180 files · 3.4 GB" is what it says, not "1+ files". **All** and **None** tick
|
|
51
|
+
the top level; **Open all** expands the tree when the shape of a place matters.
|
|
52
|
+
|
|
53
|
+
A couple of **examples** sit above the field and fill it in. The index is refreshed before anything
|
|
54
|
+
cosmetic happens, so a failure after the download cannot cost you the rows you
|
|
55
|
+
just paid for.
|
|
56
|
+
|
|
57
|
+
Files go to `./transcript-downloads/<owner--name>/` beside where the viewer was
|
|
58
|
+
launched — one place, not a choice to make each time — so a dataset you pull is
|
|
59
|
+
somewhere you can reach rather than buried in a dot-directory. Because those files are yours
|
|
60
|
+
rather than the viewer's, removing a session from the library forgets the entry
|
|
61
|
+
without deleting them.
|
|
62
|
+
|
|
63
|
+
What arrives keeps the shape it had where it came from — a fetch from
|
|
64
|
+
`…/attacks/model_priors` reads back as
|
|
65
|
+
`Remote/Hugging Face/sleightbench/SLEIGHT-Bench/attacks/model_priors` — because
|
|
66
|
+
a repository's own folders are the organisation its author chose, and two
|
|
67
|
+
hundred rows in a flat list are no use to anyone.
|
|
68
|
+
|
|
69
|
+
Any host may be fetched, but the name is resolved first and refused if it lands
|
|
70
|
+
on a loopback, link-local, private or reserved address — checked again after
|
|
71
|
+
redirects, since a redirect is a second request to a second host. That is not
|
|
72
|
+
caution for its own sake: the page hands a URL to a server running as you, on
|
|
73
|
+
your network, so an unguarded fetcher is a way to reach your router, a cloud
|
|
74
|
+
metadata endpoint, or something bound to localhost. Only Hugging Face and GitHub
|
|
75
|
+
are understood well enough to list a repository; every other host is a link to
|
|
76
|
+
one file.
|
|
77
|
+
|
|
78
|
+
`s3://bucket/prefix` works too, read through the **aws CLI** rather than a
|
|
79
|
+
library. Archives are fetched as readily as loose logs and unpacked on arrival — a
|
|
80
|
+
folder of them read off this machine works the same way — because a bucket of
|
|
81
|
+
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
|
|
82
|
+
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
|
|
83
|
+
configuration and the refresh logic, so shelling out to it means this never
|
|
84
|
+
holds an AWS credential and never has to renew one. Sign in yourself —
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
aws sso login --profile rw-eng
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
— and the viewer reuses the session. Naming a profile is only needed when a
|
|
91
|
+
machine has several: with one configured, or `AWS_PROFILE` set, there is nothing
|
|
92
|
+
to disambiguate and nothing to type. Where there are several, Settings offers
|
|
93
|
+
them as a list read from the CLI rather than asking anyone to remember one. It never
|
|
94
|
+
runs the login: that is an interactive, browser-based act belonging to the person
|
|
95
|
+
at the keyboard. When there is no usable session it says so and gives the exact
|
|
96
|
+
command, because the CLI's own advice (`aws login`) is not the SSO one.
|
|
97
|
+
|
|
98
|
+
Arguments reach the CLI as a list with no shell, and the bucket, prefix and
|
|
99
|
+
profile are checked against strict patterns first — a profile called `--profile`
|
|
100
|
+
would otherwise be read as a flag.
|
|
101
|
+
|
|
102
|
+
Gated repositories need a token — see Settings below.
|
|
103
|
+
|
|
104
|
+
A first run opens empty. **Add…** brings transcripts in, and the **Local**
|
|
105
|
+
folder's ⟳ finds what this machine has already written; naming a path on the
|
|
106
|
+
command line views it without touching the library.
|
|
107
|
+
|
|
108
|
+
## The library
|
|
109
|
+
|
|
110
|
+
Sessions are organised by where they came from, and the tree is derived rather
|
|
111
|
+
than filed by hand:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Local Remote
|
|
115
|
+
Claude Code Hugging Face
|
|
116
|
+
transcript-viewer sleightbench
|
|
117
|
+
PaperParser SLEIGHT-Bench
|
|
118
|
+
Codex attacks/model_priors/…
|
|
119
|
+
… GitHub
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Both sides have the same shape: what produced the session, then the unit of work
|
|
123
|
+
— a project on this machine, a repository on the web. There is no filing step
|
|
124
|
+
and nothing lands unfiled, because the tree reads a fact rather than a decision.
|
|
125
|
+
Collections were tried first and removed: a second way to organise laid over one
|
|
126
|
+
that already existed, which the two could disagree about. **Tags** remain for
|
|
127
|
+
grouping that cuts across the tree, and a session can still be renamed and
|
|
128
|
+
starred.
|
|
129
|
+
|
|
130
|
+
A remote node links back to the folder it mirrors.
|
|
131
|
+
|
|
132
|
+
**Look on this machine** in the Add dialog can be left running: tick *keep
|
|
133
|
+
watching* and it looks again every minute, so a session started after the viewer
|
|
134
|
+
opened turns up on its own. It runs only while the viewer is open — repeatedly
|
|
135
|
+
reading someone's whole working history is not a thing to leave going out of
|
|
136
|
+
sight — and a scan nobody asked for never redraws the table, since a minute
|
|
137
|
+
later is not a moment anyone chose to be interrupted. It says so quietly when
|
|
138
|
+
something new arrives.
|
|
139
|
+
|
|
140
|
+
Indexing has no cut-off: it takes whatever Claude Code and Codex still have on
|
|
141
|
+
disk, which is as far back as they keep it rather than a window this chooses.
|
|
142
|
+
|
|
143
|
+
Removing a node removes the sessions under it — a node cannot be deleted on its
|
|
144
|
+
own, since it describes where something came from rather than a choice anyone
|
|
145
|
+
made. Removing a session takes everything kept about it: title, tags, stars,
|
|
146
|
+
starred steps and any cached AI summaries, all of which live in one record.
|
|
147
|
+
Files are deleted only where they are the viewer's own copy under
|
|
148
|
+
`~/.transcript-viewer/opened/`; a session found on this machine, or downloaded into a folder
|
|
149
|
+
of yours, keeps its file.
|
|
150
|
+
|
|
151
|
+
This tool used to be called `atif-view` and kept all of that under `~/.atif`.
|
|
152
|
+
If that directory is still there, the first run moves it to
|
|
153
|
+
`~/.transcript-viewer` — index, annotations and stored keys together — and says
|
|
154
|
+
so. Nothing is left behind and nothing is copied twice.
|
|
155
|
+
|
|
156
|
+
**Clear library** above the tree forgets every session at once, after saying
|
|
157
|
+
how many and what it will touch. Files are left alone by the same rule as a
|
|
158
|
+
single removal — only copies the viewer made itself are deleted — and stored
|
|
159
|
+
tokens are settings rather than library, so they stay.
|
|
160
|
+
|
|
161
|
+
Claude Code names a session's directory by replacing every `/` in the working
|
|
162
|
+
directory with `-`, which cannot be undone by reading the string: `atif-make`
|
|
163
|
+
and `atif/make` escape identically. The path is rebuilt by asking the filesystem
|
|
164
|
+
which one exists, so a project appears under its real name.
|
|
165
|
+
|
|
166
|
+
## Asking Claude about a transcript
|
|
167
|
+
|
|
168
|
+
Two optional AI features, both off until you press something:
|
|
169
|
+
|
|
170
|
+
- **explain this call** — on any tool call, summarises what it tried to do and
|
|
171
|
+
what came back. The summary is kept, so you pay for it once — and once it
|
|
172
|
+
exists the call simply shows it, with no button to press.
|
|
173
|
+
- **Ask Claude** — a collapsible panel holding a conversation about the
|
|
174
|
+
session. Most sessions go to the model whole; only one too large for the
|
|
175
|
+
budget is sampled, and then by scoring each step against the question. Of 81
|
|
176
|
+
sessions here, 58 are sent entire and 23 sampled.
|
|
177
|
+
Follow-ups carry the earlier questions and answers, but not their step dumps:
|
|
178
|
+
those are already digested into the answers, and replaying a page of
|
|
179
|
+
transcript per turn would make a long conversation quadratic. An answer
|
|
180
|
+
reports what it read — "read all 62 steps", or "read 40 of 312 steps" — so a
|
|
181
|
+
partial view is visible rather than implied, and the step numbers it cites are
|
|
182
|
+
links into the transcript.
|
|
183
|
+
|
|
184
|
+
When sampling is needed, steps are scored by how many of the question's words
|
|
185
|
+
they mention, longer words counting for more, with a long word also scoring at a
|
|
186
|
+
discount on its first four characters so "authentication" finds `test_auth.py`.
|
|
187
|
+
Candidates are trimmed to the budget in score order and then read back
|
|
188
|
+
chronologically, so what survives is the most useful rather than the earliest.
|
|
189
|
+
A question that matches nothing falls back to the steps the previous answer
|
|
190
|
+
read, or, on a first question, to the closing steps.
|
|
191
|
+
|
|
192
|
+
Both stream: text appears as it is written rather than after the call finishes.
|
|
193
|
+
The response is newline-delimited JSON read with `fetch`, not server-sent
|
|
194
|
+
events — `EventSource` reconnects when the connection closes, which would
|
|
195
|
+
silently repeat a paid call. The framing makes no difference to how finely
|
|
196
|
+
tokens arrive; SSE would stream exactly the same. `X-Content-Type-Options:
|
|
197
|
+
nosniff` is set, or the browser withholds the opening bytes while it sniffs the
|
|
198
|
+
type.
|
|
199
|
+
|
|
200
|
+
Measured, rather than assumed: over loopback the server delivers 202 of 202
|
|
201
|
+
frames individually at a 3.8 ms median gap, with `TCP_NODELAY` making no
|
|
202
|
+
difference either way, and building the HTML for a 30,000-character answer costs
|
|
203
|
+
under a millisecond. What remains is how coarsely the API itself emits text.
|
|
204
|
+
|
|
205
|
+
Thinking is reported separately from text. A model that thinks for twenty
|
|
206
|
+
seconds before its first word is indistinguishable from a hang, so the panel
|
|
207
|
+
says "Thinking…" while that is what is happening — the deliberation itself is
|
|
208
|
+
never sent to the page. A call summary skips thinking entirely: two sentences
|
|
209
|
+
about one tool call are delayed by it, not improved.
|
|
210
|
+
|
|
211
|
+
A conversation lives in the page, not the library — switching transcripts
|
|
212
|
+
starts a fresh one. Call summaries are cached and do persist.
|
|
213
|
+
|
|
214
|
+
Nothing is sent on load, on hover, or in the background. Every request is one
|
|
215
|
+
click, of yours.
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
uv tool install "transcript-viewer[ai]" # brings in the anthropic SDK
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The SDK and a credential are two separate requirements, and Settings names
|
|
222
|
+
whichever is missing — a key saved with no SDK installed still reports as saved,
|
|
223
|
+
rather than looking like the save failed. Running from a checkout, the extra is
|
|
224
|
+
not implied:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
uv run --extra ai transcript-viewer
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Then either export `ANTHROPIC_API_KEY`, or paste a key into **Settings** in the
|
|
231
|
+
top bar. Settings holds three credentials — an Anthropic key for the AI
|
|
232
|
+
features, and Hugging Face and GitHub tokens for **From URL…** — each with the
|
|
233
|
+
same treatment described here, and each falling back to its usual environment
|
|
234
|
+
variable (`HF_TOKEN`, `GITHUB_TOKEN`) when nothing is stored. A key set in Settings is stored at `~/.transcript-viewer/config.json`, mode `0600`
|
|
235
|
+
inside a `0700` directory; it is sent to the Anthropic API and nowhere else, and
|
|
236
|
+
is never read back into the page — the page only ever sees its last four
|
|
237
|
+
characters. With a key saved, the field shows dots and that tail, so it is
|
|
238
|
+
obvious at a glance that one is there; they are a placeholder rather than a
|
|
239
|
+
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
|
|
240
|
+
file, so prefer the environment variable if you have the choice. Settings shows
|
|
241
|
+
which of the two is in use, and **Remove** clears the stored one.
|
|
242
|
+
|
|
243
|
+
With no key configured, every AI control is hidden and the endpoint refuses.
|
|
244
|
+
|
|
245
|
+
Each transcript also has its own **With AI support** switch. Turn it off and
|
|
246
|
+
that session's controls disappear — useful when a transcript holds something that should not
|
|
247
|
+
leave the machine. The server enforces it too, so a switched-off transcript is
|
|
248
|
+
refused even if a request is made directly.
|
|
249
|
+
|
|
250
|
+
## Themes
|
|
251
|
+
|
|
252
|
+
Three, from Diwan: `paper`, `cool` (both light) and `dark`. The control in the
|
|
253
|
+
top bar cycles them, as Diwan's own header does, and the choice is remembered
|
|
254
|
+
per browser. Note that "light and dark" is really three modes here — two of
|
|
255
|
+
Diwan's palettes are light.
|
|
256
|
+
|
|
257
|
+
## What it shows
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
transcript-viewer view # everything in the index
|
|
261
|
+
transcript-viewer view path/to/log # a single file
|
|
262
|
+
transcript-viewer view --port 8080 --no-open
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Stack**: Python's standard-library `http.server` and a single self-contained
|
|
266
|
+
HTML page — vanilla JS and CSS, no framework, no build step, no CDN. That is the
|
|
267
|
+
whole point of the zero-dependency rule: the viewer is one file you can read.
|
|
268
|
+
|
|
269
|
+
Binds `127.0.0.1` only — session logs routinely contain source code and tool
|
|
270
|
+
output, and must not be reachable off-host. Trajectories convert on demand and
|
|
271
|
+
cache in memory, so opening a large corpus is cheap.
|
|
272
|
+
|
|
273
|
+
**Links.** URLs and absolute filesystem paths are both linkified in one pass —
|
|
274
|
+
in prose, inside JSON argument values, and in tool output. Clicking a path
|
|
275
|
+
reveals it in Finder (`open -R`, which selects the item rather than launching
|
|
276
|
+
it, so clicking a path in a log can never execute anything); a path that no
|
|
277
|
+
longer exists is struck through instead. `file://` links cannot be used for this
|
|
278
|
+
because Chrome refuses to follow them from an http page, so the link calls back
|
|
279
|
+
to the local server. Path detection requires a plausible root (`~`, `/Users`,
|
|
280
|
+
`/opt`, …) so prose like "and/or", "3/4" and "2026/08/20" is left alone; inside
|
|
281
|
+
a JSON string the quotes bound the value, so paths with spaces work there.
|
|
282
|
+
|
|
283
|
+
Code spans and fenced blocks are linkified too. Standard Markdown leaves them
|
|
284
|
+
literal, but in agent transcripts a path or URL is usually written in backticks
|
|
285
|
+
— on one real corpus, 131 of 181 linkable targets sat inside code, so honouring
|
|
286
|
+
the convention would have hidden most of them.
|
|
287
|
+
|
|
288
|
+
**Markdown.** Agent messages are written in Markdown — headings, lists, code
|
|
289
|
+
fences, tables — so the viewer renders them as such. The renderer is ~60 lines
|
|
290
|
+
of vanilla JS inlined in the page: it escapes the source *first* and only then
|
|
291
|
+
applies transforms, so no markup from a log can reach the DOM, and only
|
|
292
|
+
`http(s)` links become anchors. A `raw text` toggle shows the unrendered string
|
|
293
|
+
when you need to see exactly what the model emitted.
|
|
294
|
+
|
|
295
|
+
Images are served from memory at `/api/image` and rendered inline, so a session
|
|
296
|
+
with screenshots is browsable without writing anything to disk.
|
|
297
|
+
|
|
298
|
+
**Opening things.** `Open…` in the sidebar takes a normal file dialog, and
|
|
299
|
+
files can be dropped anywhere on the window. Either way the upload goes to the
|
|
300
|
+
same `corpus.scan()` the CLI uses, so the button and `transcript-viewer <path>` can
|
|
301
|
+
never disagree about what counts as openable — logs, converted trajectories and
|
|
302
|
+
archives all work. A client-supplied filename is reduced to a leaf before
|
|
303
|
+
anything is written, and uploads live in a temporary directory for the session.
|
|
304
|
+
|
|
305
|
+
**Duration.** Each trajectory reports how long it actually ran, from the first
|
|
306
|
+
step's timestamp to the last, in whatever unit fits — these range from seconds
|
|
307
|
+
to `63h` across two and a half days.
|
|
308
|
+
|
|
309
|
+
**Favourites.** Star individual steps inside a transcript — the star sits in
|
|
310
|
+
the gutter beside the step number, visible at rest rather than on hover — and the `Favourited` lens filters to them.
|
|
311
|
+
Stars are keyed the same way the step anchors are, so one set inside a subagent
|
|
312
|
+
(whose ids restart at 1) cannot land on the wrong step. Rename a transcript from
|
|
313
|
+
its own heading by double-clicking it, or from the table; both write the same
|
|
314
|
+
record.
|
|
315
|
+
|
|
316
|
+
**Expand all.** Tool calls and branches open collapsed so a long transcript is
|
|
317
|
+
readable; one control opens or closes every one of them. It acts on what is
|
|
318
|
+
already on screen rather than repainting, so you keep your place.
|
|
319
|
+
|
|
320
|
+
**Finding things.** A run of several thousand steps needs more than scrolling.
|
|
321
|
+
`Search this run` matches across message text, reasoning, tool names, tool
|
|
322
|
+
arguments and observation output — the things a reader can actually see. Filter
|
|
323
|
+
lenses (`All / User / Agent / System / Tools / Reasoning / Branches`) carry live
|
|
324
|
+
counts for the whole run, so you can tell at a glance that a session is 7,628
|
|
325
|
+
tool turns and 451 user messages before filtering to any of them.
|
|
326
|
+
|
|
327
|
+
**Provenance and sources.** Three tabs: `Trajectory` renders the run;
|
|
328
|
+
`Raw` shows the head of the original log, so you can see what was converted
|
|
329
|
+
rather than trusting the conversion; `Files` lists everything that travelled
|
|
330
|
+
with the session — subagent traces, sidecar manifests, bundled images — each
|
|
331
|
+
revealable in the file manager. A details strip records the schema version,
|
|
332
|
+
detected source format, model, session id and transcript size.
|
|
333
|
+
|
|
334
|
+
**Reading a trajectory.** Every step is a tinted card — one colour per role, so
|
|
335
|
+
user, agent and system turns are distinguishable without reading labels — with
|
|
336
|
+
its ATIF `step_id` in the gutter to the left of the timeline. The number is also
|
|
337
|
+
an anchor, so you can link someone to a specific step. Subagent steps are
|
|
338
|
+
numbered independently (ATIF restarts them at 1) and scoped so their anchors
|
|
339
|
+
cannot collide with the parent's. Tool-call arguments are syntax-coloured, and
|
|
340
|
+
tool output is coloured only when it really parses as JSON, so ordinary command
|
|
341
|
+
output is left as plain text. The session list collapses with the button at the
|
|
342
|
+
top left, or the `\\` key.
|
|
343
|
+
|
|
344
|
+
**Branching.** A delegated subagent is a complete trajectory in its own right, so
|
|
345
|
+
the viewer renders it as one: collapsed under the tool call that spawned it,
|
|
346
|
+
labelled with the agent type, its task, and its step count. Expanding it shows
|
|
347
|
+
that agent's own steps — and because ATIF nests arbitrarily deep, a subagent that
|
|
348
|
+
delegates further renders the same way, with depth marked. Where a ref points at
|
|
349
|
+
an external file (`--split-subagents`) rather than an embedded trajectory, the
|
|
350
|
+
viewer says so instead of silently showing nothing.
|
|
351
|
+
|
|
352
|
+
Because branches sit anywhere in a trajectory that may run to thousands of
|
|
353
|
+
steps, every session with branches gets a jump list at the top — agent type,
|
|
354
|
+
task, step count — and an "only branches" filter. Steps render 250 at a time so
|
|
355
|
+
a 8,000-step session stays responsive.
|
|
356
|
+
|
|
357
|
+
## Developing alongside atif-make
|
|
358
|
+
|
|
359
|
+
The two packages are developed together. Installing the viewer editable makes
|
|
360
|
+
its own code live:
|
|
361
|
+
|
|
362
|
+
```sh
|
|
363
|
+
uv tool install --force --editable .
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
That alone still resolves `atif-make` from git, so edits to the converter would
|
|
367
|
+
not show up. To run with **both** live, add it explicitly:
|
|
368
|
+
|
|
369
|
+
```sh
|
|
370
|
+
uv run --with-editable ../atif-make transcript-viewer
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use that while changing anything in `atif-make`. Reinstall from the index
|
|
374
|
+
(`uv tool install --force transcript-viewer`) when you want to test what users actually
|
|
375
|
+
get.
|
|
376
|
+
|
|
377
|
+
## Layout
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
src/transcript_viewer/
|
|
381
|
+
page.html the whole front end — 2,232 lines of HTML, CSS and JavaScript
|
|
382
|
+
viewer.py the HTTP server: fifteen endpoints over the page and the library
|
|
383
|
+
corpus.py the index — what is on this machine, and where it came from
|
|
384
|
+
library.py what you decide about a session: title, tags, stars, summaries
|
|
385
|
+
fetch.py bringing transcripts in from Hugging Face, GitHub, S3 or a link
|
|
386
|
+
ai.py the optional Claude-backed features
|
|
387
|
+
config.py settings and tokens
|
|
388
|
+
store.py small JSON files under ~/.transcript-viewer, written so a crash cannot truncate them
|
|
389
|
+
cli.py the command line
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
The page is a file rather than a string inside `viewer.py`, which is where it
|
|
393
|
+
used to live. Two thirds of that module was CSS and JavaScript typed as though
|
|
394
|
+
it were Python — a template expression once shipped inside static HTML and
|
|
395
|
+
rendered its own source, which is harder to miss in a file that knows what it
|
|
396
|
+
is. It is read once at import and served from memory, and the wheel carries it.
|
|
397
|
+
|
|
398
|
+
## Tests
|
|
399
|
+
|
|
400
|
+
```sh
|
|
401
|
+
uv run pytest
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
While changing `atif-make` at the same time, run against the local converter or
|
|
405
|
+
the tests will resolve the published one:
|
|
406
|
+
|
|
407
|
+
```sh
|
|
408
|
+
uv run --with-editable ../atif-make pytest
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Add `--extra ai` to either command to exercise the AI paths against a real SDK;
|
|
412
|
+
the tests stub the model call, so this never contacts the API.
|
|
413
|
+
|
|
414
|
+
The suite starts a real server on an ephemeral port and exercises the endpoints,
|
|
415
|
+
including that it binds loopback only. It is isolated from your own library,
|
|
416
|
+
index, settings and opened-file store — a frozen default argument once let it
|
|
417
|
+
write to them, so there is a test for that too.
|
|
418
|
+
|
|
419
|
+
Most of the viewer's behaviour is browser JavaScript, which pytest cannot
|
|
420
|
+
reach — two real breaks shipped that way, a row click that did nothing and a
|
|
421
|
+
trajectory pane stuck on "Converting…". Those checks live in
|
|
422
|
+
`tests/page.test.js`, load the real page script against a stub DOM, and run from
|
|
423
|
+
`tests/test_page.py` as part of the same suite (skipped without node).
|
|
424
|
+
|
|
425
|
+
The AI tests never call the API. Most stub the model call and check the part
|
|
426
|
+
that matters when it is wrong — that nothing is sent unasked, that a summary is
|
|
427
|
+
paid for once, that a stored key never reaches a response, and that a transcript
|
|
428
|
+
switched off is refused by the server rather than merely hidden.
|
|
429
|
+
|
|
430
|
+
`tests/test_stream.py` is the exception: it drives the one function that does
|
|
431
|
+
touch the SDK, using a fake client but the SDK's real exception classes, so a
|
|
432
|
+
rename or re-parenting fails here instead of on someone's first paid call. It
|
|
433
|
+
found one already — the SDK moved from `httpx` to `httpx2` at 1.0. CI installs
|
|
434
|
+
the extra and fails on any skipped test, since a silently skipped test is worse
|
|
435
|
+
than no test.
|
|
436
|
+
|
|
437
|
+
`tests/test_readme.py` checks this file against the code, pairing each claim
|
|
438
|
+
with the marker that makes it true. Prose drifts quietly: a documented behaviour
|
|
439
|
+
outlived the code twice here, once because an edit matched nothing and reported
|
|
440
|
+
success anyway.
|