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,167 @@
1
+ """Trace metadata for ledger enrichment (internal)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def _load_local_traces(project_dir: str) -> list[dict[str, Any]]:
11
+ from .storage import get_traces_path, resolve_project_id
12
+
13
+ pid = resolve_project_id(project_dir, create=False)
14
+ if not pid:
15
+ return []
16
+ traces_path = get_traces_path(pid)
17
+ if not traces_path.exists():
18
+ return []
19
+ traces: list[dict[str, Any]] = []
20
+ try:
21
+ for line in traces_path.read_text().splitlines():
22
+ line = line.strip()
23
+ if line:
24
+ try:
25
+ traces.append(json.loads(line))
26
+ except json.JSONDecodeError:
27
+ continue
28
+ except OSError:
29
+ pass
30
+ return traces
31
+
32
+
33
+ def _load_conversation_preview(
34
+ project_id: str | None,
35
+ content_sha256: str | None,
36
+ max_chars: int = 200,
37
+ ) -> str | None:
38
+ """First ``max_chars`` of the cached transcript bytes referenced by
39
+ ``content_sha256``. Distinct from a generated summary — this is just a
40
+ raw head-of-blob preview used as a fallback when no summary is
41
+ configured/available.
42
+ """
43
+ if not project_id or not content_sha256:
44
+ return None
45
+ from .conversations import cache_path_for_sha
46
+
47
+ p = cache_path_for_sha(project_id, content_sha256)
48
+ if not p.is_file():
49
+ return None
50
+ try:
51
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
52
+ content = f.read(max_chars + 100)
53
+ except OSError:
54
+ return None
55
+ content = content.strip()
56
+ if not content:
57
+ return None
58
+ if len(content) > max_chars:
59
+ content = content[:max_chars] + "..."
60
+ return content
61
+
62
+
63
+ def _find_matching_file(files: list[dict[str, Any]], file_path: str) -> dict[str, Any] | None:
64
+ for f in files:
65
+ if not isinstance(f, dict):
66
+ continue
67
+ trace_path = f.get("path", "")
68
+ if trace_path == file_path:
69
+ return f
70
+ if trace_path.endswith(file_path) or file_path.endswith(trace_path):
71
+ return f
72
+ return None
73
+
74
+
75
+ def _collect_ranges(file_entry: dict[str, Any]) -> list[tuple[int, int]]:
76
+ ranges: list[tuple[int, int]] = []
77
+ if "start_line" in file_entry and "end_line" in file_entry:
78
+ try:
79
+ ranges.append((int(file_entry["start_line"]), int(file_entry["end_line"])))
80
+ except (ValueError, TypeError):
81
+ pass
82
+ for conv in file_entry.get("conversations", []):
83
+ if not isinstance(conv, dict):
84
+ continue
85
+ if "start_line" in conv and "end_line" in conv:
86
+ try:
87
+ ranges.append((int(conv["start_line"]), int(conv["end_line"])))
88
+ except (ValueError, TypeError):
89
+ pass
90
+ for r in conv.get("ranges", []):
91
+ if isinstance(r, dict) and "start_line" in r and "end_line" in r:
92
+ try:
93
+ ranges.append((int(r["start_line"]), int(r["end_line"])))
94
+ except (ValueError, TypeError):
95
+ pass
96
+ for change in file_entry.get("changes", []):
97
+ if not isinstance(change, dict):
98
+ continue
99
+ if "start_line" in change and "end_line" in change:
100
+ try:
101
+ ranges.append((int(change["start_line"]), int(change["end_line"])))
102
+ except (ValueError, TypeError):
103
+ pass
104
+ return ranges
105
+
106
+
107
+ def _extract_trace_meta(
108
+ trace: dict[str, Any],
109
+ file_path: str,
110
+ line_number: int,
111
+ ) -> dict[str, Any]:
112
+ meta: dict[str, Any] = {"trace_id": trace.get("id")}
113
+ if trace.get("timestamp"):
114
+ meta["timestamp"] = trace["timestamp"]
115
+ tool = trace.get("tool")
116
+ if isinstance(tool, dict):
117
+ meta["tool"] = tool
118
+ files_data = trace.get("files") or []
119
+ matched_file = _find_matching_file(files_data, file_path)
120
+ if matched_file:
121
+ for conv in matched_file.get("conversations", []):
122
+ if not isinstance(conv, dict):
123
+ continue
124
+ contributor = conv.get("contributor") or {}
125
+ if contributor.get("model_id") and not meta.get("model_id"):
126
+ meta["model_id"] = contributor["model_id"]
127
+ if contributor.get("type") and not meta.get("contributor_type"):
128
+ meta["contributor_type"] = contributor["type"]
129
+ if conv.get("id") and not meta.get("conversation_id"):
130
+ meta["conversation_id"] = conv["id"]
131
+ if conv.get("content_sha256") and not meta.get("content_sha256"):
132
+ meta["content_sha256"] = conv["content_sha256"]
133
+ if meta.get("model_id") and meta.get("conversation_id"):
134
+ break
135
+ ranges = _collect_ranges(matched_file)
136
+ best = None
137
+ best_dist = float("inf")
138
+ for start, end in ranges:
139
+ if start <= line_number <= end:
140
+ span = end - start
141
+ if best is None or span < (best[1] - best[0]):
142
+ best = (start, end)
143
+ best_dist = 0
144
+ else:
145
+ dist = min(abs(line_number - start), abs(line_number - end))
146
+ if dist < best_dist:
147
+ best = (start, end)
148
+ best_dist = dist
149
+ if best:
150
+ meta["matched_range"] = {"start_line": best[0], "end_line": best[1]}
151
+ if not meta.get("model_id") or not meta.get("conversation_id"):
152
+ for fe in files_data:
153
+ if not isinstance(fe, dict) or fe is matched_file:
154
+ continue
155
+ for conv in fe.get("conversations", []):
156
+ if not isinstance(conv, dict):
157
+ continue
158
+ contributor = conv.get("contributor") or {}
159
+ if contributor.get("model_id") and not meta.get("model_id"):
160
+ meta["model_id"] = contributor["model_id"]
161
+ if conv.get("id") and not meta.get("conversation_id"):
162
+ meta["conversation_id"] = conv["id"]
163
+ if conv.get("content_sha256") and not meta.get("content_sha256"):
164
+ meta["content_sha256"] = conv["content_sha256"]
165
+ if meta.get("model_id") and meta.get("conversation_id"):
166
+ break
167
+ return meta