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.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. 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