codex-flow 2.1.13__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.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
"""App-Server JSON-RPC communication, usage parsing, transcript extraction, and metadata enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .common import (
|
|
19
|
+
SESSION_INDEX_FILE,
|
|
20
|
+
TIMEOUT,
|
|
21
|
+
text_value,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def session_index_metadata(session_id: Any) -> dict[str, Any] | None:
|
|
26
|
+
"""Read the local title index when app-server cannot materialize a thread."""
|
|
27
|
+
if not session_id or not SESSION_INDEX_FILE.is_file():
|
|
28
|
+
return None
|
|
29
|
+
target = str(session_id)
|
|
30
|
+
try:
|
|
31
|
+
lines = SESSION_INDEX_FILE.read_text(encoding="utf-8").splitlines()
|
|
32
|
+
except (OSError, UnicodeError):
|
|
33
|
+
return None
|
|
34
|
+
for raw in reversed(lines):
|
|
35
|
+
try:
|
|
36
|
+
entry = json.loads(raw)
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
continue
|
|
39
|
+
if not isinstance(entry, dict):
|
|
40
|
+
continue
|
|
41
|
+
entry_id = entry.get("id") or entry.get("session_id")
|
|
42
|
+
if str(entry_id or "") != target:
|
|
43
|
+
continue
|
|
44
|
+
metadata: dict[str, Any] = {"id": entry_id}
|
|
45
|
+
name = entry.get("thread_name") or entry.get("name")
|
|
46
|
+
if name is not None:
|
|
47
|
+
metadata["name"] = name
|
|
48
|
+
preview = entry.get("preview")
|
|
49
|
+
if preview is not None:
|
|
50
|
+
metadata["preview"] = preview
|
|
51
|
+
return metadata
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def merge_thread_metadata(*values: Any) -> dict[str, Any] | None:
|
|
56
|
+
result: dict[str, Any] = {}
|
|
57
|
+
for value in values:
|
|
58
|
+
if not isinstance(value, dict):
|
|
59
|
+
continue
|
|
60
|
+
for key, item in value.items():
|
|
61
|
+
if item is not None and item != "":
|
|
62
|
+
result[key] = item
|
|
63
|
+
return result or None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def app_server_command() -> list[str]:
|
|
67
|
+
override = os.environ.get("CODEX_FLOW_APP_SERVER_COMMAND")
|
|
68
|
+
if override:
|
|
69
|
+
return shlex.split(override)
|
|
70
|
+
explicit = os.environ.get("CODEX_FLOW_CODEX_PATH")
|
|
71
|
+
explicit_path = Path(explicit).expanduser() if explicit else None
|
|
72
|
+
if explicit_path and os.access(explicit_path, os.X_OK):
|
|
73
|
+
explicit_dir = str(explicit_path.parent)
|
|
74
|
+
current_path = os.environ.get("PATH", "")
|
|
75
|
+
os.environ["PATH"] = (
|
|
76
|
+
explicit_dir
|
|
77
|
+
if not current_path
|
|
78
|
+
else os.pathsep.join((explicit_dir, current_path))
|
|
79
|
+
)
|
|
80
|
+
return [str(explicit_path), "app-server"]
|
|
81
|
+
|
|
82
|
+
home = Path.home()
|
|
83
|
+
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex")).expanduser()
|
|
84
|
+
directories: list[Path] = []
|
|
85
|
+
candidates: list[Path] = []
|
|
86
|
+
|
|
87
|
+
def add_directory(path: Path) -> None:
|
|
88
|
+
if path not in directories:
|
|
89
|
+
directories.append(path)
|
|
90
|
+
|
|
91
|
+
def add_candidate(path: Path) -> None:
|
|
92
|
+
if path not in candidates:
|
|
93
|
+
candidates.append(path)
|
|
94
|
+
|
|
95
|
+
for raw in os.environ.get("PATH", "").split(os.pathsep):
|
|
96
|
+
if raw:
|
|
97
|
+
directory = Path(raw)
|
|
98
|
+
add_directory(directory)
|
|
99
|
+
add_candidate(directory / "codex")
|
|
100
|
+
|
|
101
|
+
for directory in (
|
|
102
|
+
home / ".local/bin",
|
|
103
|
+
home / ".npm-global/bin",
|
|
104
|
+
home / "Library/pnpm",
|
|
105
|
+
home / ".volta/bin",
|
|
106
|
+
home / ".bun/bin",
|
|
107
|
+
codex_home / "bin",
|
|
108
|
+
home / "Applications/ChatGPT.app/Contents/Resources",
|
|
109
|
+
Path("/opt/homebrew/bin"),
|
|
110
|
+
Path("/usr/local/bin"),
|
|
111
|
+
Path("/Applications/ChatGPT.app/Contents/Resources"),
|
|
112
|
+
Path("/usr/bin"),
|
|
113
|
+
Path("/bin"),
|
|
114
|
+
):
|
|
115
|
+
add_directory(directory)
|
|
116
|
+
add_candidate(directory / "codex")
|
|
117
|
+
|
|
118
|
+
nvm_roots = [
|
|
119
|
+
Path(os.environ["NVM_DIR"]).expanduser() if os.environ.get("NVM_DIR") else None,
|
|
120
|
+
home / ".nvm",
|
|
121
|
+
home / ".config/nvm",
|
|
122
|
+
]
|
|
123
|
+
for root in (path for path in nvm_roots if path is not None):
|
|
124
|
+
for alias in ("current", "default"):
|
|
125
|
+
directory = root / alias / "bin"
|
|
126
|
+
add_directory(directory)
|
|
127
|
+
add_candidate(directory / "codex")
|
|
128
|
+
versions = root / "versions/node"
|
|
129
|
+
try:
|
|
130
|
+
entries = sorted(
|
|
131
|
+
(path for path in versions.iterdir() if path.is_dir()),
|
|
132
|
+
key=_nvm_version_sort_key,
|
|
133
|
+
reverse=True,
|
|
134
|
+
)
|
|
135
|
+
except OSError:
|
|
136
|
+
entries = []
|
|
137
|
+
for version in entries:
|
|
138
|
+
directory = version / "bin"
|
|
139
|
+
add_directory(directory)
|
|
140
|
+
add_candidate(directory / "codex")
|
|
141
|
+
|
|
142
|
+
prefix = os.environ.get("npm_config_prefix") or os.environ.get("PREFIX")
|
|
143
|
+
if prefix:
|
|
144
|
+
directory = Path(prefix).expanduser() / "bin"
|
|
145
|
+
add_directory(directory)
|
|
146
|
+
add_candidate(directory / "codex")
|
|
147
|
+
|
|
148
|
+
for candidate in candidates:
|
|
149
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
150
|
+
if directories:
|
|
151
|
+
os.environ["PATH"] = os.pathsep.join(str(path) for path in directories)
|
|
152
|
+
return [str(candidate), "app-server"]
|
|
153
|
+
|
|
154
|
+
# Keep an enriched PATH for launchd/Finder launches when no direct path was
|
|
155
|
+
# found; shell sessions still use the normal codex lookup behavior.
|
|
156
|
+
if directories:
|
|
157
|
+
os.environ["PATH"] = os.pathsep.join(str(path) for path in directories)
|
|
158
|
+
return ["codex", "app-server"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _nvm_version_sort_key(path: Path) -> tuple[tuple[int, ...], str]:
|
|
162
|
+
"""Order versioned nvm candidates numerically, with a stable name tie-break."""
|
|
163
|
+
return tuple(int(part) for part in re.findall(r"\d+", path.name)), path.name
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class AppServer:
|
|
167
|
+
"""Minimal stdio JSONL client for the Codex app-server."""
|
|
168
|
+
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
self.proc: subprocess.Popen[str] | None = None
|
|
171
|
+
self.next_id = 1
|
|
172
|
+
self.messages: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
|
173
|
+
self.reader: threading.Thread | None = None
|
|
174
|
+
|
|
175
|
+
def __enter__(self) -> "AppServer":
|
|
176
|
+
try:
|
|
177
|
+
self.proc = subprocess.Popen(
|
|
178
|
+
app_server_command(),
|
|
179
|
+
stdin=subprocess.PIPE,
|
|
180
|
+
stdout=subprocess.PIPE,
|
|
181
|
+
stderr=subprocess.DEVNULL,
|
|
182
|
+
text=True,
|
|
183
|
+
bufsize=1,
|
|
184
|
+
)
|
|
185
|
+
self.reader = threading.Thread(target=self._pump_stdout, daemon=True)
|
|
186
|
+
self.reader.start()
|
|
187
|
+
response = self.request(
|
|
188
|
+
"initialize",
|
|
189
|
+
{
|
|
190
|
+
"clientInfo": {
|
|
191
|
+
"name": "codex-flow",
|
|
192
|
+
"title": "FlowPilot telemetry",
|
|
193
|
+
"version": "1",
|
|
194
|
+
},
|
|
195
|
+
"capabilities": {"experimentalApi": True},
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
if response is None:
|
|
199
|
+
self.close()
|
|
200
|
+
return self
|
|
201
|
+
self.notify("initialized")
|
|
202
|
+
except (OSError, ValueError):
|
|
203
|
+
self.close()
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *_: object) -> None:
|
|
207
|
+
self.close()
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def available(self) -> bool:
|
|
211
|
+
return (
|
|
212
|
+
self.proc is not None
|
|
213
|
+
and self.proc.poll() is None
|
|
214
|
+
and self.proc.stdin is not None
|
|
215
|
+
and self.proc.stdout is not None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _pump_stdout(self) -> None:
|
|
219
|
+
proc = self.proc
|
|
220
|
+
if proc is None or proc.stdout is None:
|
|
221
|
+
self.messages.put(None)
|
|
222
|
+
return
|
|
223
|
+
try:
|
|
224
|
+
for line in proc.stdout:
|
|
225
|
+
try:
|
|
226
|
+
message = json.loads(line)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
continue
|
|
229
|
+
if isinstance(message, dict):
|
|
230
|
+
self.messages.put(message)
|
|
231
|
+
finally:
|
|
232
|
+
self.messages.put(None)
|
|
233
|
+
|
|
234
|
+
def close(self) -> None:
|
|
235
|
+
proc = self.proc
|
|
236
|
+
if proc is None:
|
|
237
|
+
return
|
|
238
|
+
try:
|
|
239
|
+
proc.terminate()
|
|
240
|
+
proc.wait(timeout=0.4)
|
|
241
|
+
except Exception:
|
|
242
|
+
try:
|
|
243
|
+
proc.kill()
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
self.proc = None
|
|
247
|
+
|
|
248
|
+
def _send(self, payload: dict[str, Any]) -> bool:
|
|
249
|
+
if not self.available:
|
|
250
|
+
return False
|
|
251
|
+
try:
|
|
252
|
+
assert self.proc is not None and self.proc.stdin is not None
|
|
253
|
+
self.proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
254
|
+
self.proc.stdin.flush()
|
|
255
|
+
return True
|
|
256
|
+
except (BrokenPipeError, OSError):
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
def notify(self, method: str, params: Any | None = None) -> None:
|
|
260
|
+
payload: dict[str, Any] = {"method": method}
|
|
261
|
+
if params is not None:
|
|
262
|
+
payload["params"] = params
|
|
263
|
+
self._send(payload)
|
|
264
|
+
|
|
265
|
+
def request(self, method: str, params: Any | None = None) -> dict[str, Any] | None:
|
|
266
|
+
request_id = self.next_id
|
|
267
|
+
self.next_id += 1
|
|
268
|
+
payload: dict[str, Any] = {"id": request_id, "method": method}
|
|
269
|
+
if params is not None:
|
|
270
|
+
payload["params"] = params
|
|
271
|
+
if not self._send(payload):
|
|
272
|
+
return None
|
|
273
|
+
return self._read_response(request_id)
|
|
274
|
+
|
|
275
|
+
def _read_response(self, request_id: int) -> dict[str, Any] | None:
|
|
276
|
+
deadline = time.monotonic() + TIMEOUT
|
|
277
|
+
deferred: list[dict[str, Any]] = []
|
|
278
|
+
try:
|
|
279
|
+
while time.monotonic() < deadline:
|
|
280
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
281
|
+
try:
|
|
282
|
+
message = self.messages.get(timeout=remaining)
|
|
283
|
+
except queue.Empty:
|
|
284
|
+
break
|
|
285
|
+
if message is None:
|
|
286
|
+
break
|
|
287
|
+
if message.get("id") != request_id:
|
|
288
|
+
deferred.append(message)
|
|
289
|
+
continue
|
|
290
|
+
if "error" in message:
|
|
291
|
+
return None
|
|
292
|
+
result = message.get("result")
|
|
293
|
+
return result if isinstance(result, dict) else None
|
|
294
|
+
finally:
|
|
295
|
+
for message in deferred:
|
|
296
|
+
self.messages.put(message)
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def rate_limits(self) -> dict[str, Any] | None:
|
|
300
|
+
return self.request("account/rateLimits/read")
|
|
301
|
+
|
|
302
|
+
def thread_usage(self, thread_id: str | None) -> dict[str, Any] | None:
|
|
303
|
+
if not thread_id:
|
|
304
|
+
return None
|
|
305
|
+
response = self.request("account/usage/read", {"threadId": thread_id})
|
|
306
|
+
if not response:
|
|
307
|
+
return None
|
|
308
|
+
usage = response.get("threadUsage")
|
|
309
|
+
return usage if isinstance(usage, dict) else None
|
|
310
|
+
|
|
311
|
+
def thread_metadata(self, thread_id: str | None) -> dict[str, Any] | None:
|
|
312
|
+
"""Read deterministic user-facing thread metadata without loading turns."""
|
|
313
|
+
if not thread_id:
|
|
314
|
+
return None
|
|
315
|
+
response = self.request(
|
|
316
|
+
"thread/read", {"threadId": thread_id, "includeTurns": False}
|
|
317
|
+
)
|
|
318
|
+
if not response:
|
|
319
|
+
return None
|
|
320
|
+
thread = response.get("thread")
|
|
321
|
+
return thread if isinstance(thread, dict) else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def quota_windows(snapshot: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
325
|
+
"""Return deterministic logical windows from current and legacy API views.
|
|
326
|
+
|
|
327
|
+
Newer Codex responses expose ``rateLimitsByLimitId.codex`` while older
|
|
328
|
+
responses put the same slots in ``rateLimits``. Codex fields win and each
|
|
329
|
+
missing field is filled from the legacy slot. Duplicate logical rows are
|
|
330
|
+
then collapsed by duration/reset while preserving same-reset rows whose
|
|
331
|
+
durations differ.
|
|
332
|
+
"""
|
|
333
|
+
if not isinstance(snapshot, dict):
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
legacy = snapshot.get("rateLimits")
|
|
337
|
+
if not isinstance(legacy, dict):
|
|
338
|
+
legacy = {}
|
|
339
|
+
by_id = snapshot.get("rateLimitsByLimitId")
|
|
340
|
+
if not isinstance(by_id, dict):
|
|
341
|
+
by_id = {}
|
|
342
|
+
codex = by_id.get("codex")
|
|
343
|
+
if not isinstance(codex, dict):
|
|
344
|
+
codex = {}
|
|
345
|
+
|
|
346
|
+
candidates: list[tuple[tuple[int, int], str, dict[str, Any]]] = []
|
|
347
|
+
for slot_index, slot in enumerate(("primary", "secondary")):
|
|
348
|
+
codex_window = codex.get(slot)
|
|
349
|
+
legacy_window = legacy.get(slot)
|
|
350
|
+
codex_window = codex_window if isinstance(codex_window, dict) else None
|
|
351
|
+
legacy_window = legacy_window if isinstance(legacy_window, dict) else None
|
|
352
|
+
if codex_window is None and legacy_window is None:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
duration = _first_quota_number(
|
|
356
|
+
codex_window,
|
|
357
|
+
legacy_window,
|
|
358
|
+
("windowDurationMins", "windowMinutes", "window_duration_mins"),
|
|
359
|
+
integer=True,
|
|
360
|
+
)
|
|
361
|
+
reset = _first_quota_number(codex_window, legacy_window, ("resetsAt", "resets_at"))
|
|
362
|
+
used = _first_quota_number(codex_window, legacy_window, ("usedPercent", "used_percent"))
|
|
363
|
+
if duration is None and reset is None and used is None:
|
|
364
|
+
continue
|
|
365
|
+
normalized = {
|
|
366
|
+
"slot": slot,
|
|
367
|
+
"used_percent": used,
|
|
368
|
+
"window_duration_mins": duration,
|
|
369
|
+
"resets_at": reset,
|
|
370
|
+
}
|
|
371
|
+
candidates.append(
|
|
372
|
+
((0 if codex_window is not None else 1, slot_index), slot, normalized)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def logical_key(row: dict[str, Any], slot: str) -> tuple[Any, ...]:
|
|
376
|
+
duration = row.get("window_duration_mins")
|
|
377
|
+
reset = row.get("resets_at")
|
|
378
|
+
if reset is not None:
|
|
379
|
+
reset_key = reset / 1000.0 if reset > 1_000_000_000_000 else reset
|
|
380
|
+
return (duration, reset_key)
|
|
381
|
+
# Without a reset timestamp there is not enough information to know
|
|
382
|
+
# whether two same-duration slots are duplicates. Keep their slot
|
|
383
|
+
# identity (and, for malformed responses, the observed usage) so an
|
|
384
|
+
# incomplete bucket cannot hide another incomplete bucket.
|
|
385
|
+
return (duration, reset, slot, row.get("used_percent"))
|
|
386
|
+
|
|
387
|
+
selected: dict[tuple[Any, ...], tuple[tuple[int, int], dict[str, Any]]] = {}
|
|
388
|
+
for rank, slot, row in candidates:
|
|
389
|
+
key = logical_key(row, slot)
|
|
390
|
+
current = selected.get(key)
|
|
391
|
+
if current is None or rank < current[0]:
|
|
392
|
+
selected[key] = (rank, row)
|
|
393
|
+
|
|
394
|
+
return [
|
|
395
|
+
row
|
|
396
|
+
for _, row in sorted(
|
|
397
|
+
selected.values(),
|
|
398
|
+
key=lambda item: (
|
|
399
|
+
item[1].get("window_duration_mins")
|
|
400
|
+
if isinstance(item[1].get("window_duration_mins"), (int, float))
|
|
401
|
+
else float("inf"),
|
|
402
|
+
item[1].get("resets_at") if item[1].get("resets_at") is not None else float("inf"),
|
|
403
|
+
item[1].get("slot", ""),
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _quota_number(value: Any, *, integer: bool = False) -> int | float | None:
|
|
410
|
+
if isinstance(value, bool):
|
|
411
|
+
return None
|
|
412
|
+
if isinstance(value, (int, float)):
|
|
413
|
+
if not math.isfinite(float(value)):
|
|
414
|
+
return None
|
|
415
|
+
return int(value) if integer else value
|
|
416
|
+
if isinstance(value, str):
|
|
417
|
+
try:
|
|
418
|
+
parsed = float(value.strip())
|
|
419
|
+
except ValueError:
|
|
420
|
+
return None
|
|
421
|
+
if not math.isfinite(parsed):
|
|
422
|
+
return None
|
|
423
|
+
return int(parsed) if integer else parsed
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _first_quota_number(
|
|
428
|
+
primary: dict[str, Any] | None,
|
|
429
|
+
fallback: dict[str, Any] | None,
|
|
430
|
+
keys: tuple[str, ...],
|
|
431
|
+
*,
|
|
432
|
+
integer: bool = False,
|
|
433
|
+
) -> int | float | None:
|
|
434
|
+
"""Use the first valid value, allowing malformed codex fields to fall back."""
|
|
435
|
+
for source in (primary, fallback):
|
|
436
|
+
if not isinstance(source, dict):
|
|
437
|
+
continue
|
|
438
|
+
for key in keys:
|
|
439
|
+
parsed = _quota_number(source.get(key), integer=integer)
|
|
440
|
+
if parsed is not None:
|
|
441
|
+
return parsed
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def usage_summary(usage: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
446
|
+
if not usage:
|
|
447
|
+
return None
|
|
448
|
+
groups = usage.get("groups") if isinstance(usage.get("groups"), list) else []
|
|
449
|
+
estimated_credits = usage.get("estimatedUsageCreditsMicros")
|
|
450
|
+
totals: dict[str, Any] = {
|
|
451
|
+
"input_tokens": 0,
|
|
452
|
+
"cached_input_tokens": 0,
|
|
453
|
+
"net_new_input_tokens": 0,
|
|
454
|
+
"output_tokens": 0,
|
|
455
|
+
"total_tokens": 0,
|
|
456
|
+
"estimated_credits_micros": (
|
|
457
|
+
int(estimated_credits)
|
|
458
|
+
if isinstance(estimated_credits, (int, float))
|
|
459
|
+
else None
|
|
460
|
+
),
|
|
461
|
+
"estimated_usd_micros": usage.get("estimatedUsageUsdMicros"),
|
|
462
|
+
}
|
|
463
|
+
normalized_groups = []
|
|
464
|
+
mapping = {
|
|
465
|
+
"inputTokens": "input_tokens",
|
|
466
|
+
"cachedInputTokens": "cached_input_tokens",
|
|
467
|
+
"netNewInputTokens": "net_new_input_tokens",
|
|
468
|
+
"outputTokens": "output_tokens",
|
|
469
|
+
"totalTokens": "total_tokens",
|
|
470
|
+
}
|
|
471
|
+
for group in groups:
|
|
472
|
+
if not isinstance(group, dict):
|
|
473
|
+
continue
|
|
474
|
+
normalized: dict[str, Any] = {
|
|
475
|
+
"model": group.get("model"),
|
|
476
|
+
"reasoning_effort": group.get("reasoningEffort"),
|
|
477
|
+
"speed": group.get("speed"),
|
|
478
|
+
"estimated_credits_micros": (
|
|
479
|
+
int(group["estimatedUsageCreditsMicros"])
|
|
480
|
+
if isinstance(group.get("estimatedUsageCreditsMicros"), (int, float))
|
|
481
|
+
else None
|
|
482
|
+
),
|
|
483
|
+
}
|
|
484
|
+
for source, target in mapping.items():
|
|
485
|
+
value = group.get(source)
|
|
486
|
+
normalized[target] = int(value) if isinstance(value, (int, float)) else None
|
|
487
|
+
if normalized[target] is not None:
|
|
488
|
+
totals[target] += normalized[target]
|
|
489
|
+
normalized_groups.append(normalized)
|
|
490
|
+
totals["groups"] = normalized_groups
|
|
491
|
+
return totals
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def usage_delta(
|
|
495
|
+
before: dict[str, Any] | None, after: dict[str, Any] | None
|
|
496
|
+
) -> dict[str, Any] | None:
|
|
497
|
+
if not before or not after:
|
|
498
|
+
return None
|
|
499
|
+
numeric = [
|
|
500
|
+
"input_tokens",
|
|
501
|
+
"cached_input_tokens",
|
|
502
|
+
"net_new_input_tokens",
|
|
503
|
+
"output_tokens",
|
|
504
|
+
"reasoning_output_tokens",
|
|
505
|
+
"cache_write_input_tokens",
|
|
506
|
+
"total_tokens",
|
|
507
|
+
"estimated_credits_micros",
|
|
508
|
+
]
|
|
509
|
+
result: dict[str, Any] = {}
|
|
510
|
+
for key in numeric:
|
|
511
|
+
end = after.get(key)
|
|
512
|
+
start = before.get(key)
|
|
513
|
+
if isinstance(end, (int, float)) and isinstance(start, (int, float)):
|
|
514
|
+
result[key] = max(0, int(end - start))
|
|
515
|
+
usd_after = after.get("estimated_usd_micros")
|
|
516
|
+
usd_before = before.get("estimated_usd_micros")
|
|
517
|
+
if isinstance(usd_after, (int, float)) and isinstance(usd_before, (int, float)):
|
|
518
|
+
result["estimated_usd_micros"] = max(0, int(usd_after - usd_before))
|
|
519
|
+
|
|
520
|
+
identity_fields = ("model", "reasoning_effort", "speed")
|
|
521
|
+
group_numeric = (
|
|
522
|
+
"input_tokens",
|
|
523
|
+
"cached_input_tokens",
|
|
524
|
+
"net_new_input_tokens",
|
|
525
|
+
"output_tokens",
|
|
526
|
+
"total_tokens",
|
|
527
|
+
"estimated_credits_micros",
|
|
528
|
+
"estimated_usd_micros",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
def group_identity(group: Any) -> tuple[Any, ...] | None:
|
|
532
|
+
if not isinstance(group, dict):
|
|
533
|
+
return None
|
|
534
|
+
identity = tuple(group.get(field) for field in identity_fields)
|
|
535
|
+
if any(value is None for value in identity):
|
|
536
|
+
return None
|
|
537
|
+
return identity
|
|
538
|
+
|
|
539
|
+
before_groups: dict[tuple[Any, ...], dict[str, Any]] = {}
|
|
540
|
+
duplicate_before: set[tuple[Any, ...]] = set()
|
|
541
|
+
before_group_values = before.get("groups")
|
|
542
|
+
if not isinstance(before_group_values, list):
|
|
543
|
+
before_group_values = []
|
|
544
|
+
for group in before_group_values:
|
|
545
|
+
identity = group_identity(group)
|
|
546
|
+
if identity is None:
|
|
547
|
+
continue
|
|
548
|
+
if identity in before_groups:
|
|
549
|
+
duplicate_before.add(identity)
|
|
550
|
+
else:
|
|
551
|
+
before_groups[identity] = group
|
|
552
|
+
|
|
553
|
+
delta_groups: list[dict[str, Any]] = []
|
|
554
|
+
after_group_values = after.get("groups")
|
|
555
|
+
if not isinstance(after_group_values, list):
|
|
556
|
+
after_group_values = []
|
|
557
|
+
for group in after_group_values:
|
|
558
|
+
identity = group_identity(group)
|
|
559
|
+
if identity is None or identity in duplicate_before:
|
|
560
|
+
continue
|
|
561
|
+
old = before_groups.get(identity)
|
|
562
|
+
if old is None:
|
|
563
|
+
continue
|
|
564
|
+
delta_group: dict[str, Any] = {
|
|
565
|
+
field: group[field] for field in identity_fields
|
|
566
|
+
}
|
|
567
|
+
comparable = False
|
|
568
|
+
for key in group_numeric:
|
|
569
|
+
end = group.get(key)
|
|
570
|
+
start = old.get(key)
|
|
571
|
+
if isinstance(end, (int, float)) and isinstance(start, (int, float)):
|
|
572
|
+
delta_group[key] = max(0, int(end - start))
|
|
573
|
+
comparable = True
|
|
574
|
+
if comparable:
|
|
575
|
+
delta_groups.append(delta_group)
|
|
576
|
+
result["groups"] = delta_groups
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def normalize_transcript_usage(value: Any) -> dict[str, Any] | None:
|
|
581
|
+
if not isinstance(value, dict):
|
|
582
|
+
return None
|
|
583
|
+
fields = (
|
|
584
|
+
"input_tokens",
|
|
585
|
+
"cached_input_tokens",
|
|
586
|
+
"cache_write_input_tokens",
|
|
587
|
+
"output_tokens",
|
|
588
|
+
"reasoning_output_tokens",
|
|
589
|
+
"total_tokens",
|
|
590
|
+
)
|
|
591
|
+
result: dict[str, Any] = {}
|
|
592
|
+
for field in fields:
|
|
593
|
+
raw = value.get(field)
|
|
594
|
+
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
|
595
|
+
result[field] = int(raw)
|
|
596
|
+
if not result:
|
|
597
|
+
return None
|
|
598
|
+
input_tokens = result.get("input_tokens")
|
|
599
|
+
cached_tokens = result.get("cached_input_tokens")
|
|
600
|
+
cache_write_tokens = result.get("cache_write_input_tokens", 0)
|
|
601
|
+
if isinstance(input_tokens, int) and isinstance(cached_tokens, int):
|
|
602
|
+
result["net_new_input_tokens"] = max(
|
|
603
|
+
0, input_tokens - cached_tokens - cache_write_tokens
|
|
604
|
+
)
|
|
605
|
+
return result
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def event_reasoning_effort(event: dict[str, Any] | None) -> str | None:
|
|
609
|
+
if not isinstance(event, dict):
|
|
610
|
+
return None
|
|
611
|
+
for key in (
|
|
612
|
+
"reasoning_effort",
|
|
613
|
+
"reasoningEffort",
|
|
614
|
+
"effort",
|
|
615
|
+
"model_reasoning_effort",
|
|
616
|
+
):
|
|
617
|
+
value = text_value(event.get(key))
|
|
618
|
+
if value:
|
|
619
|
+
return value
|
|
620
|
+
for key in ("thread_settings", "threadSettings", "settings"):
|
|
621
|
+
settings = event.get(key)
|
|
622
|
+
if not isinstance(settings, dict):
|
|
623
|
+
continue
|
|
624
|
+
for setting_key in (
|
|
625
|
+
"reasoning_effort",
|
|
626
|
+
"reasoningEffort",
|
|
627
|
+
"effort",
|
|
628
|
+
"model_reasoning_effort",
|
|
629
|
+
):
|
|
630
|
+
value = text_value(settings.get(setting_key))
|
|
631
|
+
if value:
|
|
632
|
+
return value
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def transcript_turn_metadata(path_value: Any, turn_id: Any) -> dict[str, Any] | None:
|
|
637
|
+
if not isinstance(path_value, str) or not path_value or not turn_id:
|
|
638
|
+
return None
|
|
639
|
+
path = Path(path_value)
|
|
640
|
+
if not path.is_file():
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
target_turn = str(turn_id)
|
|
644
|
+
result: dict[str, Any] = {}
|
|
645
|
+
try:
|
|
646
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
647
|
+
for line in stream:
|
|
648
|
+
try:
|
|
649
|
+
record = json.loads(line)
|
|
650
|
+
except json.JSONDecodeError:
|
|
651
|
+
continue
|
|
652
|
+
if not isinstance(record, dict):
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
record_type = record.get("type")
|
|
656
|
+
payload = record.get("payload")
|
|
657
|
+
if not isinstance(payload, dict):
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
candidate: dict[str, Any] | None = None
|
|
661
|
+
candidate_turn: Any = None
|
|
662
|
+
if record_type == "turn_context":
|
|
663
|
+
candidate = payload
|
|
664
|
+
candidate_turn = payload.get("turn_id")
|
|
665
|
+
elif record_type == "event_msg":
|
|
666
|
+
event_type = payload.get("type")
|
|
667
|
+
if event_type == "task_started":
|
|
668
|
+
candidate = payload
|
|
669
|
+
candidate_turn = payload.get("turn_id")
|
|
670
|
+
elif event_type == "thread_settings_applied":
|
|
671
|
+
candidate = payload.get("thread_settings")
|
|
672
|
+
candidate_turn = payload.get("turn_id")
|
|
673
|
+
|
|
674
|
+
if candidate_turn is None or str(candidate_turn) != target_turn:
|
|
675
|
+
continue
|
|
676
|
+
if not isinstance(candidate, dict):
|
|
677
|
+
continue
|
|
678
|
+
model = text_value(candidate.get("model"))
|
|
679
|
+
if model:
|
|
680
|
+
result["model"] = model
|
|
681
|
+
effort = event_reasoning_effort(candidate)
|
|
682
|
+
if effort:
|
|
683
|
+
result["reasoning_effort"] = effort
|
|
684
|
+
except (OSError, UnicodeError):
|
|
685
|
+
return None
|
|
686
|
+
return result or None
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def find_session_transcript(session_id: Any) -> str | None:
|
|
690
|
+
"""Locate a session transcript file from ~/.codex/sessions by session_id."""
|
|
691
|
+
if not session_id:
|
|
692
|
+
return None
|
|
693
|
+
sid = str(session_id).strip()
|
|
694
|
+
if not sid:
|
|
695
|
+
return None
|
|
696
|
+
home = Path.home()
|
|
697
|
+
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex")).expanduser()
|
|
698
|
+
sessions_dir = codex_home / "sessions"
|
|
699
|
+
if not sessions_dir.is_dir():
|
|
700
|
+
return None
|
|
701
|
+
try:
|
|
702
|
+
matches = list(sessions_dir.glob(f"**/*{sid}*.jsonl"))
|
|
703
|
+
if matches:
|
|
704
|
+
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
705
|
+
return str(matches[0])
|
|
706
|
+
except (OSError, UnicodeError):
|
|
707
|
+
pass
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _transcript_timestamp_ms(value: Any) -> int | None:
|
|
712
|
+
"""Normalize a transcript record timestamp to epoch milliseconds."""
|
|
713
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
714
|
+
# Transcript timestamps are normally ISO strings. Treat small numeric
|
|
715
|
+
# values as seconds so fixtures and older writers remain readable.
|
|
716
|
+
return int(value * 1000) if abs(float(value)) < 10_000_000_000 else int(value)
|
|
717
|
+
if not isinstance(value, str) or not value.strip():
|
|
718
|
+
return None
|
|
719
|
+
raw = value.strip()
|
|
720
|
+
try:
|
|
721
|
+
numeric = float(raw)
|
|
722
|
+
except ValueError:
|
|
723
|
+
numeric = None
|
|
724
|
+
if numeric is not None:
|
|
725
|
+
return int(numeric * 1000) if abs(numeric) < 10_000_000_000 else int(numeric)
|
|
726
|
+
try:
|
|
727
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
728
|
+
except ValueError:
|
|
729
|
+
return None
|
|
730
|
+
if parsed.tzinfo is None:
|
|
731
|
+
parsed = parsed.astimezone()
|
|
732
|
+
return int(parsed.timestamp() * 1000)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def transcript_turn_started_at(path_value: Any, turn_id: Any) -> int | None:
|
|
736
|
+
"""Return the recorded task-start timestamp for one transcript turn.
|
|
737
|
+
|
|
738
|
+
Callers use the exact ``task_started`` event to correlate delayed hooks
|
|
739
|
+
with a parent lifecycle interval instead of using the hook arrival time.
|
|
740
|
+
"""
|
|
741
|
+
if not isinstance(path_value, str) or not path_value or not turn_id:
|
|
742
|
+
return None
|
|
743
|
+
path = Path(path_value)
|
|
744
|
+
if not path.is_file():
|
|
745
|
+
return None
|
|
746
|
+
target_turn = str(turn_id)
|
|
747
|
+
try:
|
|
748
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
749
|
+
for line in stream:
|
|
750
|
+
try:
|
|
751
|
+
record = json.loads(line)
|
|
752
|
+
except json.JSONDecodeError:
|
|
753
|
+
continue
|
|
754
|
+
if not isinstance(record, dict) or record.get("type") != "event_msg":
|
|
755
|
+
continue
|
|
756
|
+
payload = record.get("payload")
|
|
757
|
+
if not isinstance(payload, dict) or payload.get("type") != "task_started":
|
|
758
|
+
continue
|
|
759
|
+
candidate_turn = payload.get("turn_id")
|
|
760
|
+
if candidate_turn is None or str(candidate_turn) != target_turn:
|
|
761
|
+
continue
|
|
762
|
+
timestamp = record.get("timestamp")
|
|
763
|
+
if timestamp is None:
|
|
764
|
+
timestamp = payload.get("timestamp")
|
|
765
|
+
if timestamp is None:
|
|
766
|
+
timestamp = payload.get("started_at")
|
|
767
|
+
started_at_ms = _transcript_timestamp_ms(timestamp)
|
|
768
|
+
if started_at_ms is not None:
|
|
769
|
+
return started_at_ms
|
|
770
|
+
except (OSError, UnicodeError):
|
|
771
|
+
return None
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def transcript_turn_usage(path_value: Any, turn_id: Any) -> dict[str, Any] | None:
|
|
776
|
+
if not isinstance(path_value, str) or not path_value or not turn_id:
|
|
777
|
+
return None
|
|
778
|
+
path = Path(path_value)
|
|
779
|
+
if not path.is_file():
|
|
780
|
+
return None
|
|
781
|
+
|
|
782
|
+
target_turn = str(turn_id)
|
|
783
|
+
current_turn: str | None = None
|
|
784
|
+
latest_total: dict[str, Any] | None = None
|
|
785
|
+
before: dict[str, Any] | None = None
|
|
786
|
+
after: dict[str, Any] | None = None
|
|
787
|
+
target_seen = False
|
|
788
|
+
turn_step_usages: list[dict[str, Any]] = []
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
792
|
+
for line in stream:
|
|
793
|
+
try:
|
|
794
|
+
record = json.loads(line)
|
|
795
|
+
except json.JSONDecodeError:
|
|
796
|
+
continue
|
|
797
|
+
if not isinstance(record, dict) or record.get("type") != "event_msg":
|
|
798
|
+
continue
|
|
799
|
+
payload = record.get("payload")
|
|
800
|
+
if not isinstance(payload, dict):
|
|
801
|
+
continue
|
|
802
|
+
kind = payload.get("type")
|
|
803
|
+
event_turn = payload.get("turn_id")
|
|
804
|
+
if kind == "task_started":
|
|
805
|
+
current_turn = str(event_turn) if event_turn is not None else None
|
|
806
|
+
if current_turn == target_turn:
|
|
807
|
+
target_seen = True
|
|
808
|
+
before = dict(latest_total) if latest_total is not None else None
|
|
809
|
+
continue
|
|
810
|
+
if kind == "token_count":
|
|
811
|
+
info = payload.get("info")
|
|
812
|
+
if isinstance(info, dict):
|
|
813
|
+
usage = normalize_transcript_usage(
|
|
814
|
+
info.get("total_token_usage")
|
|
815
|
+
)
|
|
816
|
+
last_usage = normalize_transcript_usage(
|
|
817
|
+
info.get("last_token_usage")
|
|
818
|
+
)
|
|
819
|
+
else:
|
|
820
|
+
usage = None
|
|
821
|
+
last_usage = None
|
|
822
|
+
if usage is not None:
|
|
823
|
+
latest_total = usage
|
|
824
|
+
if current_turn == target_turn:
|
|
825
|
+
after = usage
|
|
826
|
+
if current_turn == target_turn and last_usage is not None:
|
|
827
|
+
turn_step_usages.append(last_usage)
|
|
828
|
+
continue
|
|
829
|
+
if kind in {"task_complete", "turn_aborted"}:
|
|
830
|
+
completed_turn = (
|
|
831
|
+
str(event_turn) if event_turn is not None else current_turn
|
|
832
|
+
)
|
|
833
|
+
if completed_turn == current_turn:
|
|
834
|
+
current_turn = None
|
|
835
|
+
except (OSError, UnicodeError):
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
if not target_seen:
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
# Priority 1: Aggregate step-level last_token_usage within the turn.
|
|
842
|
+
# This is 100% accurate and immune to context compactions or global resets.
|
|
843
|
+
if turn_step_usages:
|
|
844
|
+
aggregated: dict[str, Any] = {}
|
|
845
|
+
numeric_keys = (
|
|
846
|
+
"input_tokens",
|
|
847
|
+
"cached_input_tokens",
|
|
848
|
+
"net_new_input_tokens",
|
|
849
|
+
"output_tokens",
|
|
850
|
+
"reasoning_output_tokens",
|
|
851
|
+
"cache_write_input_tokens",
|
|
852
|
+
"total_tokens",
|
|
853
|
+
"estimated_credits_micros",
|
|
854
|
+
"estimated_usd_micros",
|
|
855
|
+
)
|
|
856
|
+
for key in numeric_keys:
|
|
857
|
+
val = sum(
|
|
858
|
+
step[key]
|
|
859
|
+
for step in turn_step_usages
|
|
860
|
+
if isinstance(step.get(key), (int, float))
|
|
861
|
+
)
|
|
862
|
+
if val > 0:
|
|
863
|
+
aggregated[key] = int(val)
|
|
864
|
+
if aggregated.get("total_tokens", 0) > 0:
|
|
865
|
+
aggregated["groups"] = []
|
|
866
|
+
aggregated["source"] = "transcript"
|
|
867
|
+
return aggregated
|
|
868
|
+
|
|
869
|
+
if after is None:
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
# Priority 2: Fallback to cumulative delta with compaction reset detection.
|
|
873
|
+
if before is None:
|
|
874
|
+
result = dict(after)
|
|
875
|
+
result["groups"] = []
|
|
876
|
+
else:
|
|
877
|
+
after_total = after.get("total_tokens") or 0
|
|
878
|
+
before_total = before.get("total_tokens") or 0
|
|
879
|
+
if after_total < before_total:
|
|
880
|
+
# Context window reset / compaction occurred; after is the turn usage
|
|
881
|
+
result = dict(after)
|
|
882
|
+
result["groups"] = []
|
|
883
|
+
else:
|
|
884
|
+
result = usage_delta(before, after)
|
|
885
|
+
if result is None:
|
|
886
|
+
return None
|
|
887
|
+
result["source"] = "transcript"
|
|
888
|
+
return result
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def merge_usage(
|
|
892
|
+
transcript_usage: dict[str, Any] | None,
|
|
893
|
+
service_usage: dict[str, Any] | None,
|
|
894
|
+
) -> dict[str, Any] | None:
|
|
895
|
+
if transcript_usage is None:
|
|
896
|
+
if service_usage is not None:
|
|
897
|
+
service_usage = dict(service_usage)
|
|
898
|
+
service_usage["source"] = "app-server"
|
|
899
|
+
return service_usage
|
|
900
|
+
result = dict(transcript_usage)
|
|
901
|
+
if service_usage is not None:
|
|
902
|
+
for key in ("estimated_credits_micros", "estimated_usd_micros"):
|
|
903
|
+
value = service_usage.get(key)
|
|
904
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
905
|
+
result[key] = int(value)
|
|
906
|
+
groups = service_usage.get("groups")
|
|
907
|
+
if isinstance(groups, list):
|
|
908
|
+
result["groups"] = groups
|
|
909
|
+
result["source"] = "transcript+app-server"
|
|
910
|
+
return result
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def usage_group_identities(usage: dict[str, Any] | None) -> list[tuple[str | None, str | None]]:
|
|
914
|
+
if not isinstance(usage, dict):
|
|
915
|
+
return []
|
|
916
|
+
groups = usage.get("groups")
|
|
917
|
+
if not isinstance(groups, list):
|
|
918
|
+
return []
|
|
919
|
+
identities: list[tuple[str | None, str | None]] = []
|
|
920
|
+
for group in groups:
|
|
921
|
+
if not isinstance(group, dict):
|
|
922
|
+
continue
|
|
923
|
+
model = text_value(group.get("model"))
|
|
924
|
+
effort = text_value(group.get("reasoning_effort"))
|
|
925
|
+
identity = (model, effort)
|
|
926
|
+
if identity != (None, None) and identity not in identities:
|
|
927
|
+
identities.append(identity)
|
|
928
|
+
return identities
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def apply_participant_metadata(
|
|
932
|
+
participant: dict[str, Any],
|
|
933
|
+
*,
|
|
934
|
+
event: dict[str, Any] | None = None,
|
|
935
|
+
transcript_path: Any = None,
|
|
936
|
+
turn_id: Any = None,
|
|
937
|
+
usage: dict[str, Any] | None = None,
|
|
938
|
+
) -> None:
|
|
939
|
+
if event is not None:
|
|
940
|
+
model = text_value(event.get("model"))
|
|
941
|
+
if model and not text_value(participant.get("model")):
|
|
942
|
+
participant["model"] = model
|
|
943
|
+
effort = event_reasoning_effort(event)
|
|
944
|
+
if effort and not text_value(participant.get("reasoning_effort")):
|
|
945
|
+
participant["reasoning_effort"] = effort
|
|
946
|
+
|
|
947
|
+
metadata = transcript_turn_metadata(transcript_path, turn_id)
|
|
948
|
+
if metadata is not None:
|
|
949
|
+
model = text_value(metadata.get("model"))
|
|
950
|
+
if model and not text_value(participant.get("model")):
|
|
951
|
+
participant["model"] = model
|
|
952
|
+
effort = text_value(metadata.get("reasoning_effort"))
|
|
953
|
+
if effort and not text_value(participant.get("reasoning_effort")):
|
|
954
|
+
participant["reasoning_effort"] = effort
|
|
955
|
+
|
|
956
|
+
identities = usage_group_identities(usage)
|
|
957
|
+
if not text_value(participant.get("model")):
|
|
958
|
+
models = {model for model, _ in identities if model}
|
|
959
|
+
if len(models) == 1:
|
|
960
|
+
participant["model"] = next(iter(models))
|
|
961
|
+
if not text_value(participant.get("reasoning_effort")):
|
|
962
|
+
efforts = {effort for _, effort in identities if effort}
|
|
963
|
+
if len(efforts) == 1:
|
|
964
|
+
participant["reasoning_effort"] = next(iter(efforts))
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def extract_transcript_insights(
|
|
968
|
+
transcript_path: Any, turn_id: Any = None
|
|
969
|
+
) -> dict[str, Any] | None:
|
|
970
|
+
"""Extract skills used, tools/MCP calls, trajectory steps, logs, and summary from a session transcript."""
|
|
971
|
+
if not isinstance(transcript_path, str) or not transcript_path:
|
|
972
|
+
return None
|
|
973
|
+
path = Path(transcript_path)
|
|
974
|
+
if not path.is_file():
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
skills: dict[str, int] = {}
|
|
978
|
+
tools: dict[str, dict[str, Any]] = {}
|
|
979
|
+
trajectory: list[dict[str, Any]] = []
|
|
980
|
+
logs: list[dict[str, Any]] = []
|
|
981
|
+
goal: str | None = None
|
|
982
|
+
conclusion: str | None = None
|
|
983
|
+
|
|
984
|
+
try:
|
|
985
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
986
|
+
for line in stream:
|
|
987
|
+
try:
|
|
988
|
+
record = json.loads(line)
|
|
989
|
+
except json.JSONDecodeError:
|
|
990
|
+
continue
|
|
991
|
+
if not isinstance(record, dict):
|
|
992
|
+
continue
|
|
993
|
+
|
|
994
|
+
ts = record.get("timestamp")
|
|
995
|
+
record_type = record.get("type")
|
|
996
|
+
payload = record.get("payload")
|
|
997
|
+
if not isinstance(payload, dict):
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
if record_type == "response_item":
|
|
1001
|
+
p_type = payload.get("type")
|
|
1002
|
+
if p_type == "custom_tool_call":
|
|
1003
|
+
tool_name = str(payload.get("name") or "unknown")
|
|
1004
|
+
raw_input = payload.get("input", "")
|
|
1005
|
+
call_id = payload.get("call_id")
|
|
1006
|
+
is_mcp = tool_name.startswith("mcp__") or "mcp" in tool_name.lower()
|
|
1007
|
+
|
|
1008
|
+
input_text = str(raw_input)
|
|
1009
|
+
for skill in re.findall(r"skills/([a-zA-Z0-9_\-]+)", input_text):
|
|
1010
|
+
skills[skill] = skills.get(skill, 0) + 1
|
|
1011
|
+
|
|
1012
|
+
if tool_name not in tools:
|
|
1013
|
+
tools[tool_name] = {
|
|
1014
|
+
"name": tool_name,
|
|
1015
|
+
"count": 0,
|
|
1016
|
+
"is_mcp": is_mcp,
|
|
1017
|
+
"category": "mcp" if is_mcp else "system",
|
|
1018
|
+
}
|
|
1019
|
+
tools[tool_name]["count"] += 1
|
|
1020
|
+
|
|
1021
|
+
inp_summary = ""
|
|
1022
|
+
if isinstance(raw_input, str):
|
|
1023
|
+
m = re.search(r"\"cmd\"\s*:\s*\"([^\"]+)\"", raw_input)
|
|
1024
|
+
if m:
|
|
1025
|
+
inp_summary = m.group(1).strip()
|
|
1026
|
+
else:
|
|
1027
|
+
inp_summary = " ".join(raw_input.split()).strip()
|
|
1028
|
+
elif isinstance(raw_input, dict):
|
|
1029
|
+
inp_summary = json.dumps(raw_input, ensure_ascii=False)
|
|
1030
|
+
if len(inp_summary) > 160:
|
|
1031
|
+
inp_summary = inp_summary[:157] + "..."
|
|
1032
|
+
|
|
1033
|
+
clean_title = (
|
|
1034
|
+
"MCP: " + tool_name.replace("mcp__", "")
|
|
1035
|
+
if is_mcp
|
|
1036
|
+
else "调用 " + tool_name
|
|
1037
|
+
)
|
|
1038
|
+
trajectory.append(
|
|
1039
|
+
{
|
|
1040
|
+
"type": "tool_call",
|
|
1041
|
+
"name": tool_name,
|
|
1042
|
+
"title": clean_title,
|
|
1043
|
+
"detail": inp_summary,
|
|
1044
|
+
"status": "completed",
|
|
1045
|
+
"is_mcp": is_mcp,
|
|
1046
|
+
"call_id": call_id,
|
|
1047
|
+
"timestamp": ts,
|
|
1048
|
+
}
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
logs.append(
|
|
1052
|
+
{
|
|
1053
|
+
"timestamp": ts,
|
|
1054
|
+
"level": "info",
|
|
1055
|
+
"type": "tool_call",
|
|
1056
|
+
"message": f"[{tool_name}] {inp_summary}",
|
|
1057
|
+
}
|
|
1058
|
+
)
|
|
1059
|
+
elif p_type == "message":
|
|
1060
|
+
role = payload.get("role")
|
|
1061
|
+
content = payload.get("content", [])
|
|
1062
|
+
text_parts = []
|
|
1063
|
+
for item in content:
|
|
1064
|
+
if isinstance(item, dict):
|
|
1065
|
+
text_parts.append(
|
|
1066
|
+
item.get("text") or item.get("output_text") or ""
|
|
1067
|
+
)
|
|
1068
|
+
msg_text = "".join(text_parts).strip()
|
|
1069
|
+
if role == "user" and msg_text:
|
|
1070
|
+
clean_prompt = msg_text
|
|
1071
|
+
if "<USER_REQUEST>" in clean_prompt:
|
|
1072
|
+
m = re.search(
|
|
1073
|
+
r"<USER_REQUEST>(.*?)</USER_REQUEST>",
|
|
1074
|
+
clean_prompt,
|
|
1075
|
+
re.DOTALL,
|
|
1076
|
+
)
|
|
1077
|
+
if m:
|
|
1078
|
+
clean_prompt = m.group(1).strip()
|
|
1079
|
+
if "## My request:" in clean_prompt:
|
|
1080
|
+
clean_prompt = clean_prompt.split("## My request:", 1)[1].strip()
|
|
1081
|
+
if not clean_prompt.startswith("<") or not goal:
|
|
1082
|
+
goal = clean_prompt
|
|
1083
|
+
elif role == "assistant" and msg_text:
|
|
1084
|
+
conclusion = msg_text
|
|
1085
|
+
elif (
|
|
1086
|
+
record_type == "event_msg"
|
|
1087
|
+
and payload.get("type") == "item_completed"
|
|
1088
|
+
):
|
|
1089
|
+
item = payload.get("item", {})
|
|
1090
|
+
if isinstance(item, dict):
|
|
1091
|
+
itype = item.get("type")
|
|
1092
|
+
if itype == "CommandExecution":
|
|
1093
|
+
exit_code = item.get("exit_code", 0)
|
|
1094
|
+
stdout = str(item.get("stdout") or "")[:200].strip()
|
|
1095
|
+
duration = item.get("duration")
|
|
1096
|
+
dur_ms = None
|
|
1097
|
+
if isinstance(duration, dict):
|
|
1098
|
+
dur_ms = int(
|
|
1099
|
+
(duration.get("secs") or 0) * 1000
|
|
1100
|
+
+ (duration.get("nanos") or 0) / 1_000_000
|
|
1101
|
+
)
|
|
1102
|
+
if trajectory and trajectory[-1]["type"] == "tool_call":
|
|
1103
|
+
trajectory[-1]["status"] = (
|
|
1104
|
+
"completed" if exit_code == 0 else "error"
|
|
1105
|
+
)
|
|
1106
|
+
if dur_ms is not None:
|
|
1107
|
+
trajectory[-1]["duration_ms"] = dur_ms
|
|
1108
|
+
if stdout:
|
|
1109
|
+
logs.append(
|
|
1110
|
+
{
|
|
1111
|
+
"timestamp": ts,
|
|
1112
|
+
"level": "info" if exit_code == 0 else "error",
|
|
1113
|
+
"type": "command_output",
|
|
1114
|
+
"message": f"Exit {exit_code}: {stdout[:120]}",
|
|
1115
|
+
}
|
|
1116
|
+
)
|
|
1117
|
+
except (OSError, UnicodeError):
|
|
1118
|
+
return None
|
|
1119
|
+
|
|
1120
|
+
skills_list = [{"name": name, "count": count} for name, count in skills.items()]
|
|
1121
|
+
tools_list = list(tools.values())
|
|
1122
|
+
|
|
1123
|
+
return {
|
|
1124
|
+
"skills_used": skills_list,
|
|
1125
|
+
"tools_used": tools_list,
|
|
1126
|
+
"trajectory": trajectory[-20:],
|
|
1127
|
+
"logs": logs[-30:],
|
|
1128
|
+
"summary_info": {
|
|
1129
|
+
"goal": goal[:300] if goal else None,
|
|
1130
|
+
"conclusion": conclusion[:500] if conclusion else None,
|
|
1131
|
+
},
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def enrich_run_metadata(run: dict[str, Any]) -> None:
|
|
1136
|
+
thread = merge_thread_metadata(
|
|
1137
|
+
session_index_metadata(run.get("session_id")), run.get("thread")
|
|
1138
|
+
)
|
|
1139
|
+
if thread is not None:
|
|
1140
|
+
run["thread"] = thread
|
|
1141
|
+
|
|
1142
|
+
parent = run.get("parent")
|
|
1143
|
+
if isinstance(parent, dict):
|
|
1144
|
+
apply_participant_metadata(
|
|
1145
|
+
parent,
|
|
1146
|
+
transcript_path=run.get("transcript_path"),
|
|
1147
|
+
turn_id=run.get("turn_id"),
|
|
1148
|
+
usage=parent.get("usage_delta"),
|
|
1149
|
+
)
|
|
1150
|
+
workers = run.get("workers")
|
|
1151
|
+
if isinstance(workers, dict):
|
|
1152
|
+
for worker in workers.values():
|
|
1153
|
+
if not isinstance(worker, dict):
|
|
1154
|
+
continue
|
|
1155
|
+
apply_participant_metadata(
|
|
1156
|
+
worker,
|
|
1157
|
+
transcript_path=worker.get("transcript_path"),
|
|
1158
|
+
turn_id=worker.get("turn_id") or run.get("turn_id"),
|
|
1159
|
+
usage=worker.get("usage"),
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# Extract skills, tools, trajectory, logs, summary if not already populated
|
|
1163
|
+
if "skills_used" not in run or "trajectory" not in run:
|
|
1164
|
+
insights = extract_transcript_insights(
|
|
1165
|
+
run.get("transcript_path"), run.get("turn_id")
|
|
1166
|
+
)
|
|
1167
|
+
if insights:
|
|
1168
|
+
for k, v in insights.items():
|
|
1169
|
+
if v is not None and (k not in run or not run[k]):
|
|
1170
|
+
run[k] = v
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def quota_delta(
|
|
1174
|
+
before: list[dict[str, Any]], after: list[dict[str, Any]]
|
|
1175
|
+
) -> list[dict[str, Any]]:
|
|
1176
|
+
by_duration = {
|
|
1177
|
+
item.get("window_duration_mins"): item
|
|
1178
|
+
for item in before
|
|
1179
|
+
if item.get("window_duration_mins") is not None
|
|
1180
|
+
}
|
|
1181
|
+
result = []
|
|
1182
|
+
for item in after:
|
|
1183
|
+
old = by_duration.get(item.get("window_duration_mins"))
|
|
1184
|
+
delta = None
|
|
1185
|
+
if (
|
|
1186
|
+
old
|
|
1187
|
+
and isinstance(old.get("used_percent"), (int, float))
|
|
1188
|
+
and isinstance(item.get("used_percent"), (int, float))
|
|
1189
|
+
):
|
|
1190
|
+
delta = item["used_percent"] - old["used_percent"]
|
|
1191
|
+
result.append({**item, "delta_percentage_points": delta})
|
|
1192
|
+
return result
|