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.
@@ -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