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.
Files changed (32) hide show
  1. transcript_viewer-0.5.0/.coverage +0 -0
  2. transcript_viewer-0.5.0/.github/workflows/publish.yml +35 -0
  3. transcript_viewer-0.5.0/.github/workflows/test.yml +33 -0
  4. transcript_viewer-0.5.0/.gitignore +11 -0
  5. transcript_viewer-0.5.0/PKG-INFO +440 -0
  6. transcript_viewer-0.5.0/README.md +429 -0
  7. transcript_viewer-0.5.0/pyproject.toml +45 -0
  8. transcript_viewer-0.5.0/src/transcript_viewer/__init__.py +5 -0
  9. transcript_viewer-0.5.0/src/transcript_viewer/ai.py +374 -0
  10. transcript_viewer-0.5.0/src/transcript_viewer/cli.py +87 -0
  11. transcript_viewer-0.5.0/src/transcript_viewer/config.py +193 -0
  12. transcript_viewer-0.5.0/src/transcript_viewer/corpus.py +340 -0
  13. transcript_viewer-0.5.0/src/transcript_viewer/fetch.py +771 -0
  14. transcript_viewer-0.5.0/src/transcript_viewer/library.py +169 -0
  15. transcript_viewer-0.5.0/src/transcript_viewer/page.html +2248 -0
  16. transcript_viewer-0.5.0/src/transcript_viewer/store.py +83 -0
  17. transcript_viewer-0.5.0/src/transcript_viewer/viewer.py +1000 -0
  18. transcript_viewer-0.5.0/tests/page.test.js +1407 -0
  19. transcript_viewer-0.5.0/tests/test_ai.py +385 -0
  20. transcript_viewer-0.5.0/tests/test_cli.py +112 -0
  21. transcript_viewer-0.5.0/tests/test_config.py +183 -0
  22. transcript_viewer-0.5.0/tests/test_corpus.py +435 -0
  23. transcript_viewer-0.5.0/tests/test_fetch.py +782 -0
  24. transcript_viewer-0.5.0/tests/test_library.py +144 -0
  25. transcript_viewer-0.5.0/tests/test_page.py +24 -0
  26. transcript_viewer-0.5.0/tests/test_readme.py +140 -0
  27. transcript_viewer-0.5.0/tests/test_store.py +49 -0
  28. transcript_viewer-0.5.0/tests/test_stream.py +206 -0
  29. transcript_viewer-0.5.0/tests/test_style.py +112 -0
  30. transcript_viewer-0.5.0/tests/test_tidiness.py +81 -0
  31. transcript_viewer-0.5.0/tests/test_viewer.py +913 -0
  32. 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.