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.
Files changed (54) hide show
  1. agentprof/__init__.py +7 -0
  2. agentprof/adapters/__init__.py +15 -0
  3. agentprof/adapters/base.py +79 -0
  4. agentprof/adapters/claude_code/__init__.py +3 -0
  5. agentprof/adapters/claude_code/adapter.py +133 -0
  6. agentprof/adapters/claude_code/discovery.py +79 -0
  7. agentprof/adapters/claude_code/prompts.py +44 -0
  8. agentprof/adapters/claude_code/tools.py +102 -0
  9. agentprof/adapters/claude_code/transcript.py +264 -0
  10. agentprof/adapters/claude_code/tree.py +492 -0
  11. agentprof/adapters/codex/__init__.py +3 -0
  12. agentprof/adapters/codex/adapter.py +164 -0
  13. agentprof/adapters/codex/discovery.py +117 -0
  14. agentprof/adapters/codex/prompts.py +16 -0
  15. agentprof/adapters/codex/rollout.py +328 -0
  16. agentprof/adapters/codex/tools.py +97 -0
  17. agentprof/adapters/codex/tree.py +311 -0
  18. agentprof/adapters/copilot_vscode/__init__.py +3 -0
  19. agentprof/adapters/copilot_vscode/adapter.py +166 -0
  20. agentprof/adapters/copilot_vscode/debuglog.py +136 -0
  21. agentprof/adapters/copilot_vscode/discovery.py +153 -0
  22. agentprof/adapters/copilot_vscode/session.py +281 -0
  23. agentprof/adapters/copilot_vscode/tools.py +127 -0
  24. agentprof/adapters/copilot_vscode/transcript.py +176 -0
  25. agentprof/adapters/copilot_vscode/tree.py +325 -0
  26. agentprof/adapters/execution.py +64 -0
  27. agentprof/adapters/timestamps.py +13 -0
  28. agentprof/adapters/turns.py +24 -0
  29. agentprof/analysis/__init__.py +3 -0
  30. agentprof/analysis/agent_summary.py +161 -0
  31. agentprof/analysis/call_context.py +101 -0
  32. agentprof/analysis/evidence_findings.py +151 -0
  33. agentprof/analysis/execution.py +39 -0
  34. agentprof/analysis/heuristics.py +306 -0
  35. agentprof/analysis/pipeline.py +31 -0
  36. agentprof/analysis/rollup.py +196 -0
  37. agentprof/cli.py +141 -0
  38. agentprof/model.py +341 -0
  39. agentprof/pricing.json +23 -0
  40. agentprof/pricing.py +120 -0
  41. agentprof/registry.py +304 -0
  42. agentprof/server/__init__.py +3 -0
  43. agentprof/server/app.py +399 -0
  44. agentprof/server/openapi.py +31 -0
  45. agentprof/server/pricing_schema.py +40 -0
  46. agentprof/server/schemas.py +579 -0
  47. agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
  48. agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
  49. agentprof/server/static/index.html +14 -0
  50. agentprof-0.1.0.dist-info/METADATA +177 -0
  51. agentprof-0.1.0.dist-info/RECORD +54 -0
  52. agentprof-0.1.0.dist-info/WHEEL +4 -0
  53. agentprof-0.1.0.dist-info/entry_points.txt +8 -0
  54. 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