agent-trace-cli 0.1.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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversation transcripts: id computation, content-addressed local cache,
|
|
3
|
+
enumeration for sync.
|
|
4
|
+
|
|
5
|
+
Every transcript referenced by a trace is identified by an opaque
|
|
6
|
+
``conversation_id`` (sha256 over the original ``file://<abspath>`` URL).
|
|
7
|
+
The same id appears in trace records, ledger segments, git notes,
|
|
8
|
+
session summaries, and on the wire. The id is stable per
|
|
9
|
+
(machine, session) — no raw filesystem paths leak into any record that
|
|
10
|
+
travels between machines.
|
|
11
|
+
|
|
12
|
+
Transcript bytes are snapshotted into a per-project content-addressed
|
|
13
|
+
cache at hook time. Both the local machine and every machine that pulls
|
|
14
|
+
the conversation read transcript bytes through the same cache layout:
|
|
15
|
+
``<project>/conversations/<sha[:2]>/<content_sha256>``.
|
|
16
|
+
|
|
17
|
+
Stdlib only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Iterator
|
|
29
|
+
|
|
30
|
+
from .storage import (
|
|
31
|
+
get_ledgers_path,
|
|
32
|
+
get_project_dir,
|
|
33
|
+
get_traces_path,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Blobs at or under this size go inline in the conversations POST;
|
|
38
|
+
# larger blobs go through the chunked HEAD/POST blob path.
|
|
39
|
+
CHUNK_THRESHOLD_BYTES = 256 * 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# -------------------------------------------------------------------
|
|
43
|
+
# ID + content hashing
|
|
44
|
+
# -------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def compute_conversation_id(transcript_path: str) -> str:
|
|
47
|
+
"""Stable opaque id derived from the local transcript path.
|
|
48
|
+
|
|
49
|
+
Hashes the ``file://<absolute-path>`` URL form so the same recipe
|
|
50
|
+
is portable across the codebase. The hash is per-(machine, session);
|
|
51
|
+
two engineers never naturally collide on it, and that's correct —
|
|
52
|
+
each transcript is local to its machine.
|
|
53
|
+
"""
|
|
54
|
+
if not transcript_path:
|
|
55
|
+
return ""
|
|
56
|
+
return hashlib.sha256(f"file://{transcript_path}".encode("utf-8")).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def hash_bytes(data: bytes) -> str:
|
|
60
|
+
return hashlib.sha256(data).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hash_file(path: Path | str, *, chunk: int = 1024 * 1024) -> str:
|
|
64
|
+
h = hashlib.sha256()
|
|
65
|
+
with open(path, "rb") as f:
|
|
66
|
+
while True:
|
|
67
|
+
buf = f.read(chunk)
|
|
68
|
+
if not buf:
|
|
69
|
+
break
|
|
70
|
+
h.update(buf)
|
|
71
|
+
return h.hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -------------------------------------------------------------------
|
|
75
|
+
# Content-addressed cache (uniform on every machine)
|
|
76
|
+
# -------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def get_conversations_cache_dir(project_id: str) -> Path:
|
|
79
|
+
return get_project_dir(project_id) / "conversations"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cache_path_for_sha(project_id: str, sha256: str) -> Path:
|
|
83
|
+
return get_conversations_cache_dir(project_id) / sha256[:2] / sha256
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cache_has(project_id: str, sha256: str) -> bool:
|
|
87
|
+
return cache_path_for_sha(project_id, sha256).is_file()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_blob_to_cache(project_id: str, sha256: str, data: bytes) -> Path:
|
|
91
|
+
"""Write ``data`` to the cache, verifying it hashes to ``sha256``.
|
|
92
|
+
|
|
93
|
+
Raises ``ValueError`` on hash mismatch.
|
|
94
|
+
"""
|
|
95
|
+
actual = hash_bytes(data)
|
|
96
|
+
if actual != sha256:
|
|
97
|
+
raise ValueError(f"sha256 mismatch: expected {sha256}, got {actual}")
|
|
98
|
+
p = cache_path_for_sha(project_id, sha256)
|
|
99
|
+
if not p.is_file():
|
|
100
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
p.write_bytes(data)
|
|
102
|
+
return p
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_blob_from_cache(project_id: str, sha256: str) -> bytes | None:
|
|
106
|
+
p = cache_path_for_sha(project_id, sha256)
|
|
107
|
+
try:
|
|
108
|
+
return p.read_bytes()
|
|
109
|
+
except OSError:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def snapshot_transcript_to_cache(
|
|
114
|
+
project_id: str, transcript_path: str,
|
|
115
|
+
) -> tuple[str, int] | None:
|
|
116
|
+
"""Read the live transcript file and write its bytes into the cache.
|
|
117
|
+
|
|
118
|
+
Returns ``(content_sha256, size)`` or ``None`` if the file can't be read.
|
|
119
|
+
Idempotent: skips the cache write when a matching blob already exists.
|
|
120
|
+
|
|
121
|
+
Also updates the per-project ``conversation_id → latest content_sha256``
|
|
122
|
+
index so readers see the most recent snapshot even when later snapshots
|
|
123
|
+
aren't pinned by a trace (e.g., the session-end summary hook captures
|
|
124
|
+
the tail of the conversation after the last tool call).
|
|
125
|
+
"""
|
|
126
|
+
if not transcript_path:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
with open(transcript_path, "rb") as f:
|
|
130
|
+
data = f.read()
|
|
131
|
+
except OSError:
|
|
132
|
+
return None
|
|
133
|
+
sha = hash_bytes(data)
|
|
134
|
+
size = len(data)
|
|
135
|
+
p = cache_path_for_sha(project_id, sha)
|
|
136
|
+
if not p.is_file():
|
|
137
|
+
try:
|
|
138
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
p.write_bytes(data)
|
|
140
|
+
except OSError:
|
|
141
|
+
return None
|
|
142
|
+
cid = compute_conversation_id(transcript_path)
|
|
143
|
+
if cid:
|
|
144
|
+
update_conversation_index(project_id, cid, sha)
|
|
145
|
+
return sha, size
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# -------------------------------------------------------------------
|
|
149
|
+
# conversation_id → latest content_sha256 index (uniform on every machine)
|
|
150
|
+
# -------------------------------------------------------------------
|
|
151
|
+
#
|
|
152
|
+
# The cache is content-addressed, but a given ``conversation_id`` typically
|
|
153
|
+
# has multiple cached blobs over the life of a session (the transcript file
|
|
154
|
+
# keeps growing). Readers — blame, context, the viewer — want the *latest*
|
|
155
|
+
# snapshot, including the tail captured by the session-end summary hook
|
|
156
|
+
# that no trace record points at.
|
|
157
|
+
|
|
158
|
+
def conversation_index_path(project_id: str) -> Path:
|
|
159
|
+
return get_conversations_cache_dir(project_id) / "_index.json"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def read_conversation_index(project_id: str) -> dict[str, str]:
|
|
163
|
+
p = conversation_index_path(project_id)
|
|
164
|
+
if not p.is_file():
|
|
165
|
+
return {}
|
|
166
|
+
try:
|
|
167
|
+
raw = json.loads(p.read_text())
|
|
168
|
+
except (json.JSONDecodeError, OSError):
|
|
169
|
+
return {}
|
|
170
|
+
if not isinstance(raw, dict):
|
|
171
|
+
return {}
|
|
172
|
+
return {
|
|
173
|
+
str(k): str(v)
|
|
174
|
+
for k, v in raw.items()
|
|
175
|
+
if isinstance(k, str) and isinstance(v, str) and k and v
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def update_conversation_index(
|
|
180
|
+
project_id: str, conversation_id: str, content_sha256: str,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Upsert ``conversation_id → content_sha256``. Last write wins, which
|
|
183
|
+
is exactly what we want — every snapshot in the cache is valid; we just
|
|
184
|
+
point readers at the most recent one."""
|
|
185
|
+
if not conversation_id or not content_sha256:
|
|
186
|
+
return
|
|
187
|
+
p = conversation_index_path(project_id)
|
|
188
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
index = read_conversation_index(project_id)
|
|
190
|
+
if index.get(conversation_id) == content_sha256:
|
|
191
|
+
return
|
|
192
|
+
index[conversation_id] = content_sha256
|
|
193
|
+
try:
|
|
194
|
+
p.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n")
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def latest_sha_for_conversation(
|
|
200
|
+
project_id: str, conversation_id: str,
|
|
201
|
+
) -> str | None:
|
|
202
|
+
"""Resolve a ``conversation_id`` to its current ``content_sha256`` in the
|
|
203
|
+
local cache, or ``None`` if unknown.
|
|
204
|
+
|
|
205
|
+
Auto-seeds the index from trace records the first time it's consulted
|
|
206
|
+
on a project that pre-dates the index (one-shot, safe to re-run — the
|
|
207
|
+
most recent trace-pinned sha wins for each id).
|
|
208
|
+
"""
|
|
209
|
+
if not conversation_id:
|
|
210
|
+
return None
|
|
211
|
+
index = read_conversation_index(project_id)
|
|
212
|
+
if not index:
|
|
213
|
+
_seed_index_from_traces(project_id)
|
|
214
|
+
index = read_conversation_index(project_id)
|
|
215
|
+
return index.get(conversation_id) or None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _seed_index_from_traces(project_id: str) -> None:
|
|
219
|
+
"""Walk traces.jsonl once and write the index from the trace-pinned
|
|
220
|
+
pairs we find. Used to rescue data written before the index existed.
|
|
221
|
+
|
|
222
|
+
Note: this is a best-effort fallback. Snapshots written by the
|
|
223
|
+
session-end summary hook (which no trace pins) won't appear here, but
|
|
224
|
+
every subsequent snapshot will keep the index current.
|
|
225
|
+
"""
|
|
226
|
+
p = conversation_index_path(project_id)
|
|
227
|
+
if p.is_file():
|
|
228
|
+
return
|
|
229
|
+
traces_path = get_traces_path(project_id)
|
|
230
|
+
if not traces_path.is_file():
|
|
231
|
+
return
|
|
232
|
+
latest: dict[str, str] = {}
|
|
233
|
+
try:
|
|
234
|
+
for line in traces_path.read_text().splitlines():
|
|
235
|
+
line = line.strip()
|
|
236
|
+
if not line:
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
rec = json.loads(line)
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
continue
|
|
242
|
+
for cid, sha in _conv_pairs_from_trace(rec):
|
|
243
|
+
latest[cid] = sha
|
|
244
|
+
except OSError:
|
|
245
|
+
return
|
|
246
|
+
if not latest:
|
|
247
|
+
return
|
|
248
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
try:
|
|
250
|
+
p.write_text(json.dumps(latest, indent=2, sort_keys=True) + "\n")
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# -------------------------------------------------------------------
|
|
256
|
+
# Enumeration of pending uploads
|
|
257
|
+
# -------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
@dataclass
|
|
260
|
+
class ConversationBlob:
|
|
261
|
+
"""A (conversation_id, content_sha256) pair pending upload."""
|
|
262
|
+
|
|
263
|
+
conversation_id: str
|
|
264
|
+
content_sha256: str
|
|
265
|
+
size: int
|
|
266
|
+
mtime: str # ISO-8601 UTC; from the cache file's mtime
|
|
267
|
+
|
|
268
|
+
def is_chunked(self, threshold: int = CHUNK_THRESHOLD_BYTES) -> bool:
|
|
269
|
+
return self.size > threshold
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _conv_pairs_from_trace(rec: dict) -> Iterator[tuple[str, str]]:
|
|
273
|
+
for fe in rec.get("files", []) or []:
|
|
274
|
+
for conv in fe.get("conversations", []) or []:
|
|
275
|
+
cid = conv.get("id")
|
|
276
|
+
sha = conv.get("content_sha256")
|
|
277
|
+
if isinstance(cid, str) and cid and isinstance(sha, str) and sha:
|
|
278
|
+
yield cid, sha
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _conv_pairs_from_ledger(rec: dict) -> Iterator[tuple[str, str | None]]:
|
|
282
|
+
for _, fa in (rec.get("files") or {}).items():
|
|
283
|
+
for seg in (fa.get("line_attributions") or []):
|
|
284
|
+
cid = seg.get("conversation_id")
|
|
285
|
+
if isinstance(cid, str) and cid:
|
|
286
|
+
yield cid, None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _iter_conversation_pairs(project_id: str) -> dict[str, str]:
|
|
290
|
+
"""Map ``conversation_id → latest content_sha256`` from local records.
|
|
291
|
+
|
|
292
|
+
The cross-snapshot index is authoritative: it captures every snapshot
|
|
293
|
+
we've written, including the session-end summary snapshot that no trace
|
|
294
|
+
pins. Traces serve as a fallback when the index is missing (e.g.,
|
|
295
|
+
pre-index data).
|
|
296
|
+
"""
|
|
297
|
+
latest: dict[str, str] = dict(read_conversation_index(project_id))
|
|
298
|
+
|
|
299
|
+
traces_path = get_traces_path(project_id)
|
|
300
|
+
if traces_path.is_file():
|
|
301
|
+
try:
|
|
302
|
+
for line in traces_path.read_text().splitlines():
|
|
303
|
+
line = line.strip()
|
|
304
|
+
if not line:
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
rec = json.loads(line)
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
continue
|
|
310
|
+
for cid, sha in _conv_pairs_from_trace(rec):
|
|
311
|
+
latest.setdefault(cid, sha)
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
# Ledger entries may name conversation_ids that have no trace locally
|
|
316
|
+
# (e.g. pulled from a remote). They contribute no sha pin.
|
|
317
|
+
led_path = get_ledgers_path(project_id)
|
|
318
|
+
if led_path.is_file():
|
|
319
|
+
try:
|
|
320
|
+
for line in led_path.read_text().splitlines():
|
|
321
|
+
line = line.strip()
|
|
322
|
+
if not line:
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
rec = json.loads(line)
|
|
326
|
+
except json.JSONDecodeError:
|
|
327
|
+
continue
|
|
328
|
+
for cid, _ in _conv_pairs_from_ledger(rec):
|
|
329
|
+
latest.setdefault(cid, "")
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
return latest
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def enumerate_local_blobs(project_id: str) -> list[ConversationBlob]:
|
|
337
|
+
"""``ConversationBlob`` per (conversation_id, content_sha256) pair whose
|
|
338
|
+
bytes are present in the local cache. Skips ids whose snapshot has not
|
|
339
|
+
yet been materialised (e.g. ledger-only references without a cache hit).
|
|
340
|
+
"""
|
|
341
|
+
out: list[ConversationBlob] = []
|
|
342
|
+
for cid, sha in _iter_conversation_pairs(project_id).items():
|
|
343
|
+
if not sha:
|
|
344
|
+
continue
|
|
345
|
+
p = cache_path_for_sha(project_id, sha)
|
|
346
|
+
if not p.is_file():
|
|
347
|
+
continue
|
|
348
|
+
try:
|
|
349
|
+
st = os.stat(p)
|
|
350
|
+
except OSError:
|
|
351
|
+
continue
|
|
352
|
+
out.append(ConversationBlob(
|
|
353
|
+
conversation_id=cid,
|
|
354
|
+
content_sha256=sha,
|
|
355
|
+
size=st.st_size,
|
|
356
|
+
mtime=datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(),
|
|
357
|
+
))
|
|
358
|
+
return out
|