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,293 @@
|
|
|
1
|
+
"""The Codex adapter: one agent turn is one run, and one rollout file is one batch.
|
|
2
|
+
|
|
3
|
+
Layout: `~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl`. Every line is
|
|
4
|
+
`{timestamp, type, payload}`. A turn opens with an `event_msg` of type `task_started` and closes
|
|
5
|
+
with `task_complete` or `turn_aborted` carrying the same `turn_id`.
|
|
6
|
+
|
|
7
|
+
Privacy: `_project` is the only code that touches a parsed line, and it copies an allowlist of
|
|
8
|
+
keys. Session metadata yields the session id, the working folder (a hash input only, which sits in
|
|
9
|
+
memory and in the local cache as Claude Code folder names do, never in a row), whether the file is
|
|
10
|
+
a fork, and the session source reduced to a fixed word. Turn context yields the turn id, model and
|
|
11
|
+
an effort from the schema enum. Events yield turn ids, epoch times, an error flag, an abort reason
|
|
12
|
+
from a fixed set, and the type and completed/failed status of finished items. Response items yield
|
|
13
|
+
their type only, and token records two counters. Prompts, messages, reasoning, commands, patches,
|
|
14
|
+
tool arguments and outputs, instructions, git data and agent names are never copied.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Iterable, Iterator
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from agentpace import schema
|
|
27
|
+
from agentpace.adapters.base import ParseStats, RawRun, Source
|
|
28
|
+
from agentpace.adapters.claude_code import _active, _as_int, _extended, _parse_time
|
|
29
|
+
|
|
30
|
+
_EFFORTS = frozenset(schema.enum("effort"))
|
|
31
|
+
_SOURCES = frozenset(
|
|
32
|
+
{"cli", "vscode", "exec", "mcp", "custom", "review", "compact", "memory_consolidation"}
|
|
33
|
+
)
|
|
34
|
+
_SKIPPED_SOURCES = frozenset({"compact", "memory_consolidation"}) # upkeep, not agent work
|
|
35
|
+
_ABORT_REASONS = frozenset({"interrupted", "replaced", "review_ended", "budget_limited"})
|
|
36
|
+
_TOOL_ITEMS = frozenset({"function_call", "custom_tool_call", "local_shell_call"})
|
|
37
|
+
_STATUS_ITEMS = frozenset({"CommandExecution", "FileChange", "McpToolCall", "Extension"})
|
|
38
|
+
# Codex's automatic approval check on a command: seconds long, no tool call, not a task
|
|
39
|
+
# anyone plans. Its turns are skipped and counted, never rows.
|
|
40
|
+
_APPROVAL_MODEL = "codex-auto-review"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _Line:
|
|
45
|
+
kind: str
|
|
46
|
+
time: float | None
|
|
47
|
+
turn_id: str | None = None
|
|
48
|
+
epoch: float | None = None # started_at or completed_at, when the line has one
|
|
49
|
+
model: str | None = None
|
|
50
|
+
effort: str | None = None
|
|
51
|
+
failed: bool = False # a task_complete error, or an item whose status is "failed"
|
|
52
|
+
reason: str | None = None
|
|
53
|
+
item: str | None = None
|
|
54
|
+
tokens: int = 0
|
|
55
|
+
session_id: str | None = None
|
|
56
|
+
folder: str | None = None
|
|
57
|
+
forked: bool = False
|
|
58
|
+
source: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _text(value: Any) -> str | None:
|
|
62
|
+
return value if isinstance(value, str) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _epoch(value: Any) -> float | None:
|
|
66
|
+
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _reduce_source(value: Any) -> str | None:
|
|
70
|
+
"""A fixed word for where the session came from. Only key names are read from a dict."""
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return value if value in _SOURCES else None
|
|
73
|
+
if isinstance(value, dict) and set(value) == {"subagent"}:
|
|
74
|
+
inner = value["subagent"]
|
|
75
|
+
if isinstance(inner, dict) and "thread_spawn" in inner:
|
|
76
|
+
return "thread_spawn"
|
|
77
|
+
return "other"
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _project(record: Any) -> _Line | None:
|
|
82
|
+
"""Copy allowed values out of one parsed line. The only code that touches a parsed line."""
|
|
83
|
+
if not isinstance(record, dict):
|
|
84
|
+
return None
|
|
85
|
+
kind = record.get("type")
|
|
86
|
+
payload = record.get("payload")
|
|
87
|
+
if not isinstance(kind, str) or not isinstance(payload, dict):
|
|
88
|
+
return None
|
|
89
|
+
time = _parse_time(record.get("timestamp"))
|
|
90
|
+
if kind == "session_meta":
|
|
91
|
+
return _Line(
|
|
92
|
+
"session_meta",
|
|
93
|
+
time,
|
|
94
|
+
session_id=_text(payload.get("id")),
|
|
95
|
+
folder=_text(payload.get("cwd")),
|
|
96
|
+
forked=payload.get("forked_from_id") is not None,
|
|
97
|
+
source=_reduce_source(payload.get("source")),
|
|
98
|
+
)
|
|
99
|
+
if kind == "turn_context":
|
|
100
|
+
effort = payload.get("effort")
|
|
101
|
+
return _Line(
|
|
102
|
+
"turn_context",
|
|
103
|
+
time,
|
|
104
|
+
turn_id=_text(payload.get("turn_id")),
|
|
105
|
+
model=_text(payload.get("model")),
|
|
106
|
+
effort=effort if isinstance(effort, str) and effort in _EFFORTS else None,
|
|
107
|
+
)
|
|
108
|
+
if kind == "token_usage_record":
|
|
109
|
+
usage = payload.get("turn_token_usage")
|
|
110
|
+
usage = usage if isinstance(usage, dict) else {}
|
|
111
|
+
return _Line(
|
|
112
|
+
"tokens",
|
|
113
|
+
time,
|
|
114
|
+
turn_id=_text(payload.get("turn_id")),
|
|
115
|
+
tokens=_as_int(usage.get("input_tokens")) + _as_int(usage.get("output_tokens")),
|
|
116
|
+
)
|
|
117
|
+
if kind == "response_item":
|
|
118
|
+
return _Line("tool" if payload.get("type") in _TOOL_ITEMS else "line", time)
|
|
119
|
+
if kind != "event_msg":
|
|
120
|
+
return _Line("line", time)
|
|
121
|
+
event = payload.get("type")
|
|
122
|
+
if event == "task_started":
|
|
123
|
+
return _Line(
|
|
124
|
+
"start",
|
|
125
|
+
time,
|
|
126
|
+
turn_id=_text(payload.get("turn_id")),
|
|
127
|
+
epoch=_epoch(payload.get("started_at")),
|
|
128
|
+
)
|
|
129
|
+
if event == "task_complete":
|
|
130
|
+
return _Line(
|
|
131
|
+
"complete",
|
|
132
|
+
time,
|
|
133
|
+
turn_id=_text(payload.get("turn_id")),
|
|
134
|
+
epoch=_epoch(payload.get("completed_at")),
|
|
135
|
+
failed=payload.get("error") is not None,
|
|
136
|
+
)
|
|
137
|
+
if event == "turn_aborted":
|
|
138
|
+
reason = payload.get("reason")
|
|
139
|
+
return _Line(
|
|
140
|
+
"abort",
|
|
141
|
+
time,
|
|
142
|
+
turn_id=_text(payload.get("turn_id")),
|
|
143
|
+
epoch=_epoch(payload.get("completed_at")),
|
|
144
|
+
reason=reason if isinstance(reason, str) and reason in _ABORT_REASONS else None,
|
|
145
|
+
)
|
|
146
|
+
if event == "item_completed":
|
|
147
|
+
item = payload.get("item")
|
|
148
|
+
item = item if isinstance(item, dict) else {}
|
|
149
|
+
item_type = item.get("type")
|
|
150
|
+
status = item.get("status")
|
|
151
|
+
return _Line(
|
|
152
|
+
"item",
|
|
153
|
+
time,
|
|
154
|
+
item=item_type if item_type in _STATUS_ITEMS else None,
|
|
155
|
+
failed=item_type in _STATUS_ITEMS and status == "failed",
|
|
156
|
+
)
|
|
157
|
+
return _Line("line", time)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class _Turn:
|
|
162
|
+
turn_id: str | None
|
|
163
|
+
start: float | None
|
|
164
|
+
times: list[float] = field(default_factory=list)
|
|
165
|
+
tool_calls: int = 0
|
|
166
|
+
edit_calls: int = 0
|
|
167
|
+
errors: int = 0
|
|
168
|
+
tokens: int = 0
|
|
169
|
+
end: float | None = None
|
|
170
|
+
outcome: str = "unknown"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _turns(lines: Iterable[_Line]) -> Iterator[_Turn]:
|
|
174
|
+
"""Group lines into turns by position, closing each on its own complete or abort."""
|
|
175
|
+
turn: _Turn | None = None
|
|
176
|
+
for line in lines:
|
|
177
|
+
if line.kind == "start":
|
|
178
|
+
if turn is not None:
|
|
179
|
+
yield turn # a new turn began before this one closed: outcome stays unknown
|
|
180
|
+
turn = _Turn(line.turn_id, line.epoch if line.epoch is not None else line.time)
|
|
181
|
+
if turn is None:
|
|
182
|
+
continue
|
|
183
|
+
if line.time is not None:
|
|
184
|
+
turn.times.append(line.time)
|
|
185
|
+
if line.kind == "tool":
|
|
186
|
+
turn.tool_calls += 1
|
|
187
|
+
elif line.kind == "item":
|
|
188
|
+
turn.edit_calls += line.item == "FileChange"
|
|
189
|
+
turn.errors += line.failed
|
|
190
|
+
elif line.kind == "tokens" and line.turn_id is None:
|
|
191
|
+
turn.tokens = max(turn.tokens, line.tokens)
|
|
192
|
+
elif line.kind in ("complete", "abort"):
|
|
193
|
+
if line.turn_id is not None and line.turn_id != turn.turn_id:
|
|
194
|
+
continue # it closes some other turn, never the open one
|
|
195
|
+
turn.end = line.epoch if line.epoch is not None else line.time
|
|
196
|
+
if line.kind == "complete":
|
|
197
|
+
turn.outcome = "failed" if line.failed else "completed"
|
|
198
|
+
yield turn
|
|
199
|
+
turn = None
|
|
200
|
+
if turn is not None:
|
|
201
|
+
yield turn
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class CodexAdapter:
|
|
205
|
+
name = "codex"
|
|
206
|
+
# Bump whenever `parse` would return something different for the same file, so cached
|
|
207
|
+
# runs from the old logic are discarded.
|
|
208
|
+
version = 2
|
|
209
|
+
|
|
210
|
+
def default_path(self) -> Path:
|
|
211
|
+
return Path.home() / ".codex" / "sessions"
|
|
212
|
+
|
|
213
|
+
def discover(self, path: Path) -> Iterator[Source]:
|
|
214
|
+
root = _extended(path)
|
|
215
|
+
if os.path.isfile(root):
|
|
216
|
+
listing: Iterable[tuple[str, list[str], list[str]]] = [
|
|
217
|
+
(os.path.dirname(root), [], [os.path.basename(root)])
|
|
218
|
+
]
|
|
219
|
+
else:
|
|
220
|
+
listing = os.walk(root)
|
|
221
|
+
for directory, _, files in listing:
|
|
222
|
+
for file in sorted(files):
|
|
223
|
+
if file.startswith("rollout-") and file.endswith(".jsonl"):
|
|
224
|
+
# Session and project come from the file's own metadata, in `parse`.
|
|
225
|
+
yield Source(path=os.path.join(directory, file), session="", project="")
|
|
226
|
+
|
|
227
|
+
def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
|
|
228
|
+
lines: list[_Line] = []
|
|
229
|
+
try:
|
|
230
|
+
with open(source.path, encoding="utf-8", errors="replace") as handle:
|
|
231
|
+
for raw in handle:
|
|
232
|
+
try:
|
|
233
|
+
line = _project(json.loads(raw))
|
|
234
|
+
except ValueError:
|
|
235
|
+
stats.unparsed_lines += 1
|
|
236
|
+
continue
|
|
237
|
+
if line is not None:
|
|
238
|
+
lines.append(line)
|
|
239
|
+
except OSError:
|
|
240
|
+
stats.unreadable_files += 1
|
|
241
|
+
return []
|
|
242
|
+
metas = [line for line in lines if line.kind == "session_meta"]
|
|
243
|
+
if not metas or metas[0].session_id is None:
|
|
244
|
+
stats.empty_runs += 1
|
|
245
|
+
return []
|
|
246
|
+
first = metas[0]
|
|
247
|
+
# Copied history would count the same turns twice, so a fork, or a file whose metadata
|
|
248
|
+
# names more than one session, is skipped whole. Upkeep sessions are not agent work.
|
|
249
|
+
forked = any(meta.forked for meta in metas)
|
|
250
|
+
mixed = len({meta.session_id for meta in metas}) != 1
|
|
251
|
+
if forked or mixed or first.source in _SKIPPED_SOURCES:
|
|
252
|
+
stats.skipped_files += 1
|
|
253
|
+
return []
|
|
254
|
+
contexts = {line.turn_id: line for line in lines if line.kind == "turn_context"}
|
|
255
|
+
last_model = next((line.model for line in reversed(lines) if line.model), None)
|
|
256
|
+
tokens_by_turn: dict[str, int] = {}
|
|
257
|
+
for line in lines:
|
|
258
|
+
if line.kind == "tokens" and line.turn_id is not None:
|
|
259
|
+
tokens_by_turn[line.turn_id] = max(tokens_by_turn.get(line.turn_id, 0), line.tokens)
|
|
260
|
+
runs: list[RawRun] = []
|
|
261
|
+
for turn in _turns(lines):
|
|
262
|
+
context = contexts.get(turn.turn_id)
|
|
263
|
+
model = (context.model if context else None) or last_model
|
|
264
|
+
if model == _APPROVAL_MODEL:
|
|
265
|
+
stats.skipped_runs += 1
|
|
266
|
+
continue
|
|
267
|
+
times = sorted(t for t in (turn.start, *turn.times, turn.end) if t is not None)
|
|
268
|
+
if model is None or len(times) < 2:
|
|
269
|
+
stats.empty_runs += 1
|
|
270
|
+
continue
|
|
271
|
+
active_s, spans = _active(times, idle_cap_s)
|
|
272
|
+
tokens = max(turn.tokens, tokens_by_turn.get(turn.turn_id or "", 0))
|
|
273
|
+
review = first.source == "review"
|
|
274
|
+
runs.append(
|
|
275
|
+
RawRun(
|
|
276
|
+
harness=self.name,
|
|
277
|
+
model=model,
|
|
278
|
+
start=times[0],
|
|
279
|
+
end=times[-1],
|
|
280
|
+
spans=spans,
|
|
281
|
+
active_s=active_s,
|
|
282
|
+
tool_calls=turn.tool_calls,
|
|
283
|
+
edit_calls=turn.edit_calls,
|
|
284
|
+
tokens=tokens or None,
|
|
285
|
+
errors=turn.errors,
|
|
286
|
+
outcome=turn.outcome,
|
|
287
|
+
effort=context.effort if context else None,
|
|
288
|
+
agent_type="reviewer" if review else None,
|
|
289
|
+
session=first.session_id or "",
|
|
290
|
+
project=first.folder or "",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return runs
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""The Gemini CLI adapter: one agent turn is one run, and one session file is one batch.
|
|
2
|
+
|
|
3
|
+
EXPERIMENTAL. Built from the Gemini CLI source at commit
|
|
4
|
+
google-gemini/gemini-cli@62bab5b232c15b925d31e655ce844ab939c882b7, cited below as `file:line`
|
|
5
|
+
under `packages/core/src/`, and shipped unverified against real session files.
|
|
6
|
+
|
|
7
|
+
Layout: `~/.gemini/tmp/<project>/chats/session-<time>-<id>.jsonl` for a main session, and
|
|
8
|
+
`chats/<parent session id>/<session id>.jsonl` for a subagent
|
|
9
|
+
(services/chatRecordingService.ts:797-840, config/storage.ts:54-60, 195-197, 230-234).
|
|
10
|
+
`GEMINI_CLI_HOME` replaces the home folder (utils/paths.ts:22-28). Each line is one JSON record
|
|
11
|
+
(chatRecordingService.ts:959-971): a metadata record `{sessionId, projectHash, startTime, ...}`
|
|
12
|
+
first (:873-882), then message records `{id, timestamp, type, content, ...}`
|
|
13
|
+
(services/chatRecordingTypes.ts:44-87) and `$set`, `$patch` and `$rewindTo` updates
|
|
14
|
+
(chatRecordingTypes.ts:123-154). A message is appended again, whole, each time its tokens or tool
|
|
15
|
+
calls change (chatRecordingService.ts:1068-1087, 1186, 1259), so one id can appear many times and
|
|
16
|
+
its last copy wins (:463). Legacy `.json` files are not read: resuming one copies its whole
|
|
17
|
+
history into a new `.jsonl` (:748-772), so reading both would count it twice.
|
|
18
|
+
|
|
19
|
+
What one run is: Gemini CLI writes no turn-start or turn-end event, so the turn is inferred.
|
|
20
|
+
|
|
21
|
+
- A user message opens a turn when it is not a tool response and the next user message or model
|
|
22
|
+
response after it is a model response. Tool results are recorded as user messages too
|
|
23
|
+
(core/geminiChat.ts:600-609), and context the CLI injects as user turns (the environment,
|
|
24
|
+
IDE context, a compression summary) is never answered by a model call of its own before the next
|
|
25
|
+
user message (geminiChat.ts:868-888, 1418-1437; utils/environmentContext.ts:84-111).
|
|
26
|
+
- A model response is a `gemini` message carrying a model, a token count or tool calls
|
|
27
|
+
(chatRecordingService.ts:1127-1133, 1221-1227). Copies the CLI writes when it rewrites history
|
|
28
|
+
carry none of them (:1579-1591).
|
|
29
|
+
- The turn runs until the next opening prompt. Its times are the prompt, each response and each
|
|
30
|
+
tool call (a tool call's time is when it finished, geminiChat.ts:1892). Info, warning and error
|
|
31
|
+
messages, injected context and placeholder replies add no time, because they can be written
|
|
32
|
+
after the human has been away.
|
|
33
|
+
- `completed` when the last response asked for no tool call. Otherwise `failed` when the turn holds
|
|
34
|
+
an `error` message, else `unknown`: an interrupted or failed stream is rolled back with a
|
|
35
|
+
`$rewindTo` or `$patch` (geminiChat.ts:836-851), which this adapter does not replay, so the work
|
|
36
|
+
done before the rollback still counts.
|
|
37
|
+
|
|
38
|
+
Privacy: `_project` is the only code that touches a parsed line, and it copies an allowlist. The
|
|
39
|
+
metadata yields the session id and `projectHash`, a SHA-256 of the project folder written by the
|
|
40
|
+
CLI itself (chatRecordingService.ts:707, utils/paths.ts:365-367), used only as the input of the
|
|
41
|
+
`project` pseudonym. A message yields its id, timestamp, a type from a fixed set, whether a user
|
|
42
|
+
message is a tool response (only the presence of a `functionResponse` part is tested), the model,
|
|
43
|
+
three token counters, and per tool call its id, whether its name is an edit tool, whether its
|
|
44
|
+
status is `error`, and its timestamp. Prompts,
|
|
45
|
+
replies, thoughts, tool names, arguments, results, descriptions, summaries, directories and the
|
|
46
|
+
memory scratchpad are never copied.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
from collections import Counter
|
|
54
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any
|
|
58
|
+
|
|
59
|
+
from agentpace.adapters.base import ParseStats, RawRun, Source
|
|
60
|
+
from agentpace.adapters.claude_code import _active, _as_int, _extended, _parse_time
|
|
61
|
+
|
|
62
|
+
# chatRecordingTypes.ts:72-82.
|
|
63
|
+
_MESSAGE_TYPES = frozenset({"user", "info", "error", "warning", "gemini"})
|
|
64
|
+
# tools/definitions/base-declarations.ts:61 and :65.
|
|
65
|
+
_EDIT_TOOLS = frozenset({"write_file", "replace"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _Call:
|
|
70
|
+
call_id: str
|
|
71
|
+
edit: bool
|
|
72
|
+
failed: bool # status "error" (scheduler/types.ts:26-34, 206-209)
|
|
73
|
+
time: float | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class _Record:
|
|
78
|
+
kind: str # "meta", "message" or "update"
|
|
79
|
+
session_id: str | None = None
|
|
80
|
+
project: str | None = None
|
|
81
|
+
message_id: str | None = None
|
|
82
|
+
time: float | None = None
|
|
83
|
+
type: str | None = None
|
|
84
|
+
tool_response: bool = False
|
|
85
|
+
model: str | None = None
|
|
86
|
+
tokens: int | None = None
|
|
87
|
+
calls: tuple[_Call, ...] = ()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _text(value: Any) -> str | None:
|
|
91
|
+
return value if isinstance(value, str) else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_tool_response(content: Any) -> bool:
|
|
95
|
+
"""True when any part holds a `functionResponse` (utils/messageInspectors.ts:9-15)."""
|
|
96
|
+
if not isinstance(content, list):
|
|
97
|
+
return False
|
|
98
|
+
return any(isinstance(part, dict) and bool(part.get("functionResponse")) for part in content)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _tokens(value: Any) -> int | None:
|
|
102
|
+
"""Input plus output, without cache reads. `input` is the prompt count, which includes the
|
|
103
|
+
cached part, so `cached` is taken off (chatRecordingTypes.ts:19-26). Thought and tool-prompt
|
|
104
|
+
tokens are left out, as reasoning tokens are for Codex."""
|
|
105
|
+
if not isinstance(value, dict):
|
|
106
|
+
return None
|
|
107
|
+
prompt = _as_int(value.get("input"))
|
|
108
|
+
return max(prompt - _as_int(value.get("cached")), 0) + _as_int(value.get("output"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _calls(value: Any) -> tuple[_Call, ...]:
|
|
112
|
+
"""Each tool call's id, edit flag, error flag and time (chatRecordingTypes.ts:54-67)."""
|
|
113
|
+
calls: list[_Call] = []
|
|
114
|
+
for call in value if isinstance(value, list) else ():
|
|
115
|
+
if not isinstance(call, dict) or not isinstance(call.get("id"), str):
|
|
116
|
+
continue
|
|
117
|
+
calls.append(
|
|
118
|
+
_Call(
|
|
119
|
+
call_id=call["id"],
|
|
120
|
+
edit=call.get("name") in _EDIT_TOOLS,
|
|
121
|
+
failed=call.get("status") == "error",
|
|
122
|
+
time=_parse_time(call.get("timestamp")),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return tuple(calls)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _project(record: Any) -> _Record | None:
|
|
129
|
+
"""Copy allowed values out of one parsed line. The only code that touches a parsed line.
|
|
130
|
+
|
|
131
|
+
Shapes are told apart in the order the CLI's own loader uses (chatRecordingService.ts:319-563).
|
|
132
|
+
None means the line matches no known shape, or names an unknown message type."""
|
|
133
|
+
if not isinstance(record, dict):
|
|
134
|
+
return None
|
|
135
|
+
if isinstance(record.get("$rewindTo"), str) or isinstance(record.get("$patch"), dict):
|
|
136
|
+
return _Record("update")
|
|
137
|
+
if isinstance(record.get("id"), str):
|
|
138
|
+
kind = record.get("type")
|
|
139
|
+
if kind not in _MESSAGE_TYPES:
|
|
140
|
+
return None
|
|
141
|
+
time = _parse_time(record.get("timestamp"))
|
|
142
|
+
if kind == "user":
|
|
143
|
+
return _Record(
|
|
144
|
+
"message",
|
|
145
|
+
message_id=record["id"],
|
|
146
|
+
time=time,
|
|
147
|
+
type="user",
|
|
148
|
+
tool_response=_is_tool_response(record.get("content")),
|
|
149
|
+
)
|
|
150
|
+
if kind == "gemini":
|
|
151
|
+
return _Record(
|
|
152
|
+
"message",
|
|
153
|
+
message_id=record["id"],
|
|
154
|
+
time=time,
|
|
155
|
+
type="gemini",
|
|
156
|
+
model=_text(record.get("model")),
|
|
157
|
+
tokens=_tokens(record.get("tokens")),
|
|
158
|
+
calls=_calls(record.get("toolCalls")),
|
|
159
|
+
)
|
|
160
|
+
return _Record("message", message_id=record["id"], time=time, type=kind)
|
|
161
|
+
update = record.get("$set")
|
|
162
|
+
if isinstance(update, dict):
|
|
163
|
+
session_id = _text(update.get("sessionId"))
|
|
164
|
+
return _Record("meta", session_id=session_id) if session_id else _Record("update")
|
|
165
|
+
if isinstance(record.get("sessionId"), str) and isinstance(record.get("projectHash"), str):
|
|
166
|
+
return _Record("meta", session_id=record["sessionId"], project=record["projectHash"])
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class _Message:
|
|
172
|
+
"""One message id, merged across every copy of it in the file."""
|
|
173
|
+
|
|
174
|
+
type: str
|
|
175
|
+
time: float | None
|
|
176
|
+
tool_response: bool
|
|
177
|
+
model: str | None
|
|
178
|
+
tokens: int | None
|
|
179
|
+
calls: dict[str, _Call] = field(default_factory=dict)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def response(self) -> bool:
|
|
183
|
+
return self.type == "gemini" and (
|
|
184
|
+
self.model is not None or self.tokens is not None or bool(self.calls)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _merge(records: Iterable[_Record]) -> list[_Message]:
|
|
189
|
+
"""One entry per message id, at its first position. Later copies update tokens, the model
|
|
190
|
+
and tool calls, as the CLI's own loader keeps the last copy."""
|
|
191
|
+
merged: dict[str, _Message] = {}
|
|
192
|
+
for record in records:
|
|
193
|
+
if record.kind != "message" or record.message_id is None or record.type is None:
|
|
194
|
+
continue
|
|
195
|
+
message = merged.get(record.message_id)
|
|
196
|
+
if message is None:
|
|
197
|
+
merged[record.message_id] = _Message(
|
|
198
|
+
record.type,
|
|
199
|
+
record.time,
|
|
200
|
+
record.tool_response,
|
|
201
|
+
record.model,
|
|
202
|
+
record.tokens,
|
|
203
|
+
{call.call_id: call for call in record.calls},
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
if message.time is None:
|
|
207
|
+
message.time = record.time
|
|
208
|
+
message.tool_response = record.tool_response
|
|
209
|
+
message.model = record.model or message.model
|
|
210
|
+
message.tokens = record.tokens if record.tokens is not None else message.tokens
|
|
211
|
+
message.calls.update({call.call_id: call for call in record.calls})
|
|
212
|
+
return list(merged.values())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _opens(messages: Sequence[_Message], index: int) -> bool:
|
|
216
|
+
message = messages[index]
|
|
217
|
+
if message.type != "user" or message.tool_response:
|
|
218
|
+
return False
|
|
219
|
+
for later in messages[index + 1 :]:
|
|
220
|
+
if later.response:
|
|
221
|
+
return True
|
|
222
|
+
if later.type == "user":
|
|
223
|
+
return False
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _turns(messages: Sequence[_Message]) -> Iterator[tuple[_Message, Sequence[_Message]]]:
|
|
228
|
+
"""Each opening prompt with every message up to the next one."""
|
|
229
|
+
openers = [index for index in range(len(messages)) if _opens(messages, index)]
|
|
230
|
+
for position, start in enumerate(openers):
|
|
231
|
+
stop = openers[position + 1] if position + 1 < len(openers) else len(messages)
|
|
232
|
+
yield messages[start], messages[start + 1 : stop]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class GeminiCliAdapter:
|
|
236
|
+
name = "gemini-cli"
|
|
237
|
+
# Bump whenever `parse` would return something different for the same file, so cached
|
|
238
|
+
# runs from the old logic are discarded.
|
|
239
|
+
version = 1
|
|
240
|
+
|
|
241
|
+
def default_path(self) -> Path:
|
|
242
|
+
home = os.environ.get("GEMINI_CLI_HOME")
|
|
243
|
+
return (Path(home) if home else Path.home()) / ".gemini" / "tmp"
|
|
244
|
+
|
|
245
|
+
def discover(self, path: Path) -> Iterator[Source]:
|
|
246
|
+
root = _extended(path)
|
|
247
|
+
if os.path.isfile(root):
|
|
248
|
+
if root.endswith(".jsonl"):
|
|
249
|
+
yield Source(path=root, session="", project="")
|
|
250
|
+
return
|
|
251
|
+
for directory, _, files in os.walk(root):
|
|
252
|
+
parts = directory.replace("\\", "/").split("/")
|
|
253
|
+
# A main session sits in `chats`, a subagent one level below it.
|
|
254
|
+
if "chats" not in parts[-2:]:
|
|
255
|
+
continue
|
|
256
|
+
for file in sorted(files):
|
|
257
|
+
if file.endswith(".jsonl"):
|
|
258
|
+
# Session and project come from the file's own metadata, in `parse`.
|
|
259
|
+
yield Source(path=os.path.join(directory, file), session="", project="")
|
|
260
|
+
|
|
261
|
+
def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
|
|
262
|
+
records: list[_Record] = []
|
|
263
|
+
try:
|
|
264
|
+
with open(source.path, encoding="utf-8", errors="replace") as handle:
|
|
265
|
+
for raw in handle:
|
|
266
|
+
if not raw.strip():
|
|
267
|
+
continue
|
|
268
|
+
try:
|
|
269
|
+
record = _project(json.loads(raw))
|
|
270
|
+
except ValueError:
|
|
271
|
+
record = None
|
|
272
|
+
if record is None:
|
|
273
|
+
stats.unparsed_lines += 1
|
|
274
|
+
continue
|
|
275
|
+
records.append(record)
|
|
276
|
+
except OSError:
|
|
277
|
+
stats.unreadable_files += 1
|
|
278
|
+
return []
|
|
279
|
+
sessions = [r.session_id for r in records if r.kind == "meta" and r.session_id]
|
|
280
|
+
if not sessions:
|
|
281
|
+
stats.empty_runs += 1
|
|
282
|
+
return []
|
|
283
|
+
if len(set(sessions)) != 1:
|
|
284
|
+
# Metadata naming two sessions would mix their turns, so the file is skipped whole.
|
|
285
|
+
stats.skipped_files += 1
|
|
286
|
+
return []
|
|
287
|
+
project = next((r.project for r in records if r.kind == "meta" and r.project), None)
|
|
288
|
+
messages = _merge(records)
|
|
289
|
+
last_model = next((m.model for m in reversed(messages) if m.model), None)
|
|
290
|
+
turns = list(_turns(messages))
|
|
291
|
+
if not turns:
|
|
292
|
+
stats.empty_runs += 1 # as a cached file with no runs is counted
|
|
293
|
+
return []
|
|
294
|
+
runs: list[RawRun] = []
|
|
295
|
+
for prompt, body in turns:
|
|
296
|
+
responses = [message for message in body if message.response]
|
|
297
|
+
calls: dict[str, _Call] = {}
|
|
298
|
+
for response in responses:
|
|
299
|
+
calls.update(response.calls)
|
|
300
|
+
models = Counter(response.model for response in responses if response.model)
|
|
301
|
+
model = models.most_common(1)[0][0] if models else last_model
|
|
302
|
+
stamps = [prompt.time, *(r.time for r in responses), *(c.time for c in calls.values())]
|
|
303
|
+
times = sorted(t for t in stamps if t is not None)
|
|
304
|
+
if model is None or len(times) < 2:
|
|
305
|
+
stats.empty_runs += 1
|
|
306
|
+
continue
|
|
307
|
+
if not responses[-1].calls:
|
|
308
|
+
outcome = "completed"
|
|
309
|
+
elif any(message.type == "error" for message in body):
|
|
310
|
+
outcome = "failed"
|
|
311
|
+
else:
|
|
312
|
+
outcome = "unknown"
|
|
313
|
+
active_s, spans = _active(times, idle_cap_s)
|
|
314
|
+
tokens = sum(response.tokens or 0 for response in responses)
|
|
315
|
+
runs.append(
|
|
316
|
+
RawRun(
|
|
317
|
+
harness=self.name,
|
|
318
|
+
model=model,
|
|
319
|
+
start=times[0],
|
|
320
|
+
end=times[-1],
|
|
321
|
+
spans=spans,
|
|
322
|
+
active_s=active_s,
|
|
323
|
+
tool_calls=len(calls),
|
|
324
|
+
edit_calls=sum(call.edit for call in calls.values()),
|
|
325
|
+
tokens=tokens or None,
|
|
326
|
+
errors=sum(call.failed for call in calls.values()),
|
|
327
|
+
outcome=outcome,
|
|
328
|
+
session=sessions[0],
|
|
329
|
+
project=project or "",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return runs
|