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
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))
|