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.
Files changed (113) hide show
  1. codex_flow/__init__.py +28 -0
  2. codex_flow/__main__.py +9 -0
  3. codex_flow/cli.py +242 -0
  4. codex_flow/data/LICENSE +21 -0
  5. codex_flow/data/README.en.md +303 -0
  6. codex_flow/data/README.md +305 -0
  7. codex_flow/data/VERSION +1 -0
  8. codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
  9. codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
  10. codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
  11. codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
  12. codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
  13. codex_flow/data/apps/macos-overlay/README.en.md +121 -0
  14. codex_flow/data/apps/macos-overlay/README.md +123 -0
  15. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
  16. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
  17. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
  18. codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
  19. codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
  20. codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
  21. codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
  22. codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
  23. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
  24. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
  25. codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
  26. codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
  27. codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
  28. codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
  29. codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
  30. codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
  31. codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
  32. codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
  33. codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
  34. codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
  35. codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
  36. codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
  37. codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
  38. codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
  39. codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
  40. codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
  41. codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
  42. codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
  43. codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
  44. codex_flow/data/apps/macos-overlay/build.sh +75 -0
  45. codex_flow/data/benchmark/corpus.json +103 -0
  46. codex_flow/data/benchmark/manifest.example.json +41 -0
  47. codex_flow/data/benchmark/manifest.schema.json +137 -0
  48. codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
  49. codex_flow/data/benchmark/profiles.json +90 -0
  50. codex_flow/data/benchmark/schema.json +77 -0
  51. codex_flow/data/benchmark/tasks.json +50 -0
  52. codex_flow/data/completions/codex-flow.bash +34 -0
  53. codex_flow/data/completions/codex-flow.zsh +52 -0
  54. codex_flow/data/glama.json +6 -0
  55. codex_flow/data/install-release.ps1 +126 -0
  56. codex_flow/data/install-release.sh +155 -0
  57. codex_flow/data/install.ps1 +349 -0
  58. codex_flow/data/install.sh +362 -0
  59. codex_flow/data/policy/benchmark.toml +49 -0
  60. codex_flow/data/policy/defaults.toml +70 -0
  61. codex_flow/data/scripts/analyze-benchmark.py +510 -0
  62. codex_flow/data/scripts/benchmark-local.py +171 -0
  63. codex_flow/data/scripts/check-recommendation.py +277 -0
  64. codex_flow/data/scripts/doctor.py +449 -0
  65. codex_flow/data/scripts/generate-release-manifest.py +74 -0
  66. codex_flow/data/scripts/localization.py +192 -0
  67. codex_flow/data/scripts/manage-hooks.py +448 -0
  68. codex_flow/data/scripts/manage-instructions.py +389 -0
  69. codex_flow/data/scripts/manage-shell.py +151 -0
  70. codex_flow/data/scripts/materialize-corpus.py +193 -0
  71. codex_flow/data/scripts/menu.py +646 -0
  72. codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
  73. codex_flow/data/scripts/package-release.py +132 -0
  74. codex_flow/data/scripts/render-benchmark-report.py +292 -0
  75. codex_flow/data/scripts/run-benchmark.py +829 -0
  76. codex_flow/data/scripts/strategies/__init__.py +28 -0
  77. codex_flow/data/scripts/strategies/balanced.py +115 -0
  78. codex_flow/data/scripts/strategies/base.py +363 -0
  79. codex_flow/data/scripts/strategies/efficient.py +158 -0
  80. codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
  81. codex_flow/data/scripts/strategies/quality.py +209 -0
  82. codex_flow/data/scripts/strategies/speed.py +108 -0
  83. codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
  84. codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
  85. codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
  86. codex_flow/data/scripts/strategy_runtime.py +1091 -0
  87. codex_flow/data/scripts/telemetry.py +400 -0
  88. codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
  89. codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
  90. codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
  91. codex_flow/data/scripts/telemetry_core/common.py +421 -0
  92. codex_flow/data/scripts/telemetry_core/latency.py +593 -0
  93. codex_flow/data/scripts/telemetry_core/query.py +427 -0
  94. codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
  95. codex_flow/data/scripts/telemetry_core/render.py +460 -0
  96. codex_flow/data/scripts/telemetry_core/repair.py +223 -0
  97. codex_flow/data/scripts/ui.py +266 -0
  98. codex_flow/data/scripts/update-homebrew-formula.py +146 -0
  99. codex_flow/data/scripts/update_runtime_config.py +134 -0
  100. codex_flow/data/scripts/updater.py +1718 -0
  101. codex_flow/data/smithery.yaml +18 -0
  102. codex_flow/data/templates/agents/worker-explorer.toml +24 -0
  103. codex_flow/data/templates/agents/worker-implementer.toml +49 -0
  104. codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
  105. codex_flow/data/templates/flow-pilot-instructions.md +35 -0
  106. codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
  107. codex_flow/mcp.py +35 -0
  108. codex_flow-2.1.13.dist-info/METADATA +342 -0
  109. codex_flow-2.1.13.dist-info/RECORD +113 -0
  110. codex_flow-2.1.13.dist-info/WHEEL +5 -0
  111. codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
  112. codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
  113. 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