transcript-viewer 0.5.0__py3-none-any.whl
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/__init__.py +5 -0
- transcript_viewer/ai.py +374 -0
- transcript_viewer/cli.py +87 -0
- transcript_viewer/config.py +193 -0
- transcript_viewer/corpus.py +340 -0
- transcript_viewer/fetch.py +771 -0
- transcript_viewer/library.py +169 -0
- transcript_viewer/page.html +2248 -0
- transcript_viewer/store.py +83 -0
- transcript_viewer/viewer.py +1000 -0
- transcript_viewer-0.5.0.dist-info/METADATA +440 -0
- transcript_viewer-0.5.0.dist-info/RECORD +14 -0
- transcript_viewer-0.5.0.dist-info/WHEEL +4 -0
- transcript_viewer-0.5.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Corpus indexing.
|
|
2
|
+
|
|
3
|
+
The point of an index is to answer "what sessions do I have, across every agent"
|
|
4
|
+
without paying to convert them. Scanning reads only a file's leading lines, so a
|
|
5
|
+
143 MB rollout costs the same as a 4 KB one; full conversion happens lazily, when
|
|
6
|
+
something actually asks for that trajectory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from dataclasses import fields as dataclass_fields
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from atif_make.archive import extract, is_archive
|
|
20
|
+
|
|
21
|
+
from . import store
|
|
22
|
+
from atif_make.convert import AGENTS
|
|
23
|
+
from atif_make.detect import detect_format, head_lines
|
|
24
|
+
|
|
25
|
+
DEFAULT_ROOTS = (
|
|
26
|
+
Path.home() / ".claude" / "projects",
|
|
27
|
+
Path.home() / ".codex" / "sessions",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
INDEX_PATH = store.ROOT / "index.json"
|
|
31
|
+
# Where the viewer keeps files brought in by hand. Anything here is "opened"
|
|
32
|
+
# whoever asks, so a plain rescan classifies them correctly on its own.
|
|
33
|
+
OPENED_ROOT = store.ROOT / "opened"
|
|
34
|
+
|
|
35
|
+
# A first line shorter than this is structural rather than identifying — a
|
|
36
|
+
# pretty-printed JSON document opens with a bare "{".
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def content_key(path: Path) -> str:
|
|
40
|
+
"""A stable identity for a log file: the hash of everything in it.
|
|
41
|
+
|
|
42
|
+
Prefix hashing was tried first and does not hold. It has to assume the
|
|
43
|
+
opening of a file identifies it, which is true of session logs — they carry
|
|
44
|
+
a session id on line one — and false of curated corpora: SLEIGHT-Bench opens
|
|
45
|
+
every transcript with the same canary, and pairs each attack with a benign
|
|
46
|
+
twin that follows the same script until it diverges. Even nine leading lines
|
|
47
|
+
separated only 78 of its 86 files, so the whole corpus collapsed into one
|
|
48
|
+
index row.
|
|
49
|
+
|
|
50
|
+
Hashing everything cannot collide, and makes a genuine duplicate — the same
|
|
51
|
+
transcript downloaded twice — collapse to one row, which is the behaviour
|
|
52
|
+
worth having. It costs little: 1.26 GB of real corpus hashes in half a
|
|
53
|
+
second.
|
|
54
|
+
|
|
55
|
+
The price is that a session still being written re-keys as it grows, so an
|
|
56
|
+
annotation made mid-session can detach from it. `merge` keeps that from
|
|
57
|
+
doubling the row; the detaching is accepted rather than engineered around,
|
|
58
|
+
because annotating a transcript while it is still being written is rare and
|
|
59
|
+
the machinery to follow it would cost more than it saves.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
digest = hashlib.sha256()
|
|
63
|
+
with path.open("rb") as handle:
|
|
64
|
+
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
65
|
+
digest.update(block)
|
|
66
|
+
return digest.hexdigest()
|
|
67
|
+
except OSError:
|
|
68
|
+
# Fall back to the path so an unreadable file still gets *an* identity.
|
|
69
|
+
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class Entry:
|
|
74
|
+
key: str
|
|
75
|
+
origin: str # "scanned" (found on this machine) | "opened" (brought in)
|
|
76
|
+
path: str
|
|
77
|
+
format: str
|
|
78
|
+
agent: str
|
|
79
|
+
session_id: str | None
|
|
80
|
+
project: str | None
|
|
81
|
+
modified: str
|
|
82
|
+
size_bytes: int
|
|
83
|
+
subagents: int
|
|
84
|
+
# What the agent called this session, if it named one. Not the reader's
|
|
85
|
+
# title — that is theirs, and lives in the library.
|
|
86
|
+
session_title: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _session_id(fmt: str, lines: list[dict]) -> str | None:
|
|
90
|
+
for line in lines:
|
|
91
|
+
if fmt == "claude-code-transcript" and isinstance(line.get("sessionId"), str):
|
|
92
|
+
return line["sessionId"]
|
|
93
|
+
if fmt == "claude-code-stream" and isinstance(line.get("session_id"), str):
|
|
94
|
+
return line["session_id"]
|
|
95
|
+
if fmt == "codex-rollout" and line.get("type") == "session_meta":
|
|
96
|
+
payload = line.get("payload")
|
|
97
|
+
if isinstance(payload, dict):
|
|
98
|
+
return payload.get("session_id")
|
|
99
|
+
if fmt == "codex-exec" and isinstance(line.get("thread_id"), str):
|
|
100
|
+
return line["thread_id"]
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _escape(name: str) -> str:
|
|
105
|
+
"""A directory name as Claude Code would write it into a slug."""
|
|
106
|
+
return re.sub(r"[^A-Za-z0-9]+", "-", name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _decode_cwd(slug: str) -> str:
|
|
110
|
+
"""Rebuild a working directory from Claude Code's escaped slug.
|
|
111
|
+
|
|
112
|
+
The escaping replaces every run of non-alphanumeric characters with "-", so
|
|
113
|
+
"/", " " and "_" all become the same thing: "Build An LLM" and
|
|
114
|
+
"Build/An/LLM" escape identically, and neither can be recovered by reading
|
|
115
|
+
the slug.
|
|
116
|
+
|
|
117
|
+
So read the filesystem instead. At each level, look for a real child whose
|
|
118
|
+
own escaped name opens what is left of the slug, preferring the longest —
|
|
119
|
+
which is what tells "atif-make" apart from "atif" followed by "make".
|
|
120
|
+
Whatever cannot be matched is taken literally, which is the best guess
|
|
121
|
+
available for a directory since deleted.
|
|
122
|
+
"""
|
|
123
|
+
rest = slug.lstrip("-")
|
|
124
|
+
here = Path("/")
|
|
125
|
+
while rest:
|
|
126
|
+
try:
|
|
127
|
+
children = sorted(here.iterdir(), key=lambda c: -len(c.name))
|
|
128
|
+
except OSError:
|
|
129
|
+
children = []
|
|
130
|
+
|
|
131
|
+
for child in children:
|
|
132
|
+
# Trimmed at both ends: a name like "s_" escapes to "s-", and the
|
|
133
|
+
# separator that follows it in the slug is the same "-".
|
|
134
|
+
escaped = _escape(child.name).strip("-")
|
|
135
|
+
if escaped and (rest == escaped or rest.startswith(escaped + "-")):
|
|
136
|
+
here, rest = child, rest[len(escaped):].lstrip("-")
|
|
137
|
+
break
|
|
138
|
+
else:
|
|
139
|
+
# Nothing here matches, so the rest never existed or is long gone.
|
|
140
|
+
return str(here / rest.replace("-", "/")) if rest else str(here)
|
|
141
|
+
return str(here)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _project(path: Path, fmt: str) -> str | None:
|
|
145
|
+
if fmt.startswith("claude-code"):
|
|
146
|
+
# ~/.claude/projects/<slug>/<session>.jsonl — the slug is the cwd, escaped.
|
|
147
|
+
slug = path.parent.name
|
|
148
|
+
return _decode_cwd(slug) if slug.startswith("-") else slug
|
|
149
|
+
if fmt.startswith("codex"):
|
|
150
|
+
for line in head_lines(path, 5):
|
|
151
|
+
payload = line.get("payload")
|
|
152
|
+
if isinstance(payload, dict) and payload.get("cwd"):
|
|
153
|
+
return payload["cwd"]
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# The name is written again on every turn, so the newest sits near the end.
|
|
158
|
+
# Reading a tail costs nothing; reading a 14 MB transcript to find it would.
|
|
159
|
+
TITLE_TAIL = 256 * 1024
|
|
160
|
+
TITLE_HEAD = 64 * 1024
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _session_title(path: Path, fmt: str) -> str | None:
|
|
164
|
+
"""The name Claude Code gave a session, newest first.
|
|
165
|
+
|
|
166
|
+
Falls back to the head, where the first name it chose lives, so a session
|
|
167
|
+
whose last turn was enormous still shows something rather than a path.
|
|
168
|
+
"""
|
|
169
|
+
if not fmt.startswith("claude-code"):
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
with path.open("rb") as handle:
|
|
173
|
+
size = handle.seek(0, 2)
|
|
174
|
+
handle.seek(max(0, size - TITLE_TAIL))
|
|
175
|
+
tail = handle.read()
|
|
176
|
+
if size > TITLE_TAIL:
|
|
177
|
+
tail = tail.split(b"\n", 1)[-1] # a half line helps nobody
|
|
178
|
+
handle.seek(0)
|
|
179
|
+
head = handle.read(TITLE_HEAD)
|
|
180
|
+
except OSError:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
for chunk, newest_last in ((tail, True), (head, False)):
|
|
184
|
+
found = None
|
|
185
|
+
for line in chunk.splitlines():
|
|
186
|
+
if b'"ai-title"' not in line:
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
row = json.loads(line)
|
|
190
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
191
|
+
continue
|
|
192
|
+
if isinstance(row, dict) and row.get("aiTitle"):
|
|
193
|
+
found = str(row["aiTitle"])
|
|
194
|
+
if not newest_last:
|
|
195
|
+
return found
|
|
196
|
+
if found:
|
|
197
|
+
return found
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def describe(path: Path, origin: str = "scanned") -> Entry | None:
|
|
202
|
+
"""Build an index entry for one file, or None if it is not convertible."""
|
|
203
|
+
try:
|
|
204
|
+
fmt = detect_format(path)
|
|
205
|
+
except (ValueError, OSError):
|
|
206
|
+
return None
|
|
207
|
+
stat = path.stat()
|
|
208
|
+
if OPENED_ROOT in path.parents:
|
|
209
|
+
origin = "opened"
|
|
210
|
+
lines = head_lines(path, 20) if fmt not in ("har", "atif") else []
|
|
211
|
+
sub_dir = path.parent / path.stem / "subagents"
|
|
212
|
+
return Entry(
|
|
213
|
+
key=content_key(path),
|
|
214
|
+
origin=origin,
|
|
215
|
+
path=str(path),
|
|
216
|
+
format=fmt,
|
|
217
|
+
agent=AGENTS.get(fmt, "unknown"),
|
|
218
|
+
session_id=_session_id(fmt, lines),
|
|
219
|
+
project=_project(path, fmt),
|
|
220
|
+
modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc)
|
|
221
|
+
.isoformat()
|
|
222
|
+
.replace("+00:00", "Z"),
|
|
223
|
+
size_bytes=stat.st_size,
|
|
224
|
+
subagents=len(list(sub_dir.glob("*.jsonl"))) if sub_dir.is_dir() else 0,
|
|
225
|
+
session_title=_session_title(path, fmt),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def merge(existing: list[Entry], new: list[Entry]) -> list[Entry]:
|
|
230
|
+
"""Combine index entries, newest first.
|
|
231
|
+
|
|
232
|
+
One row per file, and one row per identity. A path has a single current
|
|
233
|
+
entry, so a file that grew replaces its own older row rather than adding
|
|
234
|
+
one. Identical content in two places is one document, and the copy that is
|
|
235
|
+
actually on disk wins.
|
|
236
|
+
"""
|
|
237
|
+
by_path: dict[str, Entry] = {}
|
|
238
|
+
for entry in [*existing, *new]:
|
|
239
|
+
by_path[entry.path] = entry
|
|
240
|
+
|
|
241
|
+
grouped: dict[str, list[Entry]] = {}
|
|
242
|
+
for entry in by_path.values():
|
|
243
|
+
grouped.setdefault(entry.key, []).append(entry)
|
|
244
|
+
|
|
245
|
+
# Of the places one identity lives, the row goes to the copy that is on
|
|
246
|
+
# disk, and to the most recent of those.
|
|
247
|
+
out = [
|
|
248
|
+
max(group, key=lambda e: (Path(e.path).exists(), e.modified))
|
|
249
|
+
for group in grouped.values()
|
|
250
|
+
]
|
|
251
|
+
return sorted(out, key=lambda e: e.modified, reverse=True)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def scan(roots: list[Path] | None = None, origin: str = "scanned") -> list[Entry]:
|
|
255
|
+
"""Find every convertible log under ``roots``. A root may be a single file."""
|
|
256
|
+
entries: list[Entry] = []
|
|
257
|
+
seen: set[Path] = set()
|
|
258
|
+
|
|
259
|
+
for root in roots or list(DEFAULT_ROOTS):
|
|
260
|
+
if not root.exists():
|
|
261
|
+
continue
|
|
262
|
+
if root.is_file():
|
|
263
|
+
# A zip or tarball is a container of logs, not a log.
|
|
264
|
+
if is_archive(root):
|
|
265
|
+
try:
|
|
266
|
+
root = extract(root)
|
|
267
|
+
except (ValueError, OSError):
|
|
268
|
+
continue
|
|
269
|
+
else:
|
|
270
|
+
entry = describe(root, origin)
|
|
271
|
+
if entry:
|
|
272
|
+
entries.append(entry)
|
|
273
|
+
continue
|
|
274
|
+
# A directory of archives is as ordinary as a directory of logs — a
|
|
275
|
+
# bucket of agent runs is mostly zips — so look inside them too. Each is
|
|
276
|
+
# unpacked once and its contents scanned in place.
|
|
277
|
+
roots_here = [root]
|
|
278
|
+
for archive in sorted(root.rglob("*")):
|
|
279
|
+
if not archive.is_file() or not is_archive(archive):
|
|
280
|
+
continue
|
|
281
|
+
try:
|
|
282
|
+
roots_here.append(extract(archive))
|
|
283
|
+
except (ValueError, OSError):
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
candidates = []
|
|
287
|
+
for where in roots_here:
|
|
288
|
+
candidates += (
|
|
289
|
+
sorted(where.rglob("*.jsonl"))
|
|
290
|
+
+ sorted(where.rglob("*.har"))
|
|
291
|
+
+ sorted(where.rglob("*.json"))
|
|
292
|
+
)
|
|
293
|
+
for path in candidates:
|
|
294
|
+
resolved = path.resolve()
|
|
295
|
+
if resolved in seen:
|
|
296
|
+
continue
|
|
297
|
+
# Subagent traces are reached through their parent, not indexed alone.
|
|
298
|
+
if path.parent.name == "subagents":
|
|
299
|
+
continue
|
|
300
|
+
seen.add(resolved)
|
|
301
|
+
entry = describe(path, origin)
|
|
302
|
+
if entry:
|
|
303
|
+
entries.append(entry)
|
|
304
|
+
|
|
305
|
+
entries.sort(key=lambda e: e.modified, reverse=True)
|
|
306
|
+
return entries
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def save(entries: list[Entry], path: Path | None = None) -> Path:
|
|
310
|
+
# Resolved on call, not bound as a default: a default freezes at import and
|
|
311
|
+
# cannot be redirected, which let a test write to the real index.
|
|
312
|
+
path = path or INDEX_PATH
|
|
313
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
path.write_text(json.dumps([asdict(e) for e in entries], indent=2))
|
|
315
|
+
return path
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def load(path: Path | None = None) -> list[Entry]:
|
|
319
|
+
"""Read the index, tolerating rows written by an older version."""
|
|
320
|
+
path = path or INDEX_PATH
|
|
321
|
+
if not path.exists():
|
|
322
|
+
return []
|
|
323
|
+
try:
|
|
324
|
+
rows = json.loads(path.read_text())
|
|
325
|
+
except (OSError, json.JSONDecodeError):
|
|
326
|
+
return []
|
|
327
|
+
fields = {f.name for f in dataclass_fields(Entry)}
|
|
328
|
+
entries = []
|
|
329
|
+
for row in rows:
|
|
330
|
+
if not isinstance(row, dict) or "path" not in row:
|
|
331
|
+
continue
|
|
332
|
+
known = {k: v for k, v in row.items() if k in fields}
|
|
333
|
+
# An index written before content keys existed has neither field.
|
|
334
|
+
known.setdefault("key", content_key(Path(row["path"])))
|
|
335
|
+
known.setdefault("origin", "scanned")
|
|
336
|
+
try:
|
|
337
|
+
entries.append(Entry(**known))
|
|
338
|
+
except TypeError:
|
|
339
|
+
continue
|
|
340
|
+
return entries
|