transcript-viewer 0.6.0__tar.gz → 0.6.2__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.6.0/README.md → transcript_viewer-0.6.2/PKG-INFO +27 -3
  2. transcript_viewer-0.6.0/PKG-INFO → transcript_viewer-0.6.2/README.md +14 -16
  3. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/pyproject.toml +1 -1
  4. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/library.py +35 -3
  5. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/page.html +109 -15
  6. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/viewer.py +33 -4
  7. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/page.test.js +56 -0
  8. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_readme.py +24 -0
  9. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_style.py +37 -0
  10. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_viewer.py +34 -0
  11. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/uv.lock +1 -1
  12. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/.coverage +0 -0
  13. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/.github/workflows/publish.yml +0 -0
  14. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/.github/workflows/test.yml +0 -0
  15. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/.gitignore +0 -0
  16. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/__init__.py +0 -0
  17. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/ai.py +0 -0
  18. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/cli.py +0 -0
  19. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/config.py +0 -0
  20. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/corpus.py +0 -0
  21. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/fetch.py +0 -0
  22. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/src/transcript_viewer/store.py +0 -0
  23. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_ai.py +0 -0
  24. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_cli.py +0 -0
  25. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_config.py +0 -0
  26. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_corpus.py +0 -0
  27. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_fetch.py +0 -0
  28. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_library.py +0 -0
  29. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_page.py +0 -0
  30. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_store.py +0 -0
  31. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_stream.py +0 -0
  32. {transcript_viewer-0.6.0 → transcript_viewer-0.6.2}/tests/test_tidiness.py +0 -0
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.5
2
+ Name: transcript-viewer
3
+ Version: 0.6.2
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.5.0
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
+
1
14
  # transcript-viewer
2
15
 
3
16
  Browse agent transcripts in a local web viewer — what Claude Code, Codex and
@@ -13,9 +26,15 @@ anything to change in the viewer.
13
26
  ## Install
14
27
 
15
28
  ```sh
16
- uv tool install transcript-viewer # pulls atif-make automatically
29
+ uv tool install transcript-viewer # pulls atif-make automatically
30
+ uv tool install "transcript-viewer[parquet]" # + datasets published as Parquet
31
+ uv tool install "transcript-viewer[ai,parquet]" # + the optional Claude features
17
32
  ```
18
33
 
34
+ The extras are only needed for what they name: `parquet` for a dataset that
35
+ ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
36
+ viewer works without either.
37
+
19
38
  ```sh
20
39
  transcript-viewer # the library, empty on a first run
21
40
  transcript-viewer path/to/session.jsonl # one log
@@ -395,7 +414,11 @@ The page is a file rather than a string inside `viewer.py`, which is where it
395
414
  used to live. Two thirds of that module was CSS and JavaScript typed as though
396
415
  it were Python — a template expression once shipped inside static HTML and
397
416
  rendered its own source, which is harder to miss in a file that knows what it
398
- is. It is read once at import and served from memory, and the wheel carries it.
417
+ is. It is re-read whenever it changes, so editing the interface and reloading
418
+ shows the edit — twice it did not, and read as an edit that had failed. The
419
+ cost is a stat per page load, and none on what the page then calls; for an
420
+ installed copy the file never changes and the stat always says so. The wheel
421
+ carries it.
399
422
 
400
423
  ## Tests
401
424
 
@@ -411,7 +434,8 @@ uv run --with-editable ../atif-make pytest
411
434
  ```
412
435
 
413
436
  Add `--extra ai` to either command to exercise the AI paths against a real SDK;
414
- the tests stub the model call, so this never contacts the API.
437
+ the tests stub the model call, so this never contacts the API. `--extra parquet`
438
+ does the same for the Parquet path, which is otherwise skipped.
415
439
 
416
440
  The suite starts a real server on an ephemeral port and exercises the endpoints,
417
441
  including that it binds loopback only. It is isolated from your own library,
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: transcript-viewer
3
- Version: 0.6.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.5.0
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
@@ -26,9 +13,15 @@ anything to change in the viewer.
26
13
  ## Install
27
14
 
28
15
  ```sh
29
- uv tool install transcript-viewer # pulls atif-make automatically
16
+ uv tool install transcript-viewer # pulls atif-make automatically
17
+ uv tool install "transcript-viewer[parquet]" # + datasets published as Parquet
18
+ uv tool install "transcript-viewer[ai,parquet]" # + the optional Claude features
30
19
  ```
31
20
 
21
+ The extras are only needed for what they name: `parquet` for a dataset that
22
+ ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
23
+ viewer works without either.
24
+
32
25
  ```sh
33
26
  transcript-viewer # the library, empty on a first run
34
27
  transcript-viewer path/to/session.jsonl # one log
@@ -408,7 +401,11 @@ The page is a file rather than a string inside `viewer.py`, which is where it
408
401
  used to live. Two thirds of that module was CSS and JavaScript typed as though
409
402
  it were Python — a template expression once shipped inside static HTML and
410
403
  rendered its own source, which is harder to miss in a file that knows what it
411
- is. It is read once at import and served from memory, and the wheel carries it.
404
+ is. It is re-read whenever it changes, so editing the interface and reloading
405
+ shows the edit — twice it did not, and read as an edit that had failed. The
406
+ cost is a stat per page load, and none on what the page then calls; for an
407
+ installed copy the file never changes and the stat always says so. The wheel
408
+ carries it.
412
409
 
413
410
  ## Tests
414
411
 
@@ -424,7 +421,8 @@ uv run --with-editable ../atif-make pytest
424
421
  ```
425
422
 
426
423
  Add `--extra ai` to either command to exercise the AI paths against a real SDK;
427
- the tests stub the model call, so this never contacts the API.
424
+ the tests stub the model call, so this never contacts the API. `--extra parquet`
425
+ does the same for the Parquet path, which is otherwise skipped.
428
426
 
429
427
  The suite starts a real server on an ephemeral port and exercises the endpoints,
430
428
  including that it binds loopback only. It is isolated from your own library,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.6.0"
3
+ version = "0.6.2"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -51,17 +51,49 @@ def _now() -> str:
51
51
  return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
52
52
 
53
53
 
54
- def load(path: Path | None = None) -> dict[str, dict]:
55
- """Every annotation, by key. A missing or damaged file reads as empty."""
56
- data = store.read_json(path or LIBRARY_PATH)
54
+ # What was parsed, and the state of the file it came from. The library is read
55
+ # far more often than it is written a page load asks about every session in
56
+ # it and it grows with the corpus, so parsing a megabyte of JSON per question
57
+ # is the difference between a page appearing and a page taking four seconds.
58
+ _parsed: tuple[int, int, dict[str, dict]] | None = None
59
+
60
+
61
+ def _read(path: Path) -> dict[str, dict]:
62
+ data = store.read_json(path)
57
63
  entries = data.get("entries")
58
64
  if not isinstance(entries, dict):
59
65
  return {}
60
66
  return {k: v for k, v in entries.items() if isinstance(v, dict)}
61
67
 
62
68
 
69
+ def load(path: Path | None = None) -> dict[str, dict]:
70
+ """Every annotation, by key. A missing or damaged file reads as empty.
71
+
72
+ The real library is cached against its own timestamp and size, so a change
73
+ made by anything — this process or another — is picked up on the next read.
74
+ A caller gets its own outer dict, since `remove` pops from what it is given
75
+ and callers should not be able to edit the cache by accident.
76
+ """
77
+ global _parsed
78
+ path = path or LIBRARY_PATH
79
+ if path != LIBRARY_PATH:
80
+ return _read(path)
81
+
82
+ try:
83
+ info = path.stat()
84
+ stamp = (info.st_mtime_ns, info.st_size)
85
+ except OSError:
86
+ return _read(path)
87
+
88
+ if _parsed is None or (_parsed[0], _parsed[1]) != stamp:
89
+ _parsed = (stamp[0], stamp[1], _read(path))
90
+ return dict(_parsed[2])
91
+
92
+
63
93
  def save(entries: dict[str, dict], path: Path | None = None) -> None:
64
94
  """Write the whole library atomically."""
95
+ global _parsed
96
+ _parsed = None # the next read re-stamps against the file just written
65
97
  store.write_json(path or LIBRARY_PATH, {"version": VERSION, "entries": entries})
66
98
 
67
99
 
@@ -430,8 +430,33 @@ pre.json{line-height:1.45}
430
430
  .more button{border:1px solid var(--line);background:var(--panel);color:var(--dim);border-radius:999px;
431
431
  padding:8px 18px;font:inherit;font-size:12.5px;cursor:pointer}
432
432
  .more button:hover{background:var(--sunk);color:var(--ink)}
433
+
434
+ /* Shown only if opening takes long enough to notice — a library of a few
435
+ thousand sessions is a couple of megabytes, and a blank window while that
436
+ arrives reads as broken rather than busy. */
437
+ #boot{position:fixed;inset:0;z-index:60;display:flex;flex-direction:column;
438
+ align-items:center;justify-content:center;gap:14px;background:var(--bg)}
439
+ #boot[hidden]{display:none}
440
+ #bootbar{width:min(320px,52vw);height:3px;border-radius:3px;background:var(--line);
441
+ overflow:hidden}
442
+ #bootbar i{display:block;height:100%;width:0;border-radius:3px;background:var(--accent);
443
+ transition:width .18s ease-out}
444
+ /* Nothing to measure yet: sweep rather than sit at zero, which reads as stuck. */
445
+ #bootbar.waiting i{width:35%;animation:sweep 1.1s ease-in-out infinite}
446
+ @keyframes sweep{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}
447
+ #boottext{margin:0;font:12px/1.5 var(--font-mono);color:var(--muted);
448
+ letter-spacing:.02em;text-align:center;padding:0 20px}
449
+ @media (prefers-reduced-motion:reduce){
450
+ #bootbar i{transition:none}
451
+ #bootbar.waiting i{animation:none;width:100%;opacity:.5}
452
+ }
433
453
  </style>
434
454
 
455
+ <div id="boot" hidden>
456
+ <div id="bootbar"><i></i></div>
457
+ <p id="boottext">Opening your library…</p>
458
+ </div>
459
+
435
460
  <header id="top">
436
461
  <span class="mark">Transcript Viewer</span>
437
462
  <div id="crumb"></div>
@@ -813,6 +838,8 @@ function pre(content) {
813
838
 
814
839
  const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
815
840
  const num=n=>(n??0).toLocaleString();
841
+ /* How many sessions are in the library, said the same way everywhere. */
842
+ const showCount=()=>{count.textContent=`${num(INDEX.length)} session${INDEX.length===1?"":"s"}`};
816
843
  /* One size formatter. Everything used to divide by 1048576 and say MB, so a
817
844
  900-byte log read as "0.0 MB" and a 144 GB bucket as "147456.0 MB". Each step
818
845
  keeps three significant figures, which is as much as anyone reads. */
@@ -845,7 +872,7 @@ function reveal(a){
845
872
  async function openFiles(files){
846
873
  if(!files||!files.length)return;
847
874
  const names=[...files].map(f=>f.name);
848
- count.textContent=`opening ${names.length} file${names.length>1?"s":""}…`;
875
+ count.textContent=`opening ${num(names.length)} file${names.length>1?"s":""}…`;
849
876
  const problems=[];
850
877
  let first=null;
851
878
  for(const file of files){
@@ -859,7 +886,7 @@ async function openFiles(files){
859
886
  }
860
887
  const fresh=await fetch("/api/index").then(r=>r.json());
861
888
  INDEX=fresh.sessions||[];GROUPS=fresh.groups||[];TAGS=fresh.tags||[];AI=fresh.ai||{};DOWNLOADS=fresh.downloads||DOWNLOADS;
862
- count.textContent=INDEX.length+" sessions";drawList();
889
+ showCount();drawList();
863
890
  if(first!==null)pick(first);
864
891
  if(problems.length)note(problems.join("\n"));
865
892
  picker.value="";
@@ -932,10 +959,77 @@ addEventListener("keydown",e=>{
932
959
  if(e.key==="\\"&&e.target.tagName!=="INPUT"){e.preventDefault();toggleSide()}
933
960
  });
934
961
 
935
- fetch("/api/index").then(r=>r.json()).then(d=>{
936
- INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
937
- count.textContent=INDEX.length+" sessions";drawList();showLibrary();
938
- });
962
+ /* Opening the library has three parts worth naming: waiting for the server to
963
+ answer, reading the answer, and drawing it. Held back briefly so a fast open
964
+ does not flash a bar at you. */
965
+ const boot={el:null,bar:null,text:null,timer:0};
966
+
967
+ function bootShow(){
968
+ boot.el=document.getElementById("boot");
969
+ boot.bar=document.getElementById("bootbar");
970
+ boot.text=document.getElementById("boottext");
971
+ if(!boot.el)return;
972
+ boot.timer=setTimeout(()=>{if(boot.el.hidden)boot.el.hidden=false},160);
973
+ }
974
+
975
+ function bootSay(what,done,total){
976
+ if(boot.text)boot.text.textContent=what;
977
+ if(!boot.bar)return;
978
+ if(total>0){
979
+ boot.bar.classList.remove("waiting");
980
+ boot.bar.firstElementChild.style.width=Math.min(100,done/total*100)+"%";
981
+ }else{
982
+ boot.bar.classList.add("waiting");
983
+ }
984
+ }
985
+
986
+ function bootDone(){
987
+ clearTimeout(boot.timer);
988
+ if(boot.el)boot.el.hidden=true;
989
+ }
990
+
991
+ /* Read the response as it arrives, so the bar tracks real bytes rather than
992
+ guessing at them. Without a length to measure against it sweeps instead of
993
+ claiming progress it cannot know. */
994
+ async function readIndex(){
995
+ const response=await fetch("/api/index");
996
+ if(!response.ok)throw new Error("the server answered "+response.status);
997
+ const total=Number(response.headers.get("Content-Length")||0);
998
+ if(!response.body||!total)return response.json();
999
+
1000
+ const reader=response.body.getReader();
1001
+ const chunks=[];let done=0;
1002
+ for(;;){
1003
+ const piece=await reader.read();
1004
+ if(piece.done)break;
1005
+ chunks.push(piece.value);done+=piece.value.length;
1006
+ bootSay("Reading your library… "+bytes(done)+" of "+bytes(total),done,total);
1007
+ }
1008
+ const joined=new Uint8Array(done);let at=0;
1009
+ for(const chunk of chunks){joined.set(chunk,at);at+=chunk.length}
1010
+ bootSay("Making sense of it…",0,0);
1011
+ return JSON.parse(new TextDecoder().decode(joined));
1012
+ }
1013
+
1014
+ async function openLibrary(){
1015
+ bootShow();
1016
+ bootSay("Looking for your sessions…",0,0);
1017
+ try{
1018
+ const d=await readIndex();
1019
+ INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
1020
+ bootSay(`Drawing ${num(INDEX.length)} sessions…`,0,0);
1021
+ // Let that line paint before the work that blocks the thread.
1022
+ await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
1023
+ showCount();drawList();showLibrary();
1024
+ }catch(err){
1025
+ // Leave it on screen: a blank window would say nothing at all.
1026
+ bootSay("Could not open your library — "+((err&&err.message)||err),0,0);
1027
+ return;
1028
+ }
1029
+ bootDone();
1030
+ }
1031
+
1032
+ openLibrary();
939
1033
  q.oninput=drawList;
940
1034
 
941
1035
  /* Diwan's collectionTree builds a forest carrying depth, then flattens it
@@ -1025,13 +1119,13 @@ function drawList(){
1025
1119
  <button title="Remove these sessions from the library" class="dang"
1026
1120
  onclick="event.stopPropagation();dropGroup('${esc(r.path)}')">✕</button>
1027
1121
  </span>`}
1028
- <span class="rc">${r.count}</span></div>`;
1122
+ <span class="rc">${num(r.count)}</span></div>`;
1029
1123
  }).join("");
1030
1124
 
1031
1125
  tagbar.innerHTML=TAGS.map(t=>{
1032
1126
  const on=!!ACTIVE_TAGS[t.name];
1033
1127
  return `<span class="pill tag${on?" on":""}" onclick="toggleTag('${esc(t.name)}')">
1034
- ${esc(t.name)}<b>${t.count}</b></span>`;
1128
+ ${esc(t.name)}<b>${num(t.count)}</b></span>`;
1035
1129
  }).join("");
1036
1130
  }
1037
1131
 
@@ -1593,7 +1687,7 @@ async function planUrl(){
1593
1687
  showLibrary();
1594
1688
  closeUrl();
1595
1689
  showProgress(0,0);
1596
- if(result)note(`Added ${result.added} session${result.added===1?"":"s"} — ${result.into}`);
1690
+ if(result)note(`Added ${num(result.added)} session${result.added===1?"":"s"} — ${result.into}`);
1597
1691
  return;
1598
1692
  }
1599
1693
 
@@ -1620,8 +1714,8 @@ async function planUrl(){
1620
1714
  const size=p.bytes?` · ${bytes(p.bytes)}`:"";
1621
1715
  const sample=p.names.length?` — ${p.names.slice(0,3).join(", ")}${
1622
1716
  p.count>3?", …":""}`:"";
1623
- setUrlState(`${p.count} file${p.count===1?"":"s"} in ${p.label}${size}${sample}\n`
1624
- +`→ ${p.into}`, `Download ${p.count}`);
1717
+ setUrlState(`${num(p.count)} file${p.count===1?"":"s"} in ${p.label}${size}${sample}\n`
1718
+ +`→ ${p.into}`, `Download ${num(p.count)}`);
1625
1719
  }catch(e){
1626
1720
  err.textContent=e.message;err.hidden=false;
1627
1721
  setUrlState("","Look");PLANNED=null;
@@ -1782,7 +1876,7 @@ async function annotate(key,fields){
1782
1876
  async function refreshIndex(){
1783
1877
  const d=await fetch("/api/index").then(r=>r.json());
1784
1878
  INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
1785
- count.textContent=INDEX.length+" sessions";
1879
+ showCount();
1786
1880
  drawList();
1787
1881
  if(!cur)showLibrary();
1788
1882
  }
@@ -2185,7 +2279,7 @@ function branchNav(branches){
2185
2279
  <span class="sid">${b.stepId}</span>
2186
2280
  <b>${esc(label)}</b>
2187
2281
  <span>${esc(x.description||"")||"—"}</span>
2188
- <em>${b.sub?b.sub.steps.length+" steps":"external"}</em>
2282
+ <em>${b.sub?num(b.sub.steps.length)+" steps":"external"}</em>
2189
2283
  </button>`;
2190
2284
  }).join("")+`</div></div>`;
2191
2285
  }
@@ -2213,7 +2307,7 @@ function step(s,ctx,depth,idx,prefix){
2213
2307
  const on=starredSteps().includes(key);
2214
2308
  let h=`<div class="step ${s.source}" id="step-${key}">
2215
2309
  <div class="gut">
2216
- <a class="sid" href="#step-${key}" title="step ${s.step_id}">${s.step_id}</a>
2310
+ <a class="sid" href="#step-${key}" title="step ${num(s.step_id)}">${num(s.step_id)}</a>
2217
2311
  <span class="sstar${on?" on":""}" onclick="toggleStep(event,'${key}')"
2218
2312
  title="${on?"Remove from favourites":"Add to favourites"}">
2219
2313
  <svg width="11" height="11" viewBox="0 0 24 24" fill="${on?"currentColor":"none"}"
@@ -2257,7 +2351,7 @@ function branch(ref,ctx,depth){
2257
2351
  return `<details class="branch"${OPEN_ALL?" open":""}><summary>
2258
2352
  <b>${esc(label)}</b>
2259
2353
  <span class="desc">${esc(x.description||"")}</span>
2260
- <span class="pill br">${sub.steps.length} steps</span>
2354
+ <span class="pill br">${num(sub.steps.length)} steps</span>
2261
2355
  ${depth?`<span class="pill">depth ${depth+1}</span>`:""}
2262
2356
  </summary><div class="inner">
2263
2357
  ${sub.steps.map(s=>step(s,sub,depth+1,null,esc(sub.trajectory_id||"sub")+"-")).join("")}
@@ -35,7 +35,33 @@ from . import ai, config, fetch, library
35
35
  # The page is a real .html file rather than a string in here: it is 2,232 lines
36
36
  # of CSS and JavaScript, which is not Python and should not be typed as though
37
37
  # it were. Read once at import, and served from memory.
38
- PAGE = (Path(__file__).parent / "page.html").read_text(encoding="utf-8")
38
+ PAGE_PATH = Path(__file__).parent / "page.html"
39
+
40
+ # What was read, and the state of the file it was read from.
41
+ _page: tuple[float, int, str] | None = None
42
+
43
+
44
+ def page() -> str:
45
+ """The interface, re-read when the file behind it changes.
46
+
47
+ The whole interface is that one file, so editing it and seeing nothing
48
+ change until the server is restarted reads as the edit not having worked.
49
+ Twice now it has. The cost is one stat per page load — not per request, and
50
+ not on anything the page then calls — and for an installed copy, where the
51
+ file never changes, it is a stat that always says the same thing.
52
+ """
53
+ global _page
54
+ try:
55
+ info = PAGE_PATH.stat()
56
+ stamp = (info.st_mtime, info.st_size)
57
+ except OSError:
58
+ # If it cannot be stat'd, what was already read is better than nothing.
59
+ if _page is not None:
60
+ return _page[2]
61
+ raise
62
+ if _page is None or (_page[0], _page[1]) != stamp:
63
+ _page = (stamp[0], stamp[1], PAGE_PATH.read_text(encoding="utf-8"))
64
+ return _page[2]
39
65
 
40
66
 
41
67
  def _point_images_at_server(trajectory: Trajectory, index: str) -> None:
@@ -305,7 +331,7 @@ class _Handler(BaseHTTPRequestHandler):
305
331
  return {
306
332
  e.key
307
333
  for e in self.entries
308
- if (at := _group(e, library.get(e.key).get("source", "")))
334
+ if (at := _group(e, library.load().get(e.key, {}).get("source", "")))
309
335
  and (at == name or at.startswith(f"{name}/"))
310
336
  }
311
337
 
@@ -828,10 +854,13 @@ class _Handler(BaseHTTPRequestHandler):
828
854
  url = urlparse(self.path)
829
855
 
830
856
  if url.path == "/":
831
- self._send(PAGE.encode(), "text/html; charset=utf-8")
857
+ self._send(page().encode(), "text/html; charset=utf-8")
832
858
  return
833
859
 
834
860
  if url.path == "/api/index":
861
+ # Read once. Asking per session made a page load parse the whole
862
+ # library as many times as there were sessions in it.
863
+ records = library.load()
835
864
  rows = [
836
865
  {
837
866
  "key": e.key,
@@ -845,7 +874,7 @@ class _Handler(BaseHTTPRequestHandler):
845
874
  "size_bytes": e.size_bytes,
846
875
  "subagents": e.subagents,
847
876
  "session_title": e.session_title,
848
- "group": _group(e, library.get(e.key).get("source", "")),
877
+ "group": _group(e, records.get(e.key, {}).get("source", "")),
849
878
  }
850
879
  for e in self.entries
851
880
  # A downloaded folder the reader deleted, or a drive not mounted
@@ -1424,3 +1424,59 @@ test("everything reads as All sessions", () => {
1424
1424
  console.log(`\n${tests.length - failed} passed, ${failed} failed`);
1425
1425
  process.exit(failed ? 1 : 0);
1426
1426
  })();
1427
+
1428
+ test("opening the library reports each stage it goes through", async () => {
1429
+ const said = [];
1430
+ const el = { hidden: true };
1431
+ const bar = {
1432
+ classList: { add() {}, remove() {} },
1433
+ firstElementChild: { style: {} },
1434
+ };
1435
+ const text = { textContent: "" };
1436
+ globalThis.__boot = { boot: el, bootbar: bar, boottext: text, said };
1437
+
1438
+ const seen = run(`
1439
+ document.getElementById = id => globalThis.__boot[id] || {textContent:"",style:{},classList:{add(){},remove(){}}};
1440
+ globalThis.requestAnimationFrame = fn => fn();
1441
+ globalThis.fetch = async () => ({
1442
+ ok: true,
1443
+ headers: { get: () => "0" },
1444
+ body: null,
1445
+ json: async () => ({sessions: [], groups: [], tags: [], ai: {}}),
1446
+ });
1447
+ const stages = [];
1448
+ const realSay = bootSay;
1449
+ bootSay = (what) => { stages.push(what); realSay(what, 0, 0); };
1450
+ return openLibrary().then(() => stages);
1451
+ `);
1452
+
1453
+ return seen.then((stages) => {
1454
+ assert.ok(
1455
+ stages.some((s) => /Looking for your sessions/.test(s)),
1456
+ "it should say it is looking before it has anything",
1457
+ );
1458
+ assert.ok(
1459
+ stages.some((s) => /Drawing/.test(s)),
1460
+ "it should say it is drawing once it has the data",
1461
+ );
1462
+ });
1463
+ });
1464
+
1465
+ test("a failure to open leaves the reason on screen", async () => {
1466
+ const text = { textContent: "" };
1467
+ const el = { hidden: false };
1468
+ globalThis.__boot = {
1469
+ boot: el,
1470
+ bootbar: { classList: { add() {}, remove() {} }, firstElementChild: { style: {} } },
1471
+ boottext: text,
1472
+ };
1473
+
1474
+ await run(`
1475
+ document.getElementById = id => globalThis.__boot[id] || {textContent:"",style:{},classList:{add(){},remove(){}}};
1476
+ globalThis.fetch = async () => ({ ok: false, status: 500 });
1477
+ return openLibrary();
1478
+ `);
1479
+
1480
+ assert.match(text.textContent, /Could not open your library/);
1481
+ assert.strictEqual(el.hidden, false, "a blank window would say nothing at all");
1482
+ });
@@ -41,6 +41,8 @@ CLAIMS = [
41
41
  ("quadratic", "HISTORY_TURNS", "src/transcript_viewer/ai.py"),
42
42
  ("links into the transcript", "function jumpToStep", "src/transcript_viewer/page.html"),
43
43
  ("--extra ai", "ai = [", "pyproject.toml"),
44
+ ("re-read whenever it changes", "def page()", "src/transcript_viewer/viewer.py"),
45
+ ("stat per page load", "PAGE_PATH.stat()", "src/transcript_viewer/viewer.py"),
44
46
  ]
45
47
 
46
48
 
@@ -162,3 +164,25 @@ def test_every_module_appears_in_the_map():
162
164
  if path.name.startswith("_") or path.suffix not in {".py", ".html"}:
163
165
  continue
164
166
  assert f" {path.name}" in readme, f"{path.name} is missing from the module map"
167
+
168
+
169
+ def test_every_extra_offered_in_the_readme_exists():
170
+ """An install command that names an extra nobody defined simply fails."""
171
+ project = Path("pyproject.toml").read_text()
172
+ defined = set(re.findall(r"^([a-z][a-z0-9-]*) = \[", project, re.MULTILINE))
173
+ readme = Path("README.md").read_text()
174
+ offered = set()
175
+ for group in re.findall(r'transcript-viewer\[([a-z,]+)\]', readme):
176
+ offered.update(group.split(","))
177
+ missing = offered - defined
178
+ assert not missing, f"the README offers extras that do not exist: {missing}"
179
+
180
+
181
+ def test_every_extra_that_exists_is_mentioned():
182
+ """An extra nobody is told about may as well not be there."""
183
+ project = Path("pyproject.toml").read_text()
184
+ block = project.split("[project.optional-dependencies]")[1].split("\n[")[0]
185
+ defined = set(re.findall(r"^([a-z][a-z0-9-]*) = \[", block, re.MULTILINE))
186
+ readme = Path("README.md").read_text()
187
+ unmentioned = {name for name in defined if name not in readme}
188
+ assert not unmentioned, f"extras the README never mentions: {unmentioned}"
@@ -110,3 +110,40 @@ def test_sizes_go_through_one_formatter():
110
110
  )
111
111
  assert "1048576" not in code, "a size is still being divided by hand"
112
112
  assert code.count("function bytes(") == 1
113
+
114
+
115
+ # Numbers a reader sees are formatted in one place, so a corpus of thousands
116
+ # reads as "2,258 sessions" rather than "2258 sessions". These are the shapes
117
+ # that reached the screen raw before anyone looked.
118
+ RAW_NUMBERS = (
119
+ (r"\.length\s*\+\s*[\"']", "a count concatenated onto a string"),
120
+ (r"\$\{[^}]*\.length\}\s*(?:session|step|file|result|match)", "${…length} beside a noun"),
121
+ (r"\$\{\s*[a-zA-Z_$][\w.$]*\.count\s*\}", "${…count}"),
122
+ (r"\$\{\s*[a-zA-Z_$][\w.$]*\.added\s*\}", "${…added}"),
123
+ (r"\$\{\s*s\.step_id\s*\}", "${s.step_id}"),
124
+ )
125
+
126
+
127
+ @pytest.mark.parametrize(("pattern", "what"), RAW_NUMBERS, ids=[w for _, w in RAW_NUMBERS])
128
+ def test_a_number_on_screen_goes_through_the_formatter(pattern, what):
129
+ script = PAGE[PAGE.index("<script>") : PAGE.rindex("</script>")]
130
+ hits = []
131
+ for i, line in enumerate(script.splitlines(), start=1):
132
+ stripped = line.strip()
133
+ if stripped.startswith("//") or stripped.startswith("*"):
134
+ continue
135
+ for match in re.finditer(pattern, line):
136
+ around = line[max(0, match.start() - 14) : match.end() + 3]
137
+ if "num(" in around or "bytes(" in around:
138
+ continue
139
+ hits.append(f"line {i}: {stripped[:90]}")
140
+ assert not hits, f"{what} without num(): " + "; ".join(hits)
141
+
142
+
143
+ def test_the_session_count_is_said_in_one_place():
144
+ """It was written out three times, and one of them was already wrong."""
145
+ script = PAGE[PAGE.index("<script>") : PAGE.rindex("</script>")]
146
+ assert "const showCount=" in script, "the count should have a single home"
147
+ assert script.count('count.textContent=') <= 2, (
148
+ "the session count is being written in more than one place again"
149
+ )
@@ -911,3 +911,37 @@ def test_clearing_an_empty_library_is_harmless(server):
911
911
  _delete(server + "/api/library?all=1")
912
912
  status, payload = _delete(server + "/api/library?all=1")
913
913
  assert status == 200 and payload["removed"] == 0
914
+
915
+
916
+ def test_the_page_is_re_read_when_it_changes(tmp_path, monkeypatch):
917
+ """Editing the interface and seeing nothing change reads as a broken edit."""
918
+ from transcript_viewer import viewer
919
+
920
+ page_file = tmp_path / "page.html"
921
+ page_file.write_text("<p>first</p>")
922
+ monkeypatch.setattr(viewer, "PAGE_PATH", page_file)
923
+ monkeypatch.setattr(viewer, "_page", None)
924
+
925
+ assert viewer.page() == "<p>first</p>"
926
+
927
+ # A same-size rewrite is the hard case: only the timestamp separates them.
928
+ page_file.write_text("<p>secnd</p>")
929
+ import os
930
+ stamp = page_file.stat().st_mtime + 10
931
+ os.utime(page_file, (stamp, stamp))
932
+
933
+ assert viewer.page() == "<p>secnd</p>"
934
+
935
+
936
+ def test_an_unreadable_page_serves_what_was_already_read(tmp_path, monkeypatch):
937
+ """A file being written to should not take the interface down mid-save."""
938
+ from transcript_viewer import viewer
939
+
940
+ page_file = tmp_path / "page.html"
941
+ page_file.write_text("<p>kept</p>")
942
+ monkeypatch.setattr(viewer, "PAGE_PATH", page_file)
943
+ monkeypatch.setattr(viewer, "_page", None)
944
+ assert viewer.page() == "<p>kept</p>"
945
+
946
+ page_file.unlink()
947
+ assert viewer.page() == "<p>kept</p>"
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.6.0"
391
+ version = "0.6.2"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },