ccx-native 0.0.1 → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ccx.store.paths import iter_subagent_transcripts
|
|
8
|
+
from ccx.claude.transcript import (
|
|
9
|
+
_iter_transcript,
|
|
10
|
+
_merge_usage_objects,
|
|
11
|
+
_parse_event_ts_ms,
|
|
12
|
+
_process_command_message,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
ANALYTICS_SCHEMA_VERSION = 3
|
|
16
|
+
TOKEN_FIELDS = (
|
|
17
|
+
("input", "input_tokens"),
|
|
18
|
+
("output", "output_tokens"),
|
|
19
|
+
("cacheRead", "cache_read_input_tokens"),
|
|
20
|
+
("cacheCreation", "cache_creation_input_tokens"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _zero_tokens() -> dict[str, int]:
|
|
25
|
+
return {"input": 0, "output": 0, "cacheRead": 0, "cacheCreation": 0}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_tokens(dst: dict[str, int], src: dict[str, int]) -> None:
|
|
29
|
+
for key in _zero_tokens():
|
|
30
|
+
dst[key] = int(dst.get(key) or 0) + int(src.get(key) or 0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tokens_from_usage(usage: dict[str, Any]) -> dict[str, int]:
|
|
34
|
+
out = _zero_tokens()
|
|
35
|
+
for out_key, usage_key in TOKEN_FIELDS:
|
|
36
|
+
val = usage.get(usage_key)
|
|
37
|
+
if isinstance(val, int):
|
|
38
|
+
out[out_key] += val
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _message_id(message: dict[str, Any]) -> str | None:
|
|
43
|
+
value = message.get("id")
|
|
44
|
+
return value if isinstance(value, str) and value else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _add_model_usage(
|
|
48
|
+
prompt: dict[str, Any],
|
|
49
|
+
totals: dict[str, Any],
|
|
50
|
+
model: str | None,
|
|
51
|
+
tokens: dict[str, int],
|
|
52
|
+
*,
|
|
53
|
+
level: str = "main",
|
|
54
|
+
events: int = 1,
|
|
55
|
+
) -> None:
|
|
56
|
+
if model == "<synthetic>":
|
|
57
|
+
return
|
|
58
|
+
model = model or "unknown"
|
|
59
|
+
prompt_model = prompt.setdefault("byModel", {}).setdefault(
|
|
60
|
+
model, {"events": 0, "tokens": _zero_tokens()}
|
|
61
|
+
)
|
|
62
|
+
prompt_model["events"] += int(events)
|
|
63
|
+
_add_tokens(prompt_model["tokens"], tokens)
|
|
64
|
+
|
|
65
|
+
bucket = _model_bucket(totals, model)
|
|
66
|
+
bucket["events"] += int(events)
|
|
67
|
+
bucket["level"] = level if bucket.get("level") in (None, level) else "mixed"
|
|
68
|
+
_add_tokens(bucket["tokens"], tokens)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _content_blocks(content: Any) -> list[dict[str, Any]]:
|
|
72
|
+
if isinstance(content, list):
|
|
73
|
+
return [c for c in content if isinstance(c, dict)]
|
|
74
|
+
if isinstance(content, str):
|
|
75
|
+
return [{"type": "text", "text": content}]
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _text_from_user_content(content: Any) -> str:
|
|
80
|
+
parts = []
|
|
81
|
+
for block in _content_blocks(content):
|
|
82
|
+
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
83
|
+
processed = _process_command_message(block["text"])
|
|
84
|
+
if processed is not None:
|
|
85
|
+
parts.append(processed)
|
|
86
|
+
return "\n".join(parts).strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_synthetic_user_text(text: str) -> bool:
|
|
90
|
+
stripped = text.strip()
|
|
91
|
+
if stripped == "Continue from where you left off.":
|
|
92
|
+
return True
|
|
93
|
+
return stripped.startswith(("<bash-input>", "<bash-stdout>", "<bash-stderr>"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_pure_tool_result(content: Any) -> bool:
|
|
97
|
+
blocks = _content_blocks(content)
|
|
98
|
+
return bool(blocks) and all(b.get("type") == "tool_result" for b in blocks)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _contains_queued_command(value: Any) -> int:
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
count = 1 if value.get("type") == "queued_command" else 0
|
|
104
|
+
return count + sum(_contains_queued_command(v) for v in value.values())
|
|
105
|
+
if isinstance(value, list):
|
|
106
|
+
return sum(_contains_queued_command(v) for v in value)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _model_bucket(totals: dict[str, Any], model: str) -> dict[str, Any]:
|
|
111
|
+
by_model = totals.setdefault("byModel", {})
|
|
112
|
+
bucket = by_model.setdefault(model, {"events": 0, "tokens": _zero_tokens()})
|
|
113
|
+
return bucket
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _new_prompt(prompt_id: str, ts_ms: int | None, turn_index: int) -> dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"promptId": prompt_id,
|
|
119
|
+
"turnIndex": turn_index,
|
|
120
|
+
"tsMs": ts_ms,
|
|
121
|
+
"durationMs": 0,
|
|
122
|
+
"tokens": _zero_tokens(),
|
|
123
|
+
"byModel": {},
|
|
124
|
+
"tools": {},
|
|
125
|
+
"toolErrors": 0,
|
|
126
|
+
"askUserQuestions": 0,
|
|
127
|
+
"steering": {"enqueued": 0, "boundary": 0, "idle": 0},
|
|
128
|
+
"subagents": {"count": 0, "tokens": _zero_tokens(), "byAgent": {}},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _add_model_tokens(
|
|
133
|
+
prompt: dict[str, Any],
|
|
134
|
+
totals: dict[str, Any],
|
|
135
|
+
model: str | None,
|
|
136
|
+
tokens: dict[str, int],
|
|
137
|
+
*,
|
|
138
|
+
level: str = "main",
|
|
139
|
+
events: int = 1,
|
|
140
|
+
) -> None:
|
|
141
|
+
_add_model_usage(
|
|
142
|
+
prompt, totals, model, tokens, level=level, events=events
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _add_tool(prompt: dict[str, Any], totals: dict[str, Any], name: str) -> None:
|
|
147
|
+
prompt_tools = prompt.setdefault("tools", {})
|
|
148
|
+
prompt_tools[name] = int(prompt_tools.get(name) or 0) + 1
|
|
149
|
+
totals_tools = totals.setdefault("tools", {})
|
|
150
|
+
totals_tools[name] = int(totals_tools.get(name) or 0) + 1
|
|
151
|
+
if name == "AskUserQuestion":
|
|
152
|
+
prompt["askUserQuestions"] = int(prompt.get("askUserQuestions") or 0) + 1
|
|
153
|
+
totals["askUserQuestions"] = int(totals.get("askUserQuestions") or 0) + 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _prompt_by_id(prompts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
157
|
+
return {p["promptId"]: p for p in prompts}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _agent_label(evt: dict[str, Any], path: pathlib.Path) -> str:
|
|
161
|
+
return (
|
|
162
|
+
evt.get("attributionAgent")
|
|
163
|
+
or evt.get("attributionSkill")
|
|
164
|
+
or evt.get("entrypoint")
|
|
165
|
+
or evt.get("agentId")
|
|
166
|
+
or path.stem
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read_subagent_rollup(
|
|
171
|
+
path: pathlib.Path, stats: dict[str, int]
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
rollup = {
|
|
174
|
+
"path": str(path),
|
|
175
|
+
"agent": path.stem,
|
|
176
|
+
"promptId": None,
|
|
177
|
+
"tokens": _zero_tokens(),
|
|
178
|
+
"byModel": {},
|
|
179
|
+
}
|
|
180
|
+
local_stats: dict[str, int] = {}
|
|
181
|
+
usage_by_message: dict[str, dict[str, Any]] = {}
|
|
182
|
+
idless_usage_index = 0
|
|
183
|
+
try:
|
|
184
|
+
for evt in _iter_transcript(path, local_stats, include_sidechain=True):
|
|
185
|
+
if rollup["promptId"] is None and evt.get("promptId"):
|
|
186
|
+
rollup["promptId"] = evt.get("promptId")
|
|
187
|
+
rollup["agent"] = _agent_label(evt, path)
|
|
188
|
+
msg = evt.get("message") or {}
|
|
189
|
+
if evt.get("type") != "assistant" or not isinstance(msg, dict):
|
|
190
|
+
continue
|
|
191
|
+
usage = msg.get("usage") or {}
|
|
192
|
+
if not isinstance(usage, dict) or not usage:
|
|
193
|
+
continue
|
|
194
|
+
mid = _message_id(msg)
|
|
195
|
+
if mid is None:
|
|
196
|
+
idless_usage_index += 1
|
|
197
|
+
key = f"<idless>:{idless_usage_index}"
|
|
198
|
+
else:
|
|
199
|
+
key = mid
|
|
200
|
+
model = msg.get("model")
|
|
201
|
+
model = model if isinstance(model, str) and model else None
|
|
202
|
+
prior = usage_by_message.get(key)
|
|
203
|
+
if prior is None:
|
|
204
|
+
usage_by_message[key] = {
|
|
205
|
+
"usage": _merge_usage_objects(None, usage),
|
|
206
|
+
"model": model,
|
|
207
|
+
}
|
|
208
|
+
else:
|
|
209
|
+
prior["usage"] = _merge_usage_objects(
|
|
210
|
+
prior.get("usage"), usage
|
|
211
|
+
)
|
|
212
|
+
if model is not None:
|
|
213
|
+
prior["model"] = model
|
|
214
|
+
except OSError:
|
|
215
|
+
stats["subagentFilesUnreadable"] = (
|
|
216
|
+
stats.get("subagentFilesUnreadable", 0) + 1
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
for item in usage_by_message.values():
|
|
220
|
+
tokens = _tokens_from_usage(item.get("usage") or {})
|
|
221
|
+
_add_tokens(rollup["tokens"], tokens)
|
|
222
|
+
model = item.get("model")
|
|
223
|
+
if model and model != "<synthetic>":
|
|
224
|
+
bucket = rollup["byModel"].setdefault(
|
|
225
|
+
model, {"events": 0, "tokens": _zero_tokens()}
|
|
226
|
+
)
|
|
227
|
+
bucket["events"] += 1
|
|
228
|
+
_add_tokens(bucket["tokens"], tokens)
|
|
229
|
+
|
|
230
|
+
stats["malformedLines"] = (
|
|
231
|
+
stats.get("malformedLines", 0)
|
|
232
|
+
+ int(local_stats.get("malformedLines") or 0)
|
|
233
|
+
)
|
|
234
|
+
return rollup
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _add_subagent_rollups(rec: dict[str, Any], prompts: list[dict[str, Any]],
|
|
238
|
+
totals: dict[str, Any], stats: dict[str, int]) -> None:
|
|
239
|
+
cwd = rec.get("cwd") or ""
|
|
240
|
+
session_id = rec.get("sessionId") or ""
|
|
241
|
+
if not cwd or not session_id:
|
|
242
|
+
return
|
|
243
|
+
prompt_map = _prompt_by_id(prompts)
|
|
244
|
+
fallback_prompt = prompts[-1] if prompts else None
|
|
245
|
+
sub_total = totals.setdefault("subagents", {"count": 0, "tokens": _zero_tokens(), "byAgent": {}})
|
|
246
|
+
for path in iter_subagent_transcripts(cwd, session_id) or []:
|
|
247
|
+
rollup = _read_subagent_rollup(path, stats)
|
|
248
|
+
prompt = prompt_map.get(rollup.get("promptId")) or fallback_prompt
|
|
249
|
+
if prompt is None:
|
|
250
|
+
continue
|
|
251
|
+
sub_total["count"] = int(sub_total.get("count") or 0) + 1
|
|
252
|
+
_add_tokens(sub_total["tokens"], rollup["tokens"])
|
|
253
|
+
_add_tokens(totals["tokens"], rollup["tokens"])
|
|
254
|
+
agent_bucket = sub_total.setdefault("byAgent", {}).setdefault(
|
|
255
|
+
rollup["agent"], {"count": 0, "tokens": _zero_tokens(), "byModel": {}}
|
|
256
|
+
)
|
|
257
|
+
agent_bucket["count"] += 1
|
|
258
|
+
_add_tokens(agent_bucket["tokens"], rollup["tokens"])
|
|
259
|
+
for model, model_data in rollup["byModel"].items():
|
|
260
|
+
abm = agent_bucket["byModel"].setdefault(model, {"events": 0, "tokens": _zero_tokens()})
|
|
261
|
+
abm["events"] += int(model_data.get("events") or 0)
|
|
262
|
+
_add_tokens(abm["tokens"], model_data.get("tokens") or {})
|
|
263
|
+
_add_model_tokens(
|
|
264
|
+
prompt,
|
|
265
|
+
totals,
|
|
266
|
+
model,
|
|
267
|
+
model_data.get("tokens") or {},
|
|
268
|
+
level="subagent",
|
|
269
|
+
events=int(model_data.get("events") or 0),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
prompt_sub = prompt.setdefault("subagents", {"count": 0, "tokens": _zero_tokens(), "byAgent": {}})
|
|
273
|
+
prompt_sub["count"] = int(prompt_sub.get("count") or 0) + 1
|
|
274
|
+
_add_tokens(prompt_sub["tokens"], rollup["tokens"])
|
|
275
|
+
_add_tokens(prompt["tokens"], rollup["tokens"])
|
|
276
|
+
ps_agent = prompt_sub.setdefault("byAgent", {}).setdefault(
|
|
277
|
+
rollup["agent"], {"count": 0, "tokens": _zero_tokens()}
|
|
278
|
+
)
|
|
279
|
+
ps_agent["count"] += 1
|
|
280
|
+
_add_tokens(ps_agent["tokens"], rollup["tokens"])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def compute_session_analytics(
|
|
284
|
+
rec: dict[str, Any], *, before_offset: int | None = None,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
stats = {"malformedLines": 0, "subagentFilesUnreadable": 0}
|
|
287
|
+
totals: dict[str, Any] = {
|
|
288
|
+
"turns": 0,
|
|
289
|
+
"startedAt": None,
|
|
290
|
+
"endedAt": None,
|
|
291
|
+
"activeMs": 0,
|
|
292
|
+
"transcriptSpanMs": 0,
|
|
293
|
+
"tokens": _zero_tokens(),
|
|
294
|
+
"byModel": {},
|
|
295
|
+
"tools": {},
|
|
296
|
+
"toolErrors": 0,
|
|
297
|
+
"askUserQuestions": 0,
|
|
298
|
+
"subagents": {"count": 0, "tokens": _zero_tokens(), "byAgent": {}},
|
|
299
|
+
"steering": {"enqueued": 0, "boundary": 0, "idle": 0},
|
|
300
|
+
}
|
|
301
|
+
prompts: list[dict[str, Any]] = []
|
|
302
|
+
prompts_by_id: dict[str, dict[str, Any]] = {}
|
|
303
|
+
current: dict[str, Any] | None = None
|
|
304
|
+
first_ts: int | None = None
|
|
305
|
+
last_ts: int | None = None
|
|
306
|
+
generated_prompt_index = 0
|
|
307
|
+
queued_pending = 0
|
|
308
|
+
usage_by_message: dict[str, dict[str, Any]] = {}
|
|
309
|
+
idless_usage_index = 0
|
|
310
|
+
seen_tool_blocks: set[tuple[str, str]] = set()
|
|
311
|
+
|
|
312
|
+
transcript = rec.get("transcript") or ""
|
|
313
|
+
# A fork's transcript opens with its source's copied history; usage counts
|
|
314
|
+
# only what this worker incurred after the fork point.
|
|
315
|
+
from ccx.ops.forking import inherited_offset
|
|
316
|
+
for evt in _iter_transcript(
|
|
317
|
+
transcript, stats, after_offset=inherited_offset(rec),
|
|
318
|
+
before_offset=before_offset,
|
|
319
|
+
):
|
|
320
|
+
ts_ms = _parse_event_ts_ms(evt)
|
|
321
|
+
if ts_ms is not None:
|
|
322
|
+
first_ts = ts_ms if first_ts is None else min(first_ts, ts_ms)
|
|
323
|
+
last_ts = ts_ms if last_ts is None else max(last_ts, ts_ms)
|
|
324
|
+
|
|
325
|
+
etype = evt.get("type")
|
|
326
|
+
if etype == "queue-operation":
|
|
327
|
+
op = evt.get("operation")
|
|
328
|
+
if op == "enqueue":
|
|
329
|
+
queued_pending += 1
|
|
330
|
+
totals["steering"]["enqueued"] += 1
|
|
331
|
+
if current is not None:
|
|
332
|
+
current["steering"]["enqueued"] += 1
|
|
333
|
+
elif op in ("dequeue", "remove"):
|
|
334
|
+
queued_pending = max(0, queued_pending - 1)
|
|
335
|
+
if current is not None and ts_ms is not None:
|
|
336
|
+
current["_lastTsMs"] = ts_ms
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
queued_attachments = _contains_queued_command(evt)
|
|
340
|
+
if queued_attachments:
|
|
341
|
+
totals["steering"]["boundary"] += queued_attachments
|
|
342
|
+
if current is not None:
|
|
343
|
+
current["steering"]["boundary"] += queued_attachments
|
|
344
|
+
if ts_ms is not None:
|
|
345
|
+
current["_lastTsMs"] = ts_ms
|
|
346
|
+
queued_pending = max(0, queued_pending - queued_attachments)
|
|
347
|
+
|
|
348
|
+
msg = evt.get("message") or {}
|
|
349
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
350
|
+
if etype == "user":
|
|
351
|
+
if _is_pure_tool_result(content):
|
|
352
|
+
for block in _content_blocks(content):
|
|
353
|
+
if block.get("type") == "tool_result" and block.get("is_error"):
|
|
354
|
+
totals["toolErrors"] += 1
|
|
355
|
+
if current is not None:
|
|
356
|
+
current["toolErrors"] += 1
|
|
357
|
+
if current is not None and ts_ms is not None:
|
|
358
|
+
current["_lastTsMs"] = ts_ms
|
|
359
|
+
continue
|
|
360
|
+
text = _text_from_user_content(content)
|
|
361
|
+
if text and not _is_synthetic_user_text(text):
|
|
362
|
+
prompt_id = str(evt.get("promptId") or f"turn-{generated_prompt_index + 1}")
|
|
363
|
+
if prompt_id in prompts_by_id:
|
|
364
|
+
current = prompts_by_id[prompt_id]
|
|
365
|
+
else:
|
|
366
|
+
generated_prompt_index += 1
|
|
367
|
+
current = _new_prompt(prompt_id, ts_ms, generated_prompt_index)
|
|
368
|
+
prompts_by_id[prompt_id] = current
|
|
369
|
+
prompts.append(current)
|
|
370
|
+
if queued_pending:
|
|
371
|
+
current["steering"]["idle"] += queued_pending
|
|
372
|
+
totals["steering"]["idle"] += queued_pending
|
|
373
|
+
queued_pending = 0
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
if etype != "assistant" or current is None or not isinstance(msg, dict):
|
|
377
|
+
if current is not None and ts_ms is not None:
|
|
378
|
+
current["_lastTsMs"] = ts_ms
|
|
379
|
+
continue
|
|
380
|
+
if msg.get("model") == "<synthetic>":
|
|
381
|
+
continue
|
|
382
|
+
usage = msg.get("usage") or {}
|
|
383
|
+
if isinstance(usage, dict) and usage:
|
|
384
|
+
model = msg.get("model")
|
|
385
|
+
model = model if isinstance(model, str) and model else None
|
|
386
|
+
mid = _message_id(msg)
|
|
387
|
+
if mid is None:
|
|
388
|
+
idless_usage_index += 1
|
|
389
|
+
key = f"<idless>:{idless_usage_index}"
|
|
390
|
+
else:
|
|
391
|
+
key = mid
|
|
392
|
+
prior = usage_by_message.get(key)
|
|
393
|
+
if prior is None:
|
|
394
|
+
usage_by_message[key] = {
|
|
395
|
+
"usage": _merge_usage_objects(None, usage),
|
|
396
|
+
"prompt": current,
|
|
397
|
+
"model": model,
|
|
398
|
+
}
|
|
399
|
+
else:
|
|
400
|
+
prior["usage"] = _merge_usage_objects(
|
|
401
|
+
prior.get("usage"), usage
|
|
402
|
+
)
|
|
403
|
+
if model is not None:
|
|
404
|
+
prior["model"] = model
|
|
405
|
+
# First prompt attribution wins. A repeated API message id
|
|
406
|
+
# appearing after another user boundary is still one message,
|
|
407
|
+
# not fresh usage for the later prompt.
|
|
408
|
+
for block in _content_blocks(content):
|
|
409
|
+
if block.get("type") == "tool_use":
|
|
410
|
+
mid = _message_id(msg)
|
|
411
|
+
block_id = block.get("id")
|
|
412
|
+
if (
|
|
413
|
+
isinstance(mid, str)
|
|
414
|
+
and mid
|
|
415
|
+
and isinstance(block_id, str)
|
|
416
|
+
and block_id
|
|
417
|
+
):
|
|
418
|
+
tool_key = (mid, block_id)
|
|
419
|
+
if tool_key in seen_tool_blocks:
|
|
420
|
+
continue
|
|
421
|
+
seen_tool_blocks.add(tool_key)
|
|
422
|
+
_add_tool(current, totals, block.get("name") or "?")
|
|
423
|
+
if ts_ms is not None:
|
|
424
|
+
current["_lastTsMs"] = ts_ms
|
|
425
|
+
|
|
426
|
+
for item in usage_by_message.values():
|
|
427
|
+
target_prompt = item.get("prompt")
|
|
428
|
+
if not isinstance(target_prompt, dict):
|
|
429
|
+
continue
|
|
430
|
+
tokens = _tokens_from_usage(item.get("usage") or {})
|
|
431
|
+
_add_tokens(target_prompt["tokens"], tokens)
|
|
432
|
+
_add_tokens(totals["tokens"], tokens)
|
|
433
|
+
_add_model_usage(
|
|
434
|
+
target_prompt,
|
|
435
|
+
totals,
|
|
436
|
+
item.get("model"),
|
|
437
|
+
tokens,
|
|
438
|
+
level="main",
|
|
439
|
+
events=1,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
totals["turns"] = len(prompts)
|
|
443
|
+
for prompt in prompts:
|
|
444
|
+
last_prompt_ts = prompt.pop("_lastTsMs", None)
|
|
445
|
+
if prompt.get("tsMs") is not None and last_prompt_ts is not None:
|
|
446
|
+
prompt["durationMs"] = max(0, last_prompt_ts - prompt["tsMs"])
|
|
447
|
+
totals["activeMs"] += int(prompt.get("durationMs") or 0)
|
|
448
|
+
if first_ts is not None and last_ts is not None:
|
|
449
|
+
totals["startedAt"] = first_ts
|
|
450
|
+
totals["endedAt"] = last_ts
|
|
451
|
+
totals["transcriptSpanMs"] = max(0, last_ts - first_ts)
|
|
452
|
+
|
|
453
|
+
_add_subagent_rollups(rec, prompts, totals, stats)
|
|
454
|
+
totals["subagents"]["tokens"] = dict(totals["subagents"]["tokens"])
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
"analyticsSchemaVersion": ANALYTICS_SCHEMA_VERSION,
|
|
458
|
+
"generatedAt": int(time.time() * 1000),
|
|
459
|
+
"short": rec.get("short"),
|
|
460
|
+
"sessionId": rec.get("sessionId"),
|
|
461
|
+
"name": rec.get("name") or rec.get("short"),
|
|
462
|
+
"cwd": rec.get("cwd"),
|
|
463
|
+
"totals": totals,
|
|
464
|
+
"prompts": prompts,
|
|
465
|
+
"stats": stats,
|
|
466
|
+
}
|