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,169 @@
1
+ """What you decide about a transcript, as opposed to what it is.
2
+
3
+ Titles, tags and stars live here; paths, sizes and formats stay in
4
+ atif-make's index. Keeping them apart means re-indexing can never destroy an
5
+ annotation, and an annotation survives its file being re-scanned from somewhere
6
+ new — records are keyed by content, so a file that moves keeps everything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import store
17
+
18
+ LIBRARY_PATH = store.ROOT / "library.json"
19
+ VERSION = 1
20
+
21
+ # Two viewers can be open at once; serialise our own writes and let the atomic
22
+ # replace settle the rest.
23
+ _lock = threading.Lock()
24
+
25
+ _DEFAULTS: dict[str, Any] = {
26
+ "title": "",
27
+ # Where a fetched session came from. This is what places it in the tree, what
28
+ # the tree links back to, and what a refresh asks again.
29
+ "source": "",
30
+ # The folder that fetch was told to download into, so a refresh puts new
31
+ # files beside the old ones rather than somewhere else.
32
+ "into": "",
33
+ "tags": [],
34
+ "starred": False,
35
+ "note": "",
36
+ # Individual steps starred inside a transcript. Held as the viewer's step
37
+ # keys — "12" at the top level, "<trajectory>-3" inside a subagent, whose
38
+ # ids restart at 1 — so a star cannot land on the wrong step.
39
+ "starred_steps": [],
40
+ # Summaries the model has already produced, by tool-call id. Cached here so
41
+ # a call is paid for once, and so they survive a restart like any other note.
42
+ "summaries": {},
43
+ # Whether this transcript may be sent to the model at all. On by default,
44
+ # but a single switch turns every AI control off for one session — useful
45
+ # when a transcript holds something that should not leave the machine.
46
+ "ai": True,
47
+ }
48
+
49
+
50
+ def _now() -> str:
51
+ return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
52
+
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)
57
+ entries = data.get("entries")
58
+ if not isinstance(entries, dict):
59
+ return {}
60
+ return {k: v for k, v in entries.items() if isinstance(v, dict)}
61
+
62
+
63
+ def save(entries: dict[str, dict], path: Path | None = None) -> None:
64
+ """Write the whole library atomically."""
65
+ store.write_json(path or LIBRARY_PATH, {"version": VERSION, "entries": entries})
66
+
67
+
68
+ def get(key: str, path: Path | None = None) -> dict:
69
+ """One record, with defaults filled in, whether or not it is annotated."""
70
+ path = path or LIBRARY_PATH
71
+ return {**_DEFAULTS, **load(path).get(key, {})}
72
+
73
+
74
+ def _clean_tags(value: Any) -> list[str]:
75
+ """Tags are short, unique, lower-case labels — order preserved."""
76
+ if not isinstance(value, list):
77
+ return []
78
+ seen: set[str] = set()
79
+ out: list[str] = []
80
+ for tag in value:
81
+ if not isinstance(tag, str):
82
+ continue
83
+ tag = tag.strip().lower()[:40]
84
+ if tag and tag not in seen:
85
+ seen.add(tag)
86
+ out.append(tag)
87
+ return out[:20]
88
+
89
+
90
+ def _clean_steps(value: Any) -> list[str]:
91
+ """Step keys, unique and order-preserved. Kept as strings because a
92
+ subagent's key is not a number."""
93
+ if not isinstance(value, list):
94
+ return []
95
+ seen: set[str] = set()
96
+ out: list[str] = []
97
+ for item in value:
98
+ key = str(item).strip()[:120]
99
+ if key and key not in seen:
100
+ seen.add(key)
101
+ out.append(key)
102
+ return out[:2000]
103
+
104
+
105
+ def update(key: str, path: Path | None = None, **fields: Any) -> dict:
106
+ """Merge fields into one record and persist. Returns the merged record."""
107
+ if not key:
108
+ raise ValueError("a key is required")
109
+ path = path or LIBRARY_PATH
110
+ with _lock:
111
+ entries = load(path)
112
+ record = {**_DEFAULTS, **entries.get(key, {})}
113
+ for name, value in fields.items():
114
+ if name not in _DEFAULTS:
115
+ continue
116
+ if name == "tags":
117
+ record["tags"] = _clean_tags(value)
118
+ elif name == "starred_steps":
119
+ record["starred_steps"] = _clean_steps(value)
120
+ elif name == "summaries":
121
+ record["summaries"] = {
122
+ str(k)[:120]: str(v)[:4000]
123
+ for k, v in (value or {}).items()
124
+ if isinstance(k, str) and isinstance(v, str)
125
+ } if isinstance(value, dict) else {}
126
+ elif name in ("starred", "ai"):
127
+ record[name] = bool(value)
128
+ else:
129
+ record[name] = str(value).strip()[:500] if value is not None else ""
130
+ record.setdefault("added", _now())
131
+ # An entry annotated back to its defaults is not worth keeping. Compared
132
+ # against the defaults rather than tested for falsiness, because a
133
+ # default can be True: "ai" is, so `any()` would keep every entry alive.
134
+ if {k: v for k, v in record.items() if k != "added"} != _DEFAULTS:
135
+ entries[key] = record
136
+ else:
137
+ entries.pop(key, None)
138
+ save(entries, path)
139
+ return record
140
+
141
+
142
+ def remove(key: str, path: Path | None = None) -> bool:
143
+ """Forget one record. True when there was something to forget."""
144
+ path = path or LIBRARY_PATH
145
+ with _lock:
146
+ entries = load(path)
147
+ existed = entries.pop(key, None) is not None
148
+ if existed:
149
+ save(entries, path)
150
+ return existed
151
+
152
+
153
+ def tags(path: Path | None = None) -> list[tuple[str, int]]:
154
+ """Every tag with a count, most used first."""
155
+ path = path or LIBRARY_PATH
156
+ counts: dict[str, int] = {}
157
+ for record in load(path).values():
158
+ for tag in record.get("tags") or []:
159
+ counts[tag] = counts.get(tag, 0) + 1
160
+ return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
161
+
162
+
163
+ def decorate(rows: list[dict], path: Path | None = None) -> list[dict]:
164
+ """Fold annotations into index rows so a client gets one object per session."""
165
+ path = path or LIBRARY_PATH
166
+ entries = load(path)
167
+ for row in rows:
168
+ row.update({**_DEFAULTS, **entries.get(row.get("key", ""), {})})
169
+ return rows