agentprof 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.
- agentprof/__init__.py +7 -0
- agentprof/adapters/__init__.py +15 -0
- agentprof/adapters/base.py +79 -0
- agentprof/adapters/claude_code/__init__.py +3 -0
- agentprof/adapters/claude_code/adapter.py +133 -0
- agentprof/adapters/claude_code/discovery.py +79 -0
- agentprof/adapters/claude_code/prompts.py +44 -0
- agentprof/adapters/claude_code/tools.py +102 -0
- agentprof/adapters/claude_code/transcript.py +264 -0
- agentprof/adapters/claude_code/tree.py +492 -0
- agentprof/adapters/codex/__init__.py +3 -0
- agentprof/adapters/codex/adapter.py +164 -0
- agentprof/adapters/codex/discovery.py +117 -0
- agentprof/adapters/codex/prompts.py +16 -0
- agentprof/adapters/codex/rollout.py +328 -0
- agentprof/adapters/codex/tools.py +97 -0
- agentprof/adapters/codex/tree.py +311 -0
- agentprof/adapters/copilot_vscode/__init__.py +3 -0
- agentprof/adapters/copilot_vscode/adapter.py +166 -0
- agentprof/adapters/copilot_vscode/debuglog.py +136 -0
- agentprof/adapters/copilot_vscode/discovery.py +153 -0
- agentprof/adapters/copilot_vscode/session.py +281 -0
- agentprof/adapters/copilot_vscode/tools.py +127 -0
- agentprof/adapters/copilot_vscode/transcript.py +176 -0
- agentprof/adapters/copilot_vscode/tree.py +325 -0
- agentprof/adapters/execution.py +64 -0
- agentprof/adapters/timestamps.py +13 -0
- agentprof/adapters/turns.py +24 -0
- agentprof/analysis/__init__.py +3 -0
- agentprof/analysis/agent_summary.py +161 -0
- agentprof/analysis/call_context.py +101 -0
- agentprof/analysis/evidence_findings.py +151 -0
- agentprof/analysis/execution.py +39 -0
- agentprof/analysis/heuristics.py +306 -0
- agentprof/analysis/pipeline.py +31 -0
- agentprof/analysis/rollup.py +196 -0
- agentprof/cli.py +141 -0
- agentprof/model.py +341 -0
- agentprof/pricing.json +23 -0
- agentprof/pricing.py +120 -0
- agentprof/registry.py +304 -0
- agentprof/server/__init__.py +3 -0
- agentprof/server/app.py +399 -0
- agentprof/server/openapi.py +31 -0
- agentprof/server/pricing_schema.py +40 -0
- agentprof/server/schemas.py +579 -0
- agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
- agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
- agentprof/server/static/index.html +14 -0
- agentprof-0.1.0.dist-info/METADATA +177 -0
- agentprof-0.1.0.dist-info/RECORD +54 -0
- agentprof-0.1.0.dist-info/WHEEL +4 -0
- agentprof-0.1.0.dist-info/entry_points.txt +8 -0
- agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Parse one Claude Code transcript file: the main session or one subagent."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agentprof.adapters.timestamps import parse_iso_ms
|
|
11
|
+
from agentprof.model import MalformedLineDetail
|
|
12
|
+
|
|
13
|
+
_MAX_MALFORMED_LINE_DETAILS = 20
|
|
14
|
+
_MAX_EXCERPT_LENGTH = 120
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Prompt:
|
|
19
|
+
"""A user prompt: typed text, a slash command or a task notification."""
|
|
20
|
+
|
|
21
|
+
uuid: str
|
|
22
|
+
start_ms: int
|
|
23
|
+
text: str
|
|
24
|
+
source_order: int | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AssistantMessage:
|
|
29
|
+
"""One LLM call; Claude Code writes one line per content block, all sharing `message_id`."""
|
|
30
|
+
|
|
31
|
+
message_id: str
|
|
32
|
+
start_ms: int
|
|
33
|
+
model: str | None
|
|
34
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
source_order: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ToolUse:
|
|
40
|
+
"""A `tool_use` block of an assistant message."""
|
|
41
|
+
|
|
42
|
+
tool_use_id: str
|
|
43
|
+
name: str
|
|
44
|
+
input: dict[str, object]
|
|
45
|
+
start_ms: int
|
|
46
|
+
source_order: int | None = None
|
|
47
|
+
request_message_id: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ToolResult:
|
|
52
|
+
"""The `tool_result` answering a `ToolUse` with the same id."""
|
|
53
|
+
|
|
54
|
+
end_ms: int
|
|
55
|
+
is_error: bool
|
|
56
|
+
text: str
|
|
57
|
+
source_order: int | None = None
|
|
58
|
+
next_message_id: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Transcript:
|
|
63
|
+
"""Everything agentprof needs from one transcript file, in file order.
|
|
64
|
+
|
|
65
|
+
`compactions_ms` are the timestamps of `compact_boundary` entries.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
prompts: list[Prompt] = field(default_factory=list)
|
|
69
|
+
messages: list[AssistantMessage] = field(default_factory=list)
|
|
70
|
+
tool_uses: list[ToolUse] = field(default_factory=list)
|
|
71
|
+
tool_results: dict[str, ToolResult] = field(default_factory=dict)
|
|
72
|
+
tool_result_records: list[tuple[str, ToolResult]] = field(default_factory=list)
|
|
73
|
+
source_stream_id: str | None = None
|
|
74
|
+
compactions_ms: list[int] = field(default_factory=list)
|
|
75
|
+
title: str | None = None
|
|
76
|
+
cwd: str | None = None
|
|
77
|
+
first_timestamp_ms: int | None = None
|
|
78
|
+
last_message_ms: int | None = None
|
|
79
|
+
malformed_lines: int = 0
|
|
80
|
+
malformed_line_details: list[MalformedLineDetail] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _redacted_excerpt(line: str) -> str:
|
|
84
|
+
"""Retain short JSON syntax shape while removing all string and scalar contents."""
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
in_string = False
|
|
87
|
+
escaped = False
|
|
88
|
+
redacting_scalar = False
|
|
89
|
+
for char in line[:_MAX_EXCERPT_LENGTH]:
|
|
90
|
+
if in_string:
|
|
91
|
+
if escaped:
|
|
92
|
+
escaped = False
|
|
93
|
+
elif char == "\\":
|
|
94
|
+
escaped = True
|
|
95
|
+
elif char == '"':
|
|
96
|
+
parts.append('"')
|
|
97
|
+
in_string = False
|
|
98
|
+
continue
|
|
99
|
+
if char == '"':
|
|
100
|
+
parts.append('"[redacted]')
|
|
101
|
+
in_string = True
|
|
102
|
+
redacting_scalar = False
|
|
103
|
+
elif char in "{}[]:,":
|
|
104
|
+
parts.append(char)
|
|
105
|
+
redacting_scalar = False
|
|
106
|
+
elif char.isspace():
|
|
107
|
+
parts.append(" ")
|
|
108
|
+
redacting_scalar = False
|
|
109
|
+
elif not redacting_scalar:
|
|
110
|
+
parts.append("[redacted]")
|
|
111
|
+
redacting_scalar = True
|
|
112
|
+
excerpt = "".join(parts)
|
|
113
|
+
if len(line) > _MAX_EXCERPT_LENGTH:
|
|
114
|
+
return excerpt[: _MAX_EXCERPT_LENGTH - 1] + "…"
|
|
115
|
+
return excerpt[:_MAX_EXCERPT_LENGTH]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _error_category(error: json.JSONDecodeError | AttributeError | KeyError | TypeError | ValueError) -> str:
|
|
119
|
+
if isinstance(error, json.JSONDecodeError):
|
|
120
|
+
return "invalid_json"
|
|
121
|
+
if isinstance(error, KeyError):
|
|
122
|
+
return "missing_field"
|
|
123
|
+
if isinstance(error, TypeError) and str(error) == "transcript line is not a JSON object":
|
|
124
|
+
return "non_object"
|
|
125
|
+
if isinstance(error, (TypeError, AttributeError)):
|
|
126
|
+
return "invalid_type"
|
|
127
|
+
return "invalid_value"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _text_of(content: object) -> str:
|
|
131
|
+
"""Plain text of a message or tool result `content`: a string or a list of blocks."""
|
|
132
|
+
if isinstance(content, str):
|
|
133
|
+
return content
|
|
134
|
+
if isinstance(content, list):
|
|
135
|
+
return "\n".join(
|
|
136
|
+
block["text"] for block in content if isinstance(block, dict) and isinstance(block.get("text"), str)
|
|
137
|
+
)
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _blocks(content: object) -> list[dict[str, Any]]:
|
|
142
|
+
return [block for block in content if isinstance(block, dict)] if isinstance(content, list) else []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Parser:
|
|
146
|
+
def __init__(self, source_stream_id: str | None = None) -> None:
|
|
147
|
+
self.transcript = Transcript()
|
|
148
|
+
self.transcript.source_stream_id = source_stream_id
|
|
149
|
+
self._messages: dict[str, AssistantMessage] = {}
|
|
150
|
+
self._seen_tool_uses: set[str] = set()
|
|
151
|
+
self._pending_next_calls: list[ToolResult] = []
|
|
152
|
+
self._source_order = 0
|
|
153
|
+
|
|
154
|
+
def feed(self, entry: dict[str, Any]) -> None:
|
|
155
|
+
source_order = self._source_order
|
|
156
|
+
self._source_order += 1
|
|
157
|
+
entry_type = entry.get("type")
|
|
158
|
+
if entry_type == "ai-title":
|
|
159
|
+
title = entry.get("aiTitle")
|
|
160
|
+
if isinstance(title, str) and title:
|
|
161
|
+
self.transcript.title = title
|
|
162
|
+
return
|
|
163
|
+
if entry_type == "system" and entry.get("subtype") == "compact_boundary":
|
|
164
|
+
self.transcript.compactions_ms.append(parse_iso_ms(entry["timestamp"]))
|
|
165
|
+
return
|
|
166
|
+
if entry_type not in ("user", "assistant"):
|
|
167
|
+
return
|
|
168
|
+
timestamp_ms = parse_iso_ms(entry["timestamp"])
|
|
169
|
+
message = entry["message"]
|
|
170
|
+
if entry_type == "assistant":
|
|
171
|
+
self._feed_assistant(entry, message, timestamp_ms, source_order)
|
|
172
|
+
self.transcript.last_message_ms = max(self.transcript.last_message_ms or timestamp_ms, timestamp_ms)
|
|
173
|
+
else:
|
|
174
|
+
prompt_count = len(self.transcript.prompts)
|
|
175
|
+
self._feed_user(entry, message, timestamp_ms, source_order)
|
|
176
|
+
if len(self.transcript.prompts) > prompt_count:
|
|
177
|
+
self.transcript.last_message_ms = max(self.transcript.last_message_ms or timestamp_ms, timestamp_ms)
|
|
178
|
+
if self.transcript.first_timestamp_ms is None:
|
|
179
|
+
self.transcript.first_timestamp_ms = timestamp_ms
|
|
180
|
+
if self.transcript.cwd is None and isinstance(entry.get("cwd"), str):
|
|
181
|
+
self.transcript.cwd = entry["cwd"]
|
|
182
|
+
|
|
183
|
+
def _feed_assistant(
|
|
184
|
+
self, entry: dict[str, Any], message: dict[str, Any], timestamp_ms: int, source_order: int
|
|
185
|
+
) -> None:
|
|
186
|
+
message_id = str(message.get("id") or entry["uuid"])
|
|
187
|
+
call = self._messages.get(message_id)
|
|
188
|
+
if call is None:
|
|
189
|
+
for result in self._pending_next_calls:
|
|
190
|
+
result.next_message_id = message_id
|
|
191
|
+
self._pending_next_calls.clear()
|
|
192
|
+
model = message.get("model")
|
|
193
|
+
call = AssistantMessage(
|
|
194
|
+
message_id=message_id,
|
|
195
|
+
start_ms=timestamp_ms,
|
|
196
|
+
model=model if isinstance(model, str) else None,
|
|
197
|
+
source_order=source_order,
|
|
198
|
+
)
|
|
199
|
+
self._messages[message_id] = call
|
|
200
|
+
self.transcript.messages.append(call)
|
|
201
|
+
usage = message.get("usage")
|
|
202
|
+
if isinstance(usage, dict):
|
|
203
|
+
call.usage = usage # every line repeats the usage; the last line carries the final counts
|
|
204
|
+
for block in _blocks(message.get("content")):
|
|
205
|
+
if block.get("type") != "tool_use" or block["id"] in self._seen_tool_uses:
|
|
206
|
+
continue
|
|
207
|
+
self._seen_tool_uses.add(block["id"])
|
|
208
|
+
tool_input = block.get("input")
|
|
209
|
+
self.transcript.tool_uses.append(
|
|
210
|
+
ToolUse(
|
|
211
|
+
tool_use_id=str(block["id"]),
|
|
212
|
+
name=str(block["name"]),
|
|
213
|
+
input=tool_input if isinstance(tool_input, dict) else {},
|
|
214
|
+
start_ms=timestamp_ms,
|
|
215
|
+
source_order=source_order,
|
|
216
|
+
request_message_id=message_id,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _feed_user(self, entry: dict[str, Any], message: dict[str, Any], timestamp_ms: int, source_order: int) -> None:
|
|
221
|
+
content = message.get("content")
|
|
222
|
+
results = [block for block in _blocks(content) if block.get("type") == "tool_result"]
|
|
223
|
+
for block in results:
|
|
224
|
+
invocation_id = str(block["tool_use_id"])
|
|
225
|
+
result = ToolResult(
|
|
226
|
+
end_ms=timestamp_ms,
|
|
227
|
+
is_error=bool(block.get("is_error")),
|
|
228
|
+
text=_text_of(block.get("content")),
|
|
229
|
+
source_order=source_order,
|
|
230
|
+
)
|
|
231
|
+
self.transcript.tool_results[invocation_id] = result
|
|
232
|
+
self.transcript.tool_result_records.append((invocation_id, result))
|
|
233
|
+
self._pending_next_calls.append(result)
|
|
234
|
+
if results or entry.get("isMeta"):
|
|
235
|
+
return
|
|
236
|
+
self.transcript.prompts.append(
|
|
237
|
+
Prompt(uuid=str(entry["uuid"]), start_ms=timestamp_ms, text=_text_of(content), source_order=source_order)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_transcript(path: Path) -> Transcript:
|
|
242
|
+
"""Parse one transcript file; malformed lines are skipped and counted."""
|
|
243
|
+
parser = _Parser(source_stream_id=f"claude-code:{path.stem}")
|
|
244
|
+
with path.open(encoding="utf-8") as source:
|
|
245
|
+
for line_number, line in enumerate(source, start=1):
|
|
246
|
+
if not line.strip():
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
entry = json.loads(line)
|
|
250
|
+
if not isinstance(entry, dict):
|
|
251
|
+
raise TypeError("transcript line is not a JSON object")
|
|
252
|
+
parser.feed(entry)
|
|
253
|
+
except (json.JSONDecodeError, AttributeError, KeyError, TypeError, ValueError) as error:
|
|
254
|
+
parser.transcript.malformed_lines += 1
|
|
255
|
+
if len(parser.transcript.malformed_line_details) < _MAX_MALFORMED_LINE_DETAILS:
|
|
256
|
+
parser.transcript.malformed_line_details.append(
|
|
257
|
+
MalformedLineDetail(
|
|
258
|
+
source_path=str(path),
|
|
259
|
+
line_number=line_number,
|
|
260
|
+
excerpt=_redacted_excerpt(line.rstrip("\r\n")),
|
|
261
|
+
error_category=_error_category(error),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return parser.transcript
|