codex-stats 1.9.0__tar.gz → 1.11.2__tar.gz

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 (28) hide show
  1. {codex_stats-1.9.0/src/codex_stats.egg-info → codex_stats-1.11.2}/PKG-INFO +11 -5
  2. {codex_stats-1.9.0 → codex_stats-1.11.2}/README.md +1 -0
  3. codex_stats-1.11.2/pyproject.toml +77 -0
  4. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/cli.py +48 -2
  5. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/display.py +344 -1
  6. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/ingest.py +68 -5
  7. codex_stats-1.11.2/src/codex_stats/leaderboard.py +365 -0
  8. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/metrics.py +183 -0
  9. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/models.py +181 -0
  10. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/sources.py +252 -14
  11. codex_stats-1.11.2/src/codex_stats/tools.py +335 -0
  12. {codex_stats-1.9.0 → codex_stats-1.11.2/src/codex_stats.egg-info}/PKG-INFO +11 -5
  13. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats.egg-info/SOURCES.txt +4 -0
  14. codex_stats-1.11.2/tests/test_behavior.py +759 -0
  15. {codex_stats-1.9.0 → codex_stats-1.11.2}/tests/test_cli.py +5 -1
  16. codex_stats-1.11.2/tests/test_leaderboard.py +454 -0
  17. {codex_stats-1.9.0 → codex_stats-1.11.2}/tests/test_metrics.py +1 -1
  18. codex_stats-1.9.0/pyproject.toml +0 -46
  19. {codex_stats-1.9.0 → codex_stats-1.11.2}/LICENSE +0 -0
  20. {codex_stats-1.9.0 → codex_stats-1.11.2}/setup.cfg +0 -0
  21. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/__init__.py +0 -0
  22. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/__main__.py +0 -0
  23. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats/config.py +0 -0
  24. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats.egg-info/dependency_links.txt +0 -0
  25. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats.egg-info/entry_points.txt +0 -0
  26. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats.egg-info/requires.txt +0 -0
  27. {codex_stats-1.9.0 → codex_stats-1.11.2}/src/codex_stats.egg-info/top_level.txt +0 -0
  28. {codex_stats-1.9.0 → codex_stats-1.11.2}/tests/test_config.py +0 -0
@@ -1,22 +1,27 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-stats
3
- Version: 1.9.0
4
- Summary: Local AI usage observability dashboard for coding agent sessions.
3
+ Version: 1.11.2
4
+ Summary: Local-first usage dashboard and leaderboard for AI coding agent sessions: tokens, cost and activity across Codex, OpenCode, Claude Code and Hermes, read straight from the databases those tools already keep on your machine.
5
5
  Author: Vivek
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/vivek378521/codex-stats
8
8
  Project-URL: Repository, https://github.com/vivek378521/codex-stats
9
9
  Project-URL: Issues, https://github.com/vivek378521/codex-stats/issues
10
- Keywords: codex,cli,analytics,sqlite,usage
10
+ Project-URL: Changelog, https://github.com/vivek378521/codex-stats/blob/main/CHANGELOG.md
11
+ Project-URL: Leaderboard, https://codex-stats-leaderboard.vercel.app
12
+ Project-URL: Funding, https://github.com/sponsors/vivek378521
13
+ Keywords: ai,analytics,claude,cli,codex,cost,dashboard,developer-tools,hermes,leaderboard,local-first,metrics,observability,opencode,privacy,reporting,sqlite,terminal,tokens,usage
11
14
  Classifier: Development Status :: 3 - Alpha
12
15
  Classifier: Environment :: Console
13
16
  Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
15
18
  Classifier: Programming Language :: Python :: 3
16
19
  Classifier: Programming Language :: Python :: 3.11
17
20
  Classifier: Programming Language :: Python :: 3.12
18
21
  Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Build Tools
19
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: System :: Monitoring
20
25
  Classifier: Topic :: Utilities
21
26
  Requires-Python: >=3.11
22
27
  Description-Content-Type: text/markdown
@@ -39,6 +44,7 @@ It reads local session data from every coding assistant installed on the machine
39
44
  - per-tool cost overrides and a stacked per-tool token trend on the Overview
40
45
  - anomaly-aware usage insights and recommendations
41
46
  - file-level impact tracking: a "Most Edited Files" panel showing per-file edit counts and add/delete line totals parsed from Codex and Claude Code rollouts
47
+ - **tool behavior tracking**: a "Tool Behavior" panel showing the tools each agent actually called — normalized into shared categories (Read, Edit, Execute, Search, Web, Subagent, Plan), with per-tool failure rates, repeated-call detection, read/write ratio, and abandoned-turn counts, across every tool with recorded calls
42
48
  - shareable JPG cards and browser PDF export from the dashboard
43
49
 
44
50
  ## Data Sources
@@ -12,6 +12,7 @@ It reads local session data from every coding assistant installed on the machine
12
12
  - per-tool cost overrides and a stacked per-tool token trend on the Overview
13
13
  - anomaly-aware usage insights and recommendations
14
14
  - file-level impact tracking: a "Most Edited Files" panel showing per-file edit counts and add/delete line totals parsed from Codex and Claude Code rollouts
15
+ - **tool behavior tracking**: a "Tool Behavior" panel showing the tools each agent actually called — normalized into shared categories (Read, Edit, Execute, Search, Web, Subagent, Plan), with per-tool failure rates, repeated-call detection, read/write ratio, and abandoned-turn counts, across every tool with recorded calls
15
16
  - shareable JPG cards and browser PDF export from the dashboard
16
17
 
17
18
  ## Data Sources
@@ -0,0 +1,77 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "codex-stats"
7
+ version = "1.11.2"
8
+ description = "Local-first usage dashboard and leaderboard for AI coding agent sessions: tokens, cost and activity across Codex, OpenCode, Claude Code and Hermes, read straight from the databases those tools already keep on your machine."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ # SPDX form (PEP 639). The License classifier is deliberately absent: setuptools
12
+ # rejects having both, and the old license = { text = ... } table is deprecated.
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ authors = [
16
+ { name = "Vivek" }
17
+ ]
18
+ keywords = [
19
+ "ai",
20
+ "analytics",
21
+ "claude",
22
+ "cli",
23
+ "codex",
24
+ "cost",
25
+ "dashboard",
26
+ "developer-tools",
27
+ "hermes",
28
+ "leaderboard",
29
+ "local-first",
30
+ "metrics",
31
+ "observability",
32
+ "opencode",
33
+ "privacy",
34
+ "reporting",
35
+ "sqlite",
36
+ "terminal",
37
+ "tokens",
38
+ "usage",
39
+ ]
40
+ classifiers = [
41
+ "Development Status :: 3 - Alpha",
42
+ "Environment :: Console",
43
+ "Intended Audience :: Developers",
44
+ "Operating System :: OS Independent",
45
+ "Programming Language :: Python :: 3",
46
+ "Programming Language :: Python :: 3.11",
47
+ "Programming Language :: Python :: 3.12",
48
+ "Programming Language :: Python :: 3.13",
49
+ "Topic :: Software Development :: Build Tools",
50
+ "Topic :: Software Development :: Libraries :: Python Modules",
51
+ "Topic :: System :: Monitoring",
52
+ "Topic :: Utilities",
53
+ ]
54
+ # "Typing :: Typed" is deliberately absent: it promises an inline py.typed
55
+ # marker, and the package does not ship one. Add both together if that changes.
56
+
57
+ [project.optional-dependencies]
58
+ dev = [
59
+ "build>=1.2.2",
60
+ ]
61
+
62
+ [project.scripts]
63
+ codex-stats = "codex_stats.cli:main"
64
+
65
+ [project.urls]
66
+ Homepage = "https://github.com/vivek378521/codex-stats"
67
+ Repository = "https://github.com/vivek378521/codex-stats"
68
+ Issues = "https://github.com/vivek378521/codex-stats/issues"
69
+ Changelog = "https://github.com/vivek378521/codex-stats/blob/main/CHANGELOG.md"
70
+ Leaderboard = "https://codex-stats-leaderboard.vercel.app"
71
+ Funding = "https://github.com/sponsors/vivek378521"
72
+
73
+ [tool.setuptools]
74
+ package-dir = { "" = "src" }
75
+
76
+ [tool.setuptools.packages.find]
77
+ where = ["src"]
@@ -8,10 +8,12 @@ from pathlib import Path
8
8
 
9
9
  from .config import Paths, PricingConfig, load_pricing_config
10
10
  from .display import format_dashboard_html
11
+ from .leaderboard import LeaderboardConfig, LeaderboardSubmitServer
11
12
  from .metrics import (
12
13
  local_date,
13
14
  summarize_activity_heatmap_from_details,
14
15
  summarize_badges,
16
+ summarize_behavior_from_details,
15
17
  summarize_compare_from_details,
16
18
  summarize_costs_from_details,
17
19
  summarize_daily_from_details,
@@ -49,13 +51,45 @@ def main(argv: list[str] | None = None) -> int:
49
51
  paths = Paths.discover()
50
52
 
51
53
  dashboard = _build_dashboard(paths)
52
- output_path = _write_dashboard_output(format_dashboard_html(dashboard))
54
+ submit_server = _start_leaderboard(dashboard)
55
+ output_path = _write_dashboard_output(
56
+ format_dashboard_html(dashboard, leaderboard=_leaderboard_preview(submit_server))
57
+ )
53
58
  print(f"Wrote dashboard to {output_path}")
54
59
  _open_report_in_browser(output_path)
55
60
  print(f"Opened dashboard in browser: {output_path}")
61
+ if submit_server is None:
62
+ return 0
63
+ print("Leaderboard enabled: use Submit to Leaderboard in the dashboard. Press Ctrl+C to stop.")
64
+ try:
65
+ submit_server.wait()
66
+ except KeyboardInterrupt:
67
+ print("\nLeaderboard submit endpoint stopped.")
68
+ finally:
69
+ submit_server.close()
56
70
  return 0
57
71
 
58
72
 
73
+ def _start_leaderboard(dashboard: DashboardData) -> LeaderboardSubmitServer | None:
74
+ """Bring up the loopback submit endpoint when the leaderboard is configured.
75
+
76
+ Binding here, before the dashboard is rendered, is what lets the generated
77
+ HTML carry a URL that is already live.
78
+ """
79
+ config = LeaderboardConfig.from_env()
80
+ if config is None:
81
+ return None
82
+ server = LeaderboardSubmitServer(config, dashboard)
83
+ server.start()
84
+ return server
85
+
86
+
87
+ def _leaderboard_preview(server: LeaderboardSubmitServer | None) -> dict[str, object] | None:
88
+ if server is None:
89
+ return None
90
+ return server.preview()
91
+
92
+
59
93
  def _build_dashboard(paths: Paths, now: datetime | None = None) -> DashboardData:
60
94
  current_time = now or datetime.now().astimezone()
61
95
  pricing = load_pricing_config(paths)
@@ -197,6 +231,7 @@ def _build_window(
197
231
  daily_points = summarize_daily_from_details(current_details, days=max(trend_days, 1), now=now, pricing=pricing)
198
232
  activity_heatmap = summarize_activity_heatmap_from_details(current_details, timezone=now.tzinfo)
199
233
  file_impact = summarize_files_from_details(current_details, limit=10)
234
+ behavior = summarize_behavior_from_details(current_details)
200
235
  return DashboardWindow(
201
236
  key=key,
202
237
  label=label,
@@ -211,7 +246,14 @@ def _build_window(
211
246
  costs=costs,
212
247
  insights=insights,
213
248
  activity_heatmap=activity_heatmap,
214
- takeaways=summarize_takeaways(summary=summary, comparison=comparison, costs=costs, insights=insights, file_impact=file_impact),
249
+ takeaways=summarize_takeaways(
250
+ summary=summary,
251
+ comparison=comparison,
252
+ costs=costs,
253
+ insights=insights,
254
+ file_impact=file_impact,
255
+ behavior=behavior,
256
+ ),
215
257
  badges=summarize_badges(summary=summary, daily_points=daily_points, activity_heatmap=activity_heatmap),
216
258
  expensive_session=summarize_expensive_session(current_details, pricing),
217
259
  work_rhythm=summarize_work_rhythm(daily_points, activity_heatmap),
@@ -223,6 +265,7 @@ def _build_window(
223
265
  limit=5,
224
266
  ),
225
267
  file_impact=file_impact,
268
+ behavior=behavior,
226
269
  tool_breakdown=(
227
270
  summarize_source_breakdown_from_details(current_details, pricing)
228
271
  if build_tool_breakdown
@@ -265,6 +308,7 @@ def _build_all_time_window(
265
308
  daily_points = summarize_daily_from_details(trend_details, days=trend_days, now=now, pricing=pricing)
266
309
  activity_heatmap = summarize_activity_heatmap_from_details(all_details, timezone=now.tzinfo)
267
310
  file_impact = summarize_files_from_details(all_details, limit=10)
311
+ behavior = summarize_behavior_from_details(all_details)
268
312
  return DashboardWindow(
269
313
  key="all",
270
314
  label="All Time",
@@ -285,6 +329,7 @@ def _build_all_time_window(
285
329
  costs=costs,
286
330
  insights=insights,
287
331
  file_impact=file_impact,
332
+ behavior=behavior,
288
333
  ),
289
334
  badges=summarize_badges(summary=summary, daily_points=daily_points, activity_heatmap=activity_heatmap),
290
335
  expensive_session=summarize_expensive_session(all_details, pricing),
@@ -297,6 +342,7 @@ def _build_all_time_window(
297
342
  limit=5,
298
343
  ),
299
344
  file_impact=file_impact,
345
+ behavior=behavior,
300
346
  tool_breakdown=(
301
347
  summarize_source_breakdown_from_details(all_details, pricing)
302
348
  if build_tool_breakdown
@@ -4,6 +4,7 @@ import json
4
4
  import textwrap
5
5
  from collections import defaultdict
6
6
  from html import escape
7
+ from typing import Any
7
8
 
8
9
  from .models import DashboardData, DashboardWindow, HeatmapCell, ProjectDrilldown
9
10
  from .sources import source_label
@@ -16,7 +17,7 @@ SOURCE_CHART_COLORS: dict[str, str] = {
16
17
  }
17
18
 
18
19
 
19
- def format_dashboard_html(dashboard: DashboardData) -> str:
20
+ def format_dashboard_html(dashboard: DashboardData, *, leaderboard: dict[str, Any] | None = None) -> str:
20
21
  generated_at = dashboard.generated_at.strftime("%Y-%m-%d %H:%M %Z")
21
22
  scopes = list(dashboard.scopes)
22
23
  windows = list(dashboard.windows)
@@ -66,6 +67,16 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
66
67
  },
67
68
  separators=(",", ":"),
68
69
  )
70
+ leaderboard_json = "null"
71
+ leaderboard_button = ""
72
+ leaderboard_dialog = ""
73
+ if leaderboard:
74
+ leaderboard_json = json.dumps(leaderboard, separators=(",", ":"))
75
+ leaderboard_button = (
76
+ '<button class="action-button" type="button" data-action="submit-leaderboard">'
77
+ "Submit to Leaderboard</button>"
78
+ )
79
+ leaderboard_dialog = _format_leaderboard_dialog()
69
80
  return f"""<!DOCTYPE html>
70
81
  <html lang="en">
71
82
  <head>
@@ -450,6 +461,13 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
450
461
  font-size: 0.92rem;
451
462
  line-height: 1.45;
452
463
  }}
464
+ .panel-hint {{
465
+ display: block;
466
+ margin: 16px 0 0;
467
+ color: var(--muted);
468
+ font-size: 0.86rem;
469
+ line-height: 1.5;
470
+ }}
453
471
  .split {{
454
472
  display: grid;
455
473
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -776,6 +794,83 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
776
794
  .window.is-active {{
777
795
  display: block !important;
778
796
  }}
797
+ dialog {{
798
+ display: none !important;
799
+ }}
800
+ }}
801
+ .leaderboard-dialog {{
802
+ width: min(460px, calc(100vw - 32px));
803
+ padding: 0;
804
+ border: 1px solid var(--line);
805
+ border-radius: 24px;
806
+ background: var(--panel-strong);
807
+ color: var(--ink);
808
+ box-shadow: 0 30px 80px rgba(75, 56, 40, 0.28);
809
+ }}
810
+ .leaderboard-dialog::backdrop {{
811
+ background: rgba(31, 26, 23, 0.42);
812
+ }}
813
+ .leaderboard-form {{
814
+ display: flex;
815
+ flex-direction: column;
816
+ gap: 10px;
817
+ padding: 26px 28px 22px;
818
+ }}
819
+ .leaderboard-form h2 {{
820
+ margin: 0;
821
+ font-size: 22px;
822
+ }}
823
+ .leaderboard-note {{
824
+ margin: 0 0 4px;
825
+ color: var(--muted);
826
+ font-size: 14px;
827
+ line-height: 1.5;
828
+ }}
829
+ .leaderboard-label {{
830
+ font-size: 12px;
831
+ letter-spacing: 0.1em;
832
+ text-transform: uppercase;
833
+ color: var(--muted);
834
+ font-weight: 700;
835
+ }}
836
+ .leaderboard-input {{
837
+ width: 100%;
838
+ padding: 12px 14px;
839
+ border: 1px solid var(--line);
840
+ border-radius: 14px;
841
+ background: #fff;
842
+ color: var(--ink);
843
+ font: inherit;
844
+ }}
845
+ .leaderboard-input:focus {{
846
+ outline: 2px solid var(--accent);
847
+ outline-offset: 1px;
848
+ }}
849
+ .leaderboard-preview {{
850
+ margin: 0;
851
+ padding: 12px 14px;
852
+ border-radius: 14px;
853
+ background: rgba(15, 118, 110, 0.08);
854
+ color: var(--ink);
855
+ font-size: 14px;
856
+ line-height: 1.5;
857
+ }}
858
+ .leaderboard-error {{
859
+ margin: 0;
860
+ color: #9f1239;
861
+ font-size: 14px;
862
+ }}
863
+ .leaderboard-status {{
864
+ margin: 0;
865
+ color: var(--accent);
866
+ font-size: 14px;
867
+ font-weight: 700;
868
+ }}
869
+ .leaderboard-actions {{
870
+ display: flex;
871
+ gap: 10px;
872
+ justify-content: flex-end;
873
+ margin-top: 6px;
779
874
  }}
780
875
  </style>
781
876
  </head>
@@ -801,6 +896,7 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
801
896
  <div class="actions">
802
897
  <button class="action-button" type="button" data-action="copy-summary">Copy Summary</button>
803
898
  <span class="copy-feedback" data-copy-feedback></span>
899
+ {leaderboard_button}
804
900
  <div class="export-wrap">
805
901
  <button class="action-button primary" type="button" data-action="toggle-export">Export</button>
806
902
  <div class="export-menu" data-export-menu>
@@ -842,6 +938,7 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
842
938
  {scope_sections}
843
939
  <div class="footer">Generated by codex-stats</div>
844
940
  </main>
941
+ {leaderboard_dialog}
845
942
  <script>
846
943
  const dashboardAssets = {assets_json};
847
944
  const dashboardSummaries = {summaries_json};
@@ -1201,6 +1298,103 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
1201
1298
  exportMenu?.classList.remove("is-open");
1202
1299
  }}
1203
1300
  }});
1301
+ const leaderboardConfig = {leaderboard_json};
1302
+ const leaderboardDialog = document.querySelector("[data-leaderboard-dialog]");
1303
+ if (leaderboardConfig && leaderboardDialog) {{
1304
+ const leaderboardInput = leaderboardDialog.querySelector("[data-leaderboard-username]");
1305
+ const leaderboardError = leaderboardDialog.querySelector("[data-leaderboard-error]");
1306
+ const leaderboardStatus = leaderboardDialog.querySelector("[data-leaderboard-status]");
1307
+ const leaderboardPreview = leaderboardDialog.querySelector("[data-leaderboard-preview]");
1308
+ const leaderboardConfirm = leaderboardDialog.querySelector("[data-leaderboard-confirm]");
1309
+ const leaderboardFormatter = new Intl.NumberFormat("en-US");
1310
+
1311
+ function formatLeaderboardCount(value) {{
1312
+ const count = Number(value) || 0;
1313
+ if (count >= 1000000000) {{
1314
+ return `${{(count / 1000000000).toFixed(2)}}B`;
1315
+ }}
1316
+ if (count >= 1000000) {{
1317
+ return `${{(count / 1000000).toFixed(2)}}M`;
1318
+ }}
1319
+ if (count >= 1000) {{
1320
+ return `${{(count / 1000).toFixed(1)}}K`;
1321
+ }}
1322
+ return leaderboardFormatter.format(count);
1323
+ }}
1324
+
1325
+ function setLeaderboardMessage(node, message) {{
1326
+ node.textContent = message;
1327
+ node.hidden = !message;
1328
+ }}
1329
+
1330
+ function renderLeaderboardPreview() {{
1331
+ const parts = (leaderboardConfig.clis ?? []).map(
1332
+ (item) => `${{item.label}} ${{formatLeaderboardCount(item.tokens)}}`
1333
+ );
1334
+ leaderboardPreview.textContent = parts.length
1335
+ ? `Sending ${{formatLeaderboardCount(leaderboardConfig.totalTokens)}} tokens across ${{parts.join(", ")}}`
1336
+ : "No CLI usage has been recorded on this machine yet.";
1337
+ }}
1338
+
1339
+ async function submitToLeaderboard() {{
1340
+ const username = leaderboardInput.value.trim();
1341
+ if (!/^[A-Za-z0-9_-]{{1,20}}$/.test(username)) {{
1342
+ setLeaderboardMessage(leaderboardError, "Use 1-20 characters: letters, numbers, _ or -.");
1343
+ return;
1344
+ }}
1345
+ setLeaderboardMessage(leaderboardError, "");
1346
+ setLeaderboardMessage(leaderboardStatus, "");
1347
+ leaderboardConfirm.disabled = true;
1348
+ leaderboardConfirm.textContent = "Submitting…";
1349
+ try {{
1350
+ // Only the username is sent. Totals are read from local data by codex-stats.
1351
+ // text/plain keeps this a CORS-simple request, so there is no preflight.
1352
+ const response = await fetch(leaderboardConfig.submitUrl, {{
1353
+ method: "POST",
1354
+ headers: {{ "Content-Type": "text/plain" }},
1355
+ body: JSON.stringify({{ username }}),
1356
+ }});
1357
+ const payload = await response.json();
1358
+ if (!response.ok || !payload.ok) {{
1359
+ throw new Error(payload.error || `Request failed with ${{response.status}}`);
1360
+ }}
1361
+ setLeaderboardMessage(
1362
+ leaderboardStatus,
1363
+ `${{payload.updated}} You are #${{payload.rank}} with ${{formatLeaderboardCount(payload.totalTokens)}} tokens.`
1364
+ );
1365
+ leaderboardInput.disabled = true;
1366
+ leaderboardConfirm.textContent = "Submitted";
1367
+ }} catch (error) {{
1368
+ setLeaderboardMessage(leaderboardError, error.message);
1369
+ leaderboardConfirm.disabled = false;
1370
+ leaderboardConfirm.textContent = "Submit";
1371
+ }}
1372
+ }}
1373
+
1374
+ document.querySelector('[data-action="submit-leaderboard"]')?.addEventListener("click", () => {{
1375
+ renderLeaderboardPreview();
1376
+ setLeaderboardMessage(leaderboardError, "");
1377
+ setLeaderboardMessage(leaderboardStatus, "");
1378
+ leaderboardDialog.showModal();
1379
+ leaderboardInput.focus();
1380
+ leaderboardInput.select();
1381
+ }});
1382
+ leaderboardDialog.querySelector("[data-leaderboard-cancel]")?.addEventListener("click", () => {{
1383
+ leaderboardDialog.close();
1384
+ }});
1385
+ leaderboardConfirm?.addEventListener("click", submitToLeaderboard);
1386
+ leaderboardInput?.addEventListener("keydown", (event) => {{
1387
+ if (event.key === "Enter") {{
1388
+ event.preventDefault();
1389
+ submitToLeaderboard();
1390
+ }}
1391
+ }});
1392
+ leaderboardDialog.addEventListener("close", () => {{
1393
+ leaderboardInput.disabled = false;
1394
+ leaderboardConfirm.disabled = false;
1395
+ leaderboardConfirm.textContent = "Submit";
1396
+ }});
1397
+ }}
1204
1398
  reflectActive();
1205
1399
  </script>
1206
1400
  </body>
@@ -1208,6 +1402,26 @@ def format_dashboard_html(dashboard: DashboardData) -> str:
1208
1402
  """
1209
1403
 
1210
1404
 
1405
+ def _format_leaderboard_dialog() -> str:
1406
+ return """ <dialog class="leaderboard-dialog" data-leaderboard-dialog>
1407
+ <form method="dialog" class="leaderboard-form">
1408
+ <h2>Submit to the leaderboard</h2>
1409
+ <p class="leaderboard-note">Pick a public name. Submitting again from this machine updates your existing record.</p>
1410
+ <label class="leaderboard-label" for="leaderboard-username">Username</label>
1411
+ <input class="leaderboard-input" id="leaderboard-username" name="username" type="text" maxlength="20"
1412
+ pattern="[A-Za-z0-9_-]{1,20}" autocomplete="off" spellcheck="false" placeholder="ada_lovelace"
1413
+ data-leaderboard-username>
1414
+ <p class="leaderboard-preview" data-leaderboard-preview></p>
1415
+ <p class="leaderboard-error" data-leaderboard-error hidden></p>
1416
+ <p class="leaderboard-status" data-leaderboard-status hidden></p>
1417
+ <div class="leaderboard-actions">
1418
+ <button class="action-button" type="button" data-leaderboard-cancel>Cancel</button>
1419
+ <button class="action-button primary" type="button" data-leaderboard-confirm>Submit</button>
1420
+ </div>
1421
+ </form>
1422
+ </dialog>"""
1423
+
1424
+
1211
1425
  def format_dashboard_svg_assets(window: DashboardWindow, *, scope_label: str = "Codex") -> dict[str, str]:
1212
1426
  title = f"{scope_label} Stats {window.label}"
1213
1427
  return {
@@ -1430,6 +1644,8 @@ def _format_dashboard_window_section(
1430
1644
 
1431
1645
  {_format_file_impact_panel(window)}
1432
1646
 
1647
+ {_format_behavior_panel(window)}
1648
+
1433
1649
  <section class="panel">
1434
1650
  <div class="section-header">
1435
1651
  <div>
@@ -1784,6 +2000,133 @@ def _format_file_impact_panel(window: DashboardWindow) -> str:
1784
2000
  """
1785
2001
 
1786
2002
 
2003
+ def _format_behavior_panel(window: DashboardWindow) -> str:
2004
+ behavior = window.behavior
2005
+ if behavior is None or not behavior.supported or not behavior.total_calls:
2006
+ return f"""
2007
+ <section class="panel">
2008
+ <div class="section-header">
2009
+ <div>
2010
+ <p class="section-kicker">What the Agent Did</p>
2011
+ <h2>Tool Behavior</h2>
2012
+ </div>
2013
+ </div>
2014
+ {_format_empty_showcase("No tool calls recorded for this view.", "This is either a genuinely quiet range, or a scope that does not report tool use. Tool calls are read from Codex rollouts, Claude Code transcripts, the OpenCode part table, and the Hermes message log; switch to All Time to see which applies.")}
2015
+ </section>
2016
+ """
2017
+
2018
+ category_svg = _svg_bar_chart(
2019
+ [(entry.label, float(entry.calls)) for entry in behavior.categories],
2020
+ bar_color="#0f766e",
2021
+ value_formatter=lambda value: f"{int(value):,}",
2022
+ empty_label="No tool categories recorded for this view.",
2023
+ )
2024
+ tool_svg = _svg_bar_chart(
2025
+ [(entry.name, float(entry.calls)) for entry in behavior.tools[:8]],
2026
+ bar_color="#7c3aed",
2027
+ value_formatter=lambda value: f"{int(value):,}",
2028
+ empty_label="No individual tools recorded for this view.",
2029
+ )
2030
+ rows = "".join(
2031
+ f"""
2032
+ <tr>
2033
+ <td>{escape(entry.name)}</td>
2034
+ <td>{escape(entry.category.title())}</td>
2035
+ <td>{entry.calls:,}</td>
2036
+ <td>{entry.errors:,}</td>
2037
+ <td>{entry.recovered_errors:,}</td>
2038
+ <td>{escape(_fmt_percent(entry.error_rate))}</td>
2039
+ </tr>
2040
+ """
2041
+ for entry in behavior.tools[:10]
2042
+ )
2043
+ return f"""
2044
+ <section class="panel">
2045
+ <div class="section-header">
2046
+ <div>
2047
+ <p class="section-kicker">What the Agent Did</p>
2048
+ <h2>Tool Behavior</h2>
2049
+ </div>
2050
+ <div class="spotlight-kpis">
2051
+ <div class="spotlight-kpi"><strong>{behavior.total_calls:,}</strong><span>Tool calls</span></div>
2052
+ <div class="spotlight-kpi"><strong>{escape(_fmt_percent(behavior.error_rate))}</strong><span>Failed</span></div>
2053
+ <div class="spotlight-kpi"><strong>{escape(_fmt_percent(behavior.repeat_rate))}</strong><span>Repeated</span></div>
2054
+ <div class="spotlight-kpi"><strong>{escape(_fmt_behavior_percent(behavior.repeat_time_rate))}</strong><span>Repeated time</span></div>
2055
+ <div class="spotlight-kpi"><strong>{_fmt_behavior_ratio(behavior.read_write_ratio)}</strong><span>Read per edit</span></div>
2056
+ </div>
2057
+ </div>
2058
+ <div class="chart-grid">
2059
+ <div class="chart-card">
2060
+ <h3>Calls by Category</h3>
2061
+ {category_svg}
2062
+ </div>
2063
+ <div class="chart-card">
2064
+ <h3>Most Used Tools</h3>
2065
+ {tool_svg}
2066
+ </div>
2067
+ <div class="chart-card">
2068
+ <h3>Failure Detail</h3>
2069
+ <div class="table-wrap">
2070
+ <table>
2071
+ <thead>
2072
+ <tr>
2073
+ <th>Tool</th>
2074
+ <th>Category</th>
2075
+ <th>Calls</th>
2076
+ <th>Failed</th>
2077
+ <th>Recovered</th>
2078
+ <th>Error rate</th>
2079
+ </tr>
2080
+ </thead>
2081
+ <tbody>{rows}</tbody>
2082
+ </table>
2083
+ </div>
2084
+ </div>
2085
+ </div>
2086
+ <p class="panel-hint">{escape(_format_behavior_note(behavior))}</p>
2087
+ </section>
2088
+ """
2089
+
2090
+
2091
+ def _format_behavior_note(behavior) -> str:
2092
+ sessions_noun = "session" if behavior.sessions_with_calls == 1 else "sessions"
2093
+ note = (
2094
+ "Categories are normalized across tools, so exec_command, Bash and bash all count "
2095
+ f"as Execute. Repeated calls are counted within a single session, across "
2096
+ f"{behavior.sessions_with_calls:,} {sessions_noun} with tool use."
2097
+ )
2098
+ if behavior.abandoned_turns:
2099
+ turn_noun = "turn was" if behavior.abandoned_turns == 1 else "turns were"
2100
+ note += f" {behavior.abandoned_turns} {turn_noun} abandoned before finishing."
2101
+ else:
2102
+ note += " Abandoned turns are only recorded where the source reports them (Codex)."
2103
+ if behavior.total_duration_ms:
2104
+ timed_noun = "call" if behavior.timed_calls == 1 else "calls"
2105
+ note += (
2106
+ f" Repeated time is the share of tool-call time spent on repeat work, measured over the "
2107
+ f"{behavior.timed_calls:,} timed {timed_noun} (OpenCode and Claude record durations; Codex does not)."
2108
+ )
2109
+ else:
2110
+ note += " This scope does not record tool-call durations, so repeated time is unavailable."
2111
+ note += " A recovered failure is one that was later retried to success in the same session; "
2112
+ note += "recovery is an exact command match, so a retry that changes the command is not counted."
2113
+ return note
2114
+
2115
+
2116
+ def _fmt_behavior_percent(value: float | None) -> str:
2117
+ if value is None:
2118
+ return "n/a"
2119
+ return f"{value * 100:.0f}%"
2120
+
2121
+
2122
+ def _fmt_behavior_ratio(value: float | None) -> str:
2123
+ if value is None:
2124
+ return "n/a"
2125
+ if value >= 10:
2126
+ return f"{value:.0f}"
2127
+ return f"{value:.1f}"
2128
+
2129
+
1787
2130
  def _format_empty_showcase(title: str, detail: str) -> str:
1788
2131
  return f"""
1789
2132
  <div class="empty-showcase">