agentpace 0.9.2__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.
- agentpace/__init__.py +3 -0
- agentpace/__main__.py +8 -0
- agentpace/_bundled/DATA-LICENSE +121 -0
- agentpace/_bundled/example-plan.json +11 -0
- agentpace/_bundled/run.schema.json +100 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W40-2026-W45.jsonl +3219 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W44-2026-W45.jsonl +211 -0
- agentpace/_bundled/turn.schema.json +30 -0
- agentpace/adapters/__init__.py +31 -0
- agentpace/adapters/base.py +74 -0
- agentpace/adapters/claude_code.py +265 -0
- agentpace/adapters/cline.py +449 -0
- agentpace/adapters/codex.py +293 -0
- agentpace/adapters/gemini_cli.py +332 -0
- agentpace/adapters/opencode.py +397 -0
- agentpace/backtest.py +801 -0
- agentpace/cache.py +115 -0
- agentpace/charts.py +191 -0
- agentpace/claims.py +164 -0
- agentpace/classify.py +54 -0
- agentpace/cli.py +754 -0
- agentpace/estimate.py +694 -0
- agentpace/forecasts.py +284 -0
- agentpace/ingest.py +337 -0
- agentpace/paths.py +62 -0
- agentpace/pseudonym.py +51 -0
- agentpace/py.typed +0 -0
- agentpace/render.py +152 -0
- agentpace/report.py +502 -0
- agentpace/schema.py +131 -0
- agentpace/share.py +130 -0
- agentpace/site.py +133 -0
- agentpace/turns.py +282 -0
- agentpace/validate.py +115 -0
- agentpace-0.9.2.dist-info/METADATA +324 -0
- agentpace-0.9.2.dist-info/RECORD +39 -0
- agentpace-0.9.2.dist-info/WHEEL +4 -0
- agentpace-0.9.2.dist-info/entry_points.txt +2 -0
- agentpace-0.9.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Claude Code subagent transcripts.
|
|
2
|
+
|
|
3
|
+
Layout: `<root>/<project>/<session>/subagents/**/agent-*.jsonl`, with a sibling `.meta.json`.
|
|
4
|
+
The format has no published schema, so unknown record types are skipped, never fatal.
|
|
5
|
+
|
|
6
|
+
Privacy boundary: JSON has to be decoded a line at a time, so content passes through memory
|
|
7
|
+
there. `_project` copies the few allowed values out of each parsed line, and nothing else in
|
|
8
|
+
this module ever sees that line. Parse errors are counted and their messages discarded, because
|
|
9
|
+
a decoder message can quote the text it failed on.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import itertools
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from collections.abc import Iterable, Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from agentpace import schema
|
|
25
|
+
from agentpace.adapters.base import ParseStats, RawRun, Source
|
|
26
|
+
|
|
27
|
+
_EFFORTS = frozenset(schema.enum("effort"))
|
|
28
|
+
_EDIT_TOOLS = frozenset({"Edit", "MultiEdit", "NotebookEdit", "Write"})
|
|
29
|
+
_TERMINAL_TOOLS = frozenset({"StructuredOutput", "SubagentHandback"})
|
|
30
|
+
_CLEAN_STOPS = frozenset({"end_turn", "stop_sequence"})
|
|
31
|
+
_SYNTHETIC_MODEL = "<synthetic>"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class _Line:
|
|
36
|
+
"""The allowed view of one transcript line."""
|
|
37
|
+
|
|
38
|
+
role: str
|
|
39
|
+
time: float | None
|
|
40
|
+
model: str | None
|
|
41
|
+
message_id: str | None
|
|
42
|
+
tokens: int
|
|
43
|
+
stop_reason: str | None
|
|
44
|
+
tools: tuple[str, ...]
|
|
45
|
+
results: int
|
|
46
|
+
last_result_errored: bool
|
|
47
|
+
errors: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_int(value: Any) -> int:
|
|
51
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_time(value: Any) -> float | None:
|
|
55
|
+
if not isinstance(value, str):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
# Python 3.10 rejects a trailing Z.
|
|
59
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _project(record: Any) -> _Line | None:
|
|
65
|
+
"""Copy allowed values out of a parsed line. The only code that touches a parsed line."""
|
|
66
|
+
if not isinstance(record, dict):
|
|
67
|
+
return None
|
|
68
|
+
role = record.get("type")
|
|
69
|
+
if role not in ("assistant", "user"):
|
|
70
|
+
return None
|
|
71
|
+
message = record.get("message")
|
|
72
|
+
time = _parse_time(record.get("timestamp"))
|
|
73
|
+
if not isinstance(message, dict):
|
|
74
|
+
return _Line(role, time, None, None, 0, None, (), 0, False, 0)
|
|
75
|
+
usage = message.get("usage")
|
|
76
|
+
usage = usage if isinstance(usage, dict) else {}
|
|
77
|
+
tools: list[str] = []
|
|
78
|
+
results = errors = 0
|
|
79
|
+
last_errored = False
|
|
80
|
+
content = message.get("content")
|
|
81
|
+
for block in content if isinstance(content, list) else ():
|
|
82
|
+
if not isinstance(block, dict):
|
|
83
|
+
continue
|
|
84
|
+
kind = block.get("type")
|
|
85
|
+
name = block.get("name")
|
|
86
|
+
if kind == "tool_use" and isinstance(name, str):
|
|
87
|
+
tools.append(name)
|
|
88
|
+
elif kind == "tool_result":
|
|
89
|
+
results += 1
|
|
90
|
+
last_errored = block.get("is_error") is True
|
|
91
|
+
errors += last_errored
|
|
92
|
+
model = message.get("model")
|
|
93
|
+
message_id = message.get("id")
|
|
94
|
+
stop_reason = message.get("stop_reason")
|
|
95
|
+
return _Line(
|
|
96
|
+
role=role,
|
|
97
|
+
time=time,
|
|
98
|
+
model=model if role == "assistant" and isinstance(model, str) else None,
|
|
99
|
+
message_id=message_id if isinstance(message_id, str) else None,
|
|
100
|
+
tokens=_as_int(usage.get("input_tokens")) + _as_int(usage.get("output_tokens")),
|
|
101
|
+
stop_reason=stop_reason if isinstance(stop_reason, str) else None,
|
|
102
|
+
tools=tuple(tools),
|
|
103
|
+
results=results,
|
|
104
|
+
last_result_errored=last_errored,
|
|
105
|
+
errors=errors,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _meta_path(transcript: str) -> str:
|
|
110
|
+
return transcript[: -len(".jsonl")] + ".meta.json"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_meta(path: str) -> tuple[str | None, bool, str | None]:
|
|
114
|
+
"""Return only `agentType`, `finished` and `effort`. `description` and the rest stay unread.
|
|
115
|
+
|
|
116
|
+
`effort` survives only as one of the schema's values. Any other string is dropped here, so it
|
|
117
|
+
never reaches a row, the cache or a message.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
with open(path, encoding="utf-8") as handle:
|
|
121
|
+
meta = json.load(handle)
|
|
122
|
+
except (OSError, ValueError):
|
|
123
|
+
return None, False, None
|
|
124
|
+
if not isinstance(meta, dict):
|
|
125
|
+
return None, False, None
|
|
126
|
+
agent_type = meta.get("agentType")
|
|
127
|
+
effort = meta.get("effort")
|
|
128
|
+
return (
|
|
129
|
+
agent_type if isinstance(agent_type, str) else None,
|
|
130
|
+
meta.get("finished") is True,
|
|
131
|
+
effort if isinstance(effort, str) and effort in _EFFORTS else None,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _outcome(
|
|
136
|
+
stop_reason: str | None, finished: bool, last_tool: str | None, last_errored: bool
|
|
137
|
+
) -> str:
|
|
138
|
+
if stop_reason == "refusal":
|
|
139
|
+
return "failed"
|
|
140
|
+
if finished or last_tool in _TERMINAL_TOOLS or stop_reason in _CLEAN_STOPS:
|
|
141
|
+
return "completed"
|
|
142
|
+
return "failed" if last_errored else "unknown"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _active(times: list[float], idle_cap_s: int) -> tuple[int, tuple[tuple[float, float], ...]]:
|
|
146
|
+
"""Clip idle gaps, and split the run into spans wherever a gap exceeded the cap."""
|
|
147
|
+
active = 0.0
|
|
148
|
+
spans: list[tuple[float, float]] = []
|
|
149
|
+
span_start = times[0]
|
|
150
|
+
for before, after in itertools.pairwise(times):
|
|
151
|
+
gap = after - before
|
|
152
|
+
active += min(gap, idle_cap_s)
|
|
153
|
+
if gap > idle_cap_s:
|
|
154
|
+
spans.append((span_start, before))
|
|
155
|
+
span_start = after
|
|
156
|
+
spans.append((span_start, times[-1]))
|
|
157
|
+
return round(active), tuple(spans)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extended(path: Path) -> str:
|
|
161
|
+
"""Use the extended prefix on Windows, where transcripts exceed the 260-character limit."""
|
|
162
|
+
absolute = os.path.abspath(path)
|
|
163
|
+
if os.name == "nt" and not absolute.startswith("\\\\?\\"):
|
|
164
|
+
return "\\\\?\\" + absolute
|
|
165
|
+
return absolute
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ClaudeCodeAdapter:
|
|
169
|
+
name = "claude-code"
|
|
170
|
+
# Bump whenever `parse` would return something different for the same file, so cached
|
|
171
|
+
# runs from the old logic are discarded.
|
|
172
|
+
version = 1
|
|
173
|
+
|
|
174
|
+
def default_path(self) -> Path:
|
|
175
|
+
return Path.home() / ".claude" / "projects"
|
|
176
|
+
|
|
177
|
+
def discover(self, path: Path) -> Iterator[Source]:
|
|
178
|
+
root = _extended(path)
|
|
179
|
+
listing: Iterable[tuple[str, list[str], list[str]]]
|
|
180
|
+
if os.path.isfile(root):
|
|
181
|
+
listing = [(os.path.dirname(root), [], [os.path.basename(root)])]
|
|
182
|
+
else:
|
|
183
|
+
listing = os.walk(root)
|
|
184
|
+
for directory, _, files in listing:
|
|
185
|
+
parts = directory.replace("\\", "/").split("/")
|
|
186
|
+
if "subagents" not in parts:
|
|
187
|
+
continue
|
|
188
|
+
anchor = parts.index("subagents")
|
|
189
|
+
if anchor < 2:
|
|
190
|
+
continue
|
|
191
|
+
for file in sorted(files):
|
|
192
|
+
if file.startswith("agent-") and file.endswith(".jsonl"):
|
|
193
|
+
transcript = os.path.join(directory, file)
|
|
194
|
+
yield Source(
|
|
195
|
+
path=transcript,
|
|
196
|
+
session=parts[anchor - 1],
|
|
197
|
+
project=parts[anchor - 2],
|
|
198
|
+
companions=(_meta_path(transcript),),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
|
|
202
|
+
times: list[float] = []
|
|
203
|
+
models: Counter[str] = Counter()
|
|
204
|
+
tokens_by_message: dict[str, int] = {}
|
|
205
|
+
tool_calls = edit_calls = errors = 0
|
|
206
|
+
last_tool: str | None = None
|
|
207
|
+
last_stop: str | None = None
|
|
208
|
+
last_errored = False
|
|
209
|
+
try:
|
|
210
|
+
with open(source.path, encoding="utf-8", errors="replace") as handle:
|
|
211
|
+
for raw in handle:
|
|
212
|
+
# Cheap skip of the large request-log lines, which are never needed.
|
|
213
|
+
if '"assistant"' not in raw and '"user"' not in raw:
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
line = _project(json.loads(raw))
|
|
217
|
+
except ValueError:
|
|
218
|
+
stats.unparsed_lines += 1
|
|
219
|
+
continue
|
|
220
|
+
if line is None:
|
|
221
|
+
continue
|
|
222
|
+
if line.time is not None:
|
|
223
|
+
times.append(line.time)
|
|
224
|
+
if line.model and line.model != _SYNTHETIC_MODEL:
|
|
225
|
+
models[line.model] += 1
|
|
226
|
+
if line.role == "assistant" and line.message_id:
|
|
227
|
+
# One message repeats its usage on every content block.
|
|
228
|
+
seen = tokens_by_message.get(line.message_id, 0)
|
|
229
|
+
tokens_by_message[line.message_id] = max(seen, line.tokens)
|
|
230
|
+
if line.stop_reason:
|
|
231
|
+
last_stop = line.stop_reason
|
|
232
|
+
tool_calls += len(line.tools)
|
|
233
|
+
edit_calls += sum(tool in _EDIT_TOOLS for tool in line.tools)
|
|
234
|
+
if line.tools:
|
|
235
|
+
last_tool = line.tools[-1]
|
|
236
|
+
if line.results:
|
|
237
|
+
last_errored = line.last_result_errored
|
|
238
|
+
errors += line.errors
|
|
239
|
+
except OSError:
|
|
240
|
+
stats.unreadable_files += 1
|
|
241
|
+
return []
|
|
242
|
+
if len(times) < 2 or not models:
|
|
243
|
+
stats.empty_runs += 1
|
|
244
|
+
return []
|
|
245
|
+
times.sort()
|
|
246
|
+
active_s, spans = _active(times, idle_cap_s)
|
|
247
|
+
agent_type, finished, effort = _read_meta(_meta_path(source.path))
|
|
248
|
+
run = RawRun(
|
|
249
|
+
harness=self.name,
|
|
250
|
+
model=models.most_common(1)[0][0],
|
|
251
|
+
start=times[0],
|
|
252
|
+
end=times[-1],
|
|
253
|
+
spans=spans,
|
|
254
|
+
active_s=active_s,
|
|
255
|
+
tool_calls=tool_calls,
|
|
256
|
+
edit_calls=edit_calls,
|
|
257
|
+
tokens=sum(tokens_by_message.values()) or None,
|
|
258
|
+
errors=errors,
|
|
259
|
+
outcome=_outcome(last_stop, finished, last_tool, last_errored),
|
|
260
|
+
effort=effort,
|
|
261
|
+
agent_type=agent_type,
|
|
262
|
+
session=source.session,
|
|
263
|
+
project=source.project,
|
|
264
|
+
)
|
|
265
|
+
return [run]
|