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
agent_trace/context.py ADDED
@@ -0,0 +1,385 @@
1
+ """
2
+ Context retrieval for coding agents.
3
+
4
+ Retrieves AI attribution metadata and conversation context for a file
5
+ (or line range). Two modes:
6
+
7
+ - **Default:** Attribution segments with metadata, conversation size
8
+ stats, and a short preview (~200 chars). Light enough to inline
9
+ in an agent's context window.
10
+
11
+ - **Full (--full):** Everything from default mode plus the complete
12
+ conversation transcript for each AI-attributed segment.
13
+
14
+ No external dependencies — stdlib only.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ from typing import Any
23
+
24
+ from .blame import blame_file
25
+ from .conversations import cache_path_for_sha, latest_sha_for_conversation
26
+ from .storage import resolve_project_id
27
+ from .summary import latest_summary_by_id
28
+
29
+
30
+ # ===================================================================
31
+ # Conversation content helpers
32
+ # ===================================================================
33
+
34
+ def _resolve_conversation_from_cache(
35
+ project_id: str, content_sha256: str,
36
+ ) -> str | None:
37
+ """Read full conversation bytes from the per-project cache."""
38
+ if not project_id or not content_sha256:
39
+ return None
40
+ p = cache_path_for_sha(project_id, content_sha256)
41
+ try:
42
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
43
+ return f.read()
44
+ except (OSError, IOError):
45
+ return None
46
+
47
+
48
+ def _content_sha_for_conversation_id(
49
+ project_id: str, conversation_id: str,
50
+ ) -> str | None:
51
+ """Latest cached ``content_sha256`` for a given ``conversation_id``."""
52
+ if not project_id or not conversation_id:
53
+ return None
54
+ return latest_sha_for_conversation(project_id, conversation_id)
55
+
56
+
57
+ def _compute_conversation_stats(content: str) -> dict[str, int]:
58
+ """Compute size statistics for a conversation transcript."""
59
+ lines = content.split("\n")
60
+ # Count turns heuristically: lines starting with common role prefixes
61
+ turn_prefixes = ("User:", "Human:", "Assistant:", "AI:", "System:",
62
+ "user:", "human:", "assistant:", "ai:", "system:",
63
+ "**User", "**Human", "**Assistant", "**AI",
64
+ "## User", "## Human", "## Assistant", "## AI",
65
+ "### User", "### Human", "### Assistant", "### AI")
66
+ turns = 0
67
+ for line in lines:
68
+ stripped = line.strip()
69
+ if any(stripped.startswith(p) for p in turn_prefixes):
70
+ turns += 1
71
+
72
+ # If no structured turns detected, estimate from content blocks
73
+ if turns == 0:
74
+ # Try JSON-style conversation (array of messages)
75
+ try:
76
+ parsed = json.loads(content)
77
+ if isinstance(parsed, list):
78
+ turns = len(parsed)
79
+ elif isinstance(parsed, dict) and "messages" in parsed:
80
+ turns = len(parsed["messages"])
81
+ except (json.JSONDecodeError, TypeError):
82
+ pass
83
+
84
+ return {
85
+ "characters": len(content),
86
+ "lines": len(lines),
87
+ "turns": max(turns, 1), # At least 1 if content exists
88
+ }
89
+
90
+
91
+ def _extract_preview(content: str, max_chars: int = 200) -> str:
92
+ """Extract the first ~max_chars of conversation content as a preview."""
93
+ content = content.strip()
94
+ if len(content) <= max_chars:
95
+ return content
96
+ return content[:max_chars] + "..."
97
+
98
+
99
+ # ===================================================================
100
+ # Core context pipeline
101
+ # ===================================================================
102
+
103
+ def get_context(
104
+ file_path: str,
105
+ *,
106
+ start_line: int | None = None,
107
+ end_line: int | None = None,
108
+ full: bool = False,
109
+ query: str | None = None,
110
+ project_dir: str | None = None,
111
+ ) -> list[dict[str, Any]]:
112
+ """Run the context pipeline: blame → resolve conversations → build segments.
113
+
114
+ Returns a list of context segments, each with attribution metadata
115
+ and conversation info.
116
+ """
117
+ cwd = project_dir or os.getcwd()
118
+
119
+ pid = resolve_project_id(cwd, create=False)
120
+ summary_lookup = latest_summary_by_id(pid) if pid else {}
121
+
122
+ # Run blame in JSON mode to get structured attribution data
123
+ blame_json = blame_file(
124
+ file_path,
125
+ start_line=start_line,
126
+ end_line=end_line,
127
+ json_output=True,
128
+ show_no_attribution=True,
129
+ project_dir=cwd,
130
+ )
131
+
132
+ if blame_json is None:
133
+ return []
134
+
135
+ try:
136
+ blame_data = json.loads(blame_json)
137
+ except json.JSONDecodeError:
138
+ return []
139
+
140
+ attributions = blame_data.get("attributions", [])
141
+ if not attributions:
142
+ return []
143
+
144
+ # Build context segments from attributions
145
+ segments: list[dict[str, Any]] = []
146
+
147
+ for attr in attributions:
148
+ attr_start = attr.get("start_line", 0)
149
+ attr_end = attr.get("end_line", 0)
150
+
151
+ # Determine attribution type (deterministic blame: kind + ledger labels)
152
+ kind = attr.get("kind", "")
153
+ attribution_label = attr.get("attribution_label", "")
154
+ contributor_type = attr.get("contributor_type", "")
155
+
156
+ is_ai = (
157
+ kind == "AI"
158
+ or attribution_label == "AI"
159
+ or contributor_type == "ai"
160
+ )
161
+
162
+ if not is_ai:
163
+ # Anything that isn't a verified AI match is NO_ATTRIBUTION.
164
+ segments.append({
165
+ "start_line": attr_start,
166
+ "end_line": attr_end,
167
+ "attribution": "no_attribution",
168
+ })
169
+ continue
170
+
171
+ # AI attribution — resolve conversation context
172
+ segment: dict[str, Any] = {
173
+ "start_line": attr_start,
174
+ "end_line": attr_end,
175
+ "attribution": "ai",
176
+ }
177
+
178
+ # Attribution metadata
179
+ model_id = attr.get("model_id")
180
+ if model_id:
181
+ segment["model_id"] = model_id
182
+
183
+ tool = attr.get("tool")
184
+ if tool:
185
+ if isinstance(tool, dict):
186
+ segment["tool"] = tool.get("name", "")
187
+ else:
188
+ segment["tool"] = str(tool)
189
+
190
+ trace_id = attr.get("trace_id")
191
+ if trace_id:
192
+ segment["trace_id"] = trace_id
193
+
194
+ confidence = attr.get("confidence", 0.0)
195
+ segment["confidence"] = confidence
196
+
197
+ conversation_id = attr.get("conversation_id")
198
+ if conversation_id:
199
+ segment["conversation_id"] = conversation_id
200
+
201
+ # Pluggable summary (id-keyed) takes precedence over the raw preview.
202
+ summary_text = summary_lookup.get(conversation_id) if conversation_id else None
203
+ if summary_text:
204
+ segment["summary"] = summary_text
205
+
206
+ # Try to resolve conversation content from the local content-addressed cache.
207
+ conversation_content = None
208
+ if conversation_id and pid:
209
+ sha = _content_sha_for_conversation_id(pid, conversation_id)
210
+ if sha:
211
+ conversation_content = _resolve_conversation_from_cache(pid, sha)
212
+
213
+ if conversation_content:
214
+ # Compute size stats
215
+ segment["conversation_size"] = _compute_conversation_stats(
216
+ conversation_content,
217
+ )
218
+ # Preview is only useful as a fallback when no summary exists.
219
+ if not summary_text:
220
+ segment["preview"] = _extract_preview(conversation_content)
221
+
222
+ # Include full content only when requested
223
+ if full:
224
+ segment["conversation_content"] = conversation_content
225
+ else:
226
+ # No content available — still include URL if present
227
+ segment["conversation_size"] = None
228
+ if not summary_text:
229
+ segment["preview"] = None
230
+
231
+ # Pass through query for subagent instruction forwarding
232
+ if query:
233
+ segment["query"] = query
234
+
235
+ segments.append(segment)
236
+
237
+ return segments
238
+
239
+
240
+ # ===================================================================
241
+ # Output formatting
242
+ # ===================================================================
243
+
244
+ # ANSI colour codes
245
+ _BOLD = "\033[1m"
246
+ _DIM = "\033[2m"
247
+ _GREEN = "\033[32m"
248
+ _CYAN = "\033[36m"
249
+ _RESET = "\033[0m"
250
+
251
+
252
+ def format_text(file_path: str, segments: list[dict[str, Any]], full: bool = False) -> str:
253
+ """Format context segments as human-readable text."""
254
+ lines: list[str] = []
255
+ lines.append("")
256
+ lines.append(f" {_BOLD}{file_path}{_RESET}")
257
+ lines.append("")
258
+
259
+ for seg in segments:
260
+ start = seg.get("start_line", 0)
261
+ end = seg.get("end_line", 0)
262
+ attribution = seg.get("attribution", "no_attribution")
263
+
264
+ if start == end:
265
+ lr = f"L{start}"
266
+ else:
267
+ lr = f"L{start}-{end}"
268
+
269
+ if attribution != "ai":
270
+ lines.append(f" {lr:<14}{_DIM}No attribution{_RESET}")
271
+ continue
272
+
273
+ model_id = seg.get("model_id", "")
274
+ tool = seg.get("tool", "")
275
+
276
+ model_tool = model_id
277
+ if tool:
278
+ model_tool = f"{model_id} via {tool}" if model_id else tool
279
+
280
+ lines.append(f" {lr:<14}{_GREEN}AI{_RESET} ({model_tool})")
281
+
282
+ summary = seg.get("summary")
283
+ if summary:
284
+ lines.append(f" Summary: {summary}")
285
+
286
+ # Conversation size — only useful alongside the raw preview fallback.
287
+ conv_size = seg.get("conversation_size")
288
+ if conv_size and not summary:
289
+ chars = conv_size["characters"]
290
+ conv_lines = conv_size["lines"]
291
+ turns = conv_size["turns"]
292
+ lines.append(
293
+ f" {_DIM}Conversation: {chars:,} chars, "
294
+ f"{conv_lines:,} lines, {turns} turns{_RESET}"
295
+ )
296
+
297
+ # Preview — only when no generated summary is available.
298
+ preview = seg.get("preview")
299
+ if preview and not summary:
300
+ preview_line = preview.replace("\n", " ").strip()
301
+ if len(preview_line) > 120:
302
+ preview_line = preview_line[:120] + "..."
303
+ lines.append(f" Preview: \"{preview_line}\"")
304
+
305
+ # Full content
306
+ if full and seg.get("conversation_content"):
307
+ lines.append(f" {_CYAN}--- Full transcript ---{_RESET}")
308
+ for content_line in seg["conversation_content"].split("\n"):
309
+ lines.append(f" {content_line}")
310
+ lines.append(f" {_CYAN}--- End transcript ---{_RESET}")
311
+
312
+ # Hint for full retrieval
313
+ if not full and conv_size:
314
+ lines.append(
315
+ f" Full transcript: "
316
+ f"agent-trace context {file_path} --lines {start}-{end} --full"
317
+ )
318
+
319
+ # Query passthrough
320
+ if seg.get("query"):
321
+ lines.append(f" {_DIM}Query: {seg['query']}{_RESET}")
322
+
323
+ lines.append("")
324
+ return "\n".join(lines)
325
+
326
+
327
+ def format_json(
328
+ file_path: str,
329
+ segments: list[dict[str, Any]],
330
+ ) -> str:
331
+ """Format context segments as JSON."""
332
+ output = {
333
+ "file": file_path,
334
+ "segments": segments,
335
+ }
336
+ return json.dumps(output, indent=2)
337
+
338
+
339
+ # ===================================================================
340
+ # CLI entry point
341
+ # ===================================================================
342
+
343
+ def context_command(
344
+ file_path: str,
345
+ *,
346
+ lines_range: str | None = None,
347
+ full: bool = False,
348
+ json_output: bool = False,
349
+ query: str | None = None,
350
+ project_dir: str | None = None,
351
+ ) -> None:
352
+ """Execute the context command (called from cli.py)."""
353
+ # Parse --lines range
354
+ start_line = None
355
+ end_line = None
356
+ if lines_range:
357
+ parts = lines_range.split("-", 1)
358
+ try:
359
+ start_line = int(parts[0])
360
+ end_line = int(parts[1]) if len(parts) > 1 else start_line
361
+ except (ValueError, IndexError):
362
+ print(f"Invalid lines range: {lines_range} (expected format: START-END)",
363
+ file=sys.stderr)
364
+ sys.exit(1)
365
+
366
+ segments = get_context(
367
+ file_path,
368
+ start_line=start_line,
369
+ end_line=end_line,
370
+ full=full,
371
+ query=query,
372
+ project_dir=project_dir,
373
+ )
374
+
375
+ if not segments:
376
+ if json_output:
377
+ print(format_json(file_path, []))
378
+ else:
379
+ print(f"\n No attribution data found for {file_path}\n")
380
+ return
381
+
382
+ if json_output:
383
+ print(format_json(file_path, segments))
384
+ else:
385
+ print(format_text(file_path, segments, full=full))