From 3edca5d2da196135d888fed65a8669db91b4c590 Mon Sep 17 00:00:00 2001 From: JM Date: Thu, 10 Sep 2026 11:45:02 -0700 Subject: [PATCH 1/4] Add Grok Build as a local runtime adapter Discover ~/.grok/sessions, read usage.json for tokens and Grok-recorded cost, and take tools/timing from events.jsonl. Sessions stay listed when usage is missing. Chat history, generated titles, and prompts are never projected. Grok sessions are read-only in Token Meter. --- .gitignore | 14 + README.md | 4 +- page.html | 6 +- specs/ARCHITECTURE.md | 13 +- specs/USER_GUIDE.md | 33 +- .../--repo--/session-1/chat_history.jsonl | 2 + .../sessions/--repo--/session-1/events.jsonl | 5 + .../sessions/--repo--/session-1/signals.json | 8 + .../sessions/--repo--/session-1/summary.json | 9 + .../sessions/--repo--/session-1/usage.json | 30 + .../test_runtime_dispatch_characterization.py | 27 +- tests/runtimes/test_grok_adapter.py | 139 +++ tests/test_meter.py | 15 +- token_meter/app.py | 54 +- token_meter/runtimes/grok.py | 980 ++++++++++++++++++ 15 files changed, 1320 insertions(+), 19 deletions(-) create mode 100644 tests/fixtures/grok/sessions/--repo--/session-1/chat_history.jsonl create mode 100644 tests/fixtures/grok/sessions/--repo--/session-1/events.jsonl create mode 100644 tests/fixtures/grok/sessions/--repo--/session-1/signals.json create mode 100644 tests/fixtures/grok/sessions/--repo--/session-1/summary.json create mode 100644 tests/fixtures/grok/sessions/--repo--/session-1/usage.json create mode 100644 tests/runtimes/test_grok_adapter.py create mode 100644 token_meter/runtimes/grok.py diff --git a/.gitignore b/.gitignore index f3a009c..5c27c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,20 @@ DerivedData/ .idea/ .vscode/ +# Secrets and personal machine state +*.pem +*.p12 +*.pfx +id_rsa +id_rsa.pub +id_ed25519 +id_ed25519.pub +auth.json +.credentials.json +chrome-profile/ +/.token-meter/ +/.grok/ + # Local agent planning state, not public user docs PLAN.md /plan.md diff --git a/README.md b/README.md index 73c52e3..5dc8508 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Token Meter is a local-first observability dashboard for AI coding agents. It -turns session evidence from Claude, Codex, Cursor, OpenCode, Kiro, and Pi into one +turns session evidence from Claude, Codex, Cursor, OpenCode, Kiro, Pi, Hermes, and Grok into one view of what happened, what it cost, and where time went—so you can decide whether to continue, intervene, compare, or investigate a run. @@ -64,7 +64,7 @@ troubleshooting, see the [User guide](specs/USER_GUIDE.md). ## Coverage **Runtimes:** Claude Code and Desktop Agent/Cowork, Codex CLI and desktop, -Cursor Agent/Composer, OpenCode, Kiro, and Pi. +Cursor Agent/Composer, OpenCode, Kiro, Pi, Hermes, and Grok Build. | Platform | Status | Experience | | --- | --- | --- | diff --git a/page.html b/page.html index 38ad81e..4a8ed41 100644 --- a/page.html +++ b/page.html @@ -4336,9 +4336,9 @@

Assign model to this session

return value>0?`+${value}%`:value<0?`\u2212${Math.abs(value)}%`:'0%'; } // spend-range-logic-start -const SPEND_RUNTIME_COLORS={claude:'#f26722',codex:'#04a4b0',cursor:'#a974f7',opencode:'#fa5762',kiro:'#868ec2',unknown:'#889099'}; -const SPEND_RUNTIME_ORDER=['claude','codex','cursor','opencode','kiro','unknown']; -const SPEND_RUNTIME_LABELS={claude:'Claude',codex:'Codex',cursor:'Cursor',opencode:'OpenCode',kiro:'Kiro',unknown:'Unknown'}; +const SPEND_RUNTIME_COLORS={claude:'#f26722',codex:'#04a4b0',cursor:'#a974f7',opencode:'#fa5762',kiro:'#868ec2',grok:'#00bceb',unknown:'#889099'}; +const SPEND_RUNTIME_ORDER=['claude','codex','cursor','opencode','kiro','grok','unknown']; +const SPEND_RUNTIME_LABELS={claude:'Claude',codex:'Codex',cursor:'Cursor',opencode:'OpenCode',kiro:'Kiro',grok:'Grok',unknown:'Unknown'}; function spendDayDate(day){ const match=/^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day||'')); if(!match)return null; diff --git a/specs/ARCHITECTURE.md b/specs/ARCHITECTURE.md index 367afa9..69ff67c 100644 --- a/specs/ARCHITECTURE.md +++ b/specs/ARCHITECTURE.md @@ -60,7 +60,7 @@ executable and import-compatibility facade; current composition lives in Four identities are deliberately independent: - A **runtime** produced local evidence: Claude Code, Claude Desktop, Codex, - Cursor, OpenCode, Kiro, or Pi. + Cursor, OpenCode, Kiro, Pi, Hermes, or Grok. - A **model provider** owns a model and its public pricing, such as Anthropic or OpenAI. - An **account provider** may expose quota information through the user's @@ -109,6 +109,17 @@ establish a context window size, time to first token, semantic token split, or cache-savings price, so those projections remain unavailable rather than being derived or reported as zero. +The Grok adapter reads only Grok-owned session directories under `GROK_HOME` +(default `~/.grok/sessions`). A directory is accepted only when it is owned by +that home, is not a symlink, has a `summary.json`, and uses a bounded session +id. Token and cache counts come from `usage.json` when present; cost is the +Grok-recorded local estimate, not a Token Meter price-table lookup. Structural +tool names, outcomes, and turn timing come from `events.jsonl`. Sessions +without `usage.json` remain listed with unavailable tokens and cost. The +adapter uses a generic session title and never opens `chat_history.jsonl`, +`updates.jsonl`, generated titles, or session summaries. Grok sessions are +read-only in Token Meter. + ## Domain and Model Flow `token_meter/domain/usage.py` and `token_meter/models/` resolve token counts, diff --git a/specs/USER_GUIDE.md b/specs/USER_GUIDE.md index af54409..784fc8a 100644 --- a/specs/USER_GUIDE.md +++ b/specs/USER_GUIDE.md @@ -274,9 +274,9 @@ require `sudo` or security-control changes. ## Data and Evidence Token Meter reads local runtime stores: JSONL traces for Claude, Codex, Cursor, -Kiro, and Pi; read-only SQLite enrichment for Cursor, OpenCode, -and Hermes Agent; and -runtime-owned metadata needed to join a visible session to its trace. +Kiro, and Pi; Grok Build session directories; read-only SQLite enrichment for +Cursor, OpenCode, and Hermes Agent; and runtime-owned metadata needed to join a +visible session to its trace. Discovery and parsing are runtime adapters. Operating-system paths are platform services. Optional enrichment falls back to the authoritative base trace when @@ -311,6 +311,21 @@ not establish them. A provider resource identifier, such as an application-profile reference, is replaced with a safe generic model label; Token Meter does not infer or price a foundation model from it. +### Grok Build sessions + +Grok discovery reads only Grok-owned session directories. Its default root is +`~/.grok`; set `GROK_HOME` when Grok stores its local files elsewhere. A +session directory must contain `summary.json` and use a bounded session id +before Token Meter will show it. The adapter does not import cloud transcripts, +scan arbitrary directories, or project message, reasoning, generated-title, or +tool-payload content. It never opens `chat_history.jsonl` or `updates.jsonl`. + +Grok sessions use a generic content-free title. Recorded input, output, cache, +and cost can be shown when `usage.json` is present. Tool names and turn timing +come from `events.jsonl`. A session without persisted usage stays listed with +unavailable tokens and cost rather than a misleading zero. Token Meter does not +delete Grok sessions. + ### Costs and estimates Token Meter uses effective-dated provider/model price periods. Reinstalling @@ -325,7 +340,8 @@ hidden model work may be unavailable. Pi cost is the local estimate persisted in its session record, never a Token Meter price-table lookup. Token Meter does not display Pi application-profile identifiers, and leaves Pi context pressure, semantic token classification, and cache savings unavailable when the trace -does not record that evidence. +does not record that evidence. Grok cost is the local estimate Grok persisted +in `usage.json`, never a Token Meter price-table lookup. Token Meter reports recorded evidence, not a pre-flight prediction. ## Privacy @@ -377,6 +393,15 @@ Meter ignores files without a Pi session header, malformed files, symlinks, and cloud-only conversations. It does not need an API key or a provider account to read local Pi evidence. +### Grok sessions do not appear + +Run a normal Grok Build session, then confirm that its local session directory +is under `~/.grok/sessions` or the directory named by `GROK_HOME`. Token Meter +ignores directories without `summary.json`, malformed files, symlinks, and +cloud-only conversations. Sessions without `usage.json` still appear; tokens +and cost stay unavailable until Grok persists usage. It does not need an API +key or a provider account to read local Grok evidence. + ### Source changes do not appear Run `./scripts/install` from the intended checkout. Do not patch only the diff --git a/tests/fixtures/grok/sessions/--repo--/session-1/chat_history.jsonl b/tests/fixtures/grok/sessions/--repo--/session-1/chat_history.jsonl new file mode 100644 index 0000000..5366f03 --- /dev/null +++ b/tests/fixtures/grok/sessions/--repo--/session-1/chat_history.jsonl @@ -0,0 +1,2 @@ +{"role":"user","content":"private grok prompt"} +{"role":"assistant","content":"private grok response"} diff --git a/tests/fixtures/grok/sessions/--repo--/session-1/events.jsonl b/tests/fixtures/grok/sessions/--repo--/session-1/events.jsonl new file mode 100644 index 0000000..da85ee6 --- /dev/null +++ b/tests/fixtures/grok/sessions/--repo--/session-1/events.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-09-04T10:00:00Z","type":"turn_started","turn_number":1,"model_id":"grok-4.6"} +{"ts":"2026-09-04T10:00:00.4Z","type":"first_token"} +{"ts":"2026-09-04T10:00:01Z","type":"tool_started","tool_name":"read_file"} +{"ts":"2026-09-04T10:00:02Z","type":"tool_completed","tool_name":"read_file","duration_ms":12,"outcome":"success"} +{"ts":"2026-09-04T10:01:00Z","type":"turn_ended","outcome":"completed"} diff --git a/tests/fixtures/grok/sessions/--repo--/session-1/signals.json b/tests/fixtures/grok/sessions/--repo--/session-1/signals.json new file mode 100644 index 0000000..a6cb88a --- /dev/null +++ b/tests/fixtures/grok/sessions/--repo--/session-1/signals.json @@ -0,0 +1,8 @@ +{ + "turnCount": 1, + "toolCallCount": 1, + "toolsUsed": ["read_file"], + "primaryModelId": "grok-4.6", + "contextTokensUsed": 115, + "contextWindowTokens": 200000 +} diff --git a/tests/fixtures/grok/sessions/--repo--/session-1/summary.json b/tests/fixtures/grok/sessions/--repo--/session-1/summary.json new file mode 100644 index 0000000..c50e37f --- /dev/null +++ b/tests/fixtures/grok/sessions/--repo--/session-1/summary.json @@ -0,0 +1,9 @@ +{ + "info": {"id": "session-1", "cwd": "/repo"}, + "created_at": "2026-09-04T10:00:00Z", + "updated_at": "2026-09-04T10:01:00Z", + "current_model_id": "grok-4.6", + "generated_title": "private grok title from prompt", + "session_summary": "private grok summary of the user task", + "num_messages": 2 +} diff --git a/tests/fixtures/grok/sessions/--repo--/session-1/usage.json b/tests/fixtures/grok/sessions/--repo--/session-1/usage.json new file mode 100644 index 0000000..054a3e0 --- /dev/null +++ b/tests/fixtures/grok/sessions/--repo--/session-1/usage.json @@ -0,0 +1,30 @@ +{ + "sessionId": "session-1", + "session": { + "inputTokens": 100, + "outputTokens": 20, + "cachedReadTokens": 10, + "cacheCreationTokens": 5, + "reasoningTokens": 4, + "totalTokens": 135, + "modelCalls": 1, + "costUsdTicks": 1500000000, + "turnCount": 1, + "primaryModelId": "grok-4.6" + }, + "turns": [ + { + "turnNumber": 1, + "endedAt": "2026-09-04T10:01:00Z", + "inputTokens": 100, + "outputTokens": 20, + "cachedReadTokens": 10, + "cacheCreationTokens": 5, + "reasoningTokens": 4, + "totalTokens": 135, + "modelCalls": 1, + "costUsdTicks": 1500000000, + "primaryModelId": "grok-4.6" + } + ] +} diff --git a/tests/integration/test_runtime_dispatch_characterization.py b/tests/integration/test_runtime_dispatch_characterization.py index 742d7e2..62b0250 100644 --- a/tests/integration/test_runtime_dispatch_characterization.py +++ b/tests/integration/test_runtime_dispatch_characterization.py @@ -10,7 +10,7 @@ class LegacyRuntimeDispatchCharacterizationTests(unittest.TestCase): def test_runtime_registry_has_the_current_runtimes_in_discovery_order(self): self.assertEqual( meter.runtime_registry().runtime_ids, - ("claude", "codex", "cursor", "opencode", "kiro", "pi", "hermes"), + ("claude", "codex", "cursor", "opencode", "kiro", "pi", "hermes", "grok"), ) self.assertNotIsInstance(meter.runtime_registry().get("claude"), LegacyRuntimeAdapter) self.assertNotIsInstance(meter.runtime_registry().get("codex"), LegacyRuntimeAdapter) @@ -19,6 +19,7 @@ def test_runtime_registry_has_the_current_runtimes_in_discovery_order(self): self.assertNotIsInstance(meter.runtime_registry().get("kiro"), LegacyRuntimeAdapter) self.assertNotIsInstance(meter.runtime_registry().get("pi"), LegacyRuntimeAdapter) self.assertNotIsInstance(meter.runtime_registry().get("hermes"), LegacyRuntimeAdapter) + self.assertNotIsInstance(meter.runtime_registry().get("grok"), LegacyRuntimeAdapter) def test_claude_routes_through_the_native_adapter(self): source = {"provider": "claude", "id": "claude-session"} @@ -97,6 +98,17 @@ def test_hermes_routes_through_the_native_adapter(self): adapter.load.assert_called_once() + def test_grok_routes_through_the_native_adapter(self): + source = {"provider": "grok", "id": "grok-session"} + expected = {"provider": "grok", "marker": object()} + adapter = mock.Mock() + adapter.load.return_value = expected + + with mock.patch.object(meter, "_grok_native_adapter", return_value=adapter): + self.assertIs(meter.recompute(source), expected) + + adapter.load.assert_called_once() + def test_string_source_is_resolved_before_runtime_dispatch(self): source = {"provider": "codex", "id": "session-1"} expected = {"provider": "codex"} @@ -118,7 +130,7 @@ def test_missing_and_unknown_sources_return_none(self): def test_discovery_routes_each_runtime_once_in_registry_order(self): rows = { runtime_id: {"provider": runtime_id, "id": runtime_id + "-session"} - for runtime_id in ("claude", "codex", "cursor", "opencode", "kiro", "pi", "hermes") + for runtime_id in ("claude", "codex", "cursor", "opencode", "kiro", "pi", "hermes", "grok") } opencode_adapter = mock.Mock() opencode_adapter.discover_legacy.return_value = (rows["opencode"],) @@ -134,6 +146,8 @@ def test_discovery_routes_each_runtime_once_in_registry_order(self): pi_adapter.discover_legacy.return_value = (rows["pi"],) hermes_adapter = mock.Mock() hermes_adapter.discover_legacy.return_value = (rows["hermes"],) + grok_adapter = mock.Mock() + grok_adapter.discover_legacy.return_value = (rows["grok"],) with mock.patch.object( meter, "_claude_native_adapter", return_value=claude_adapter ), mock.patch.object( @@ -152,12 +166,14 @@ def test_discovery_routes_each_runtime_once_in_registry_order(self): meter, "_pi_native_adapter", return_value=pi_adapter ), mock.patch.object( meter, "_hermes_native_adapter", return_value=hermes_adapter + ), mock.patch.object( + meter, "_grok_native_adapter", return_value=grok_adapter ): discovered = meter.all_session_sources() self.assertEqual(discovered, [ rows["claude"], rows["codex"], rows["cursor"], rows["opencode"], rows["kiro"], - rows["pi"], rows["hermes"], + rows["pi"], rows["hermes"], rows["grok"], ]) claude_adapter.discover_legacy.assert_called_once() codex_adapter.discover_legacy.assert_called_once() @@ -166,6 +182,7 @@ def test_discovery_routes_each_runtime_once_in_registry_order(self): kiro_adapter.discover_legacy.assert_called_once() pi_adapter.discover_legacy.assert_called_once() hermes_adapter.discover_legacy.assert_called_once() + grok_adapter.discover_legacy.assert_called_once() def test_one_discovery_failure_returns_other_runtimes_and_bounded_status(self): codex_source = {"provider": "codex", "id": "codex-session"} @@ -183,6 +200,8 @@ def test_one_discovery_failure_returns_other_runtimes_and_bounded_status(self): pi_adapter.discover_legacy.return_value = () hermes_adapter = mock.Mock() hermes_adapter.discover_legacy.return_value = () + grok_adapter = mock.Mock() + grok_adapter.discover_legacy.return_value = () with mock.patch.object( meter, "_claude_native_adapter", return_value=claude_adapter ), mock.patch.object( @@ -197,6 +216,8 @@ def test_one_discovery_failure_returns_other_runtimes_and_bounded_status(self): meter, "_pi_native_adapter", return_value=pi_adapter ), mock.patch.object( meter, "_hermes_native_adapter", return_value=hermes_adapter + ), mock.patch.object( + meter, "_grok_native_adapter", return_value=grok_adapter ): discovered = meter.all_session_sources() diff --git a/tests/runtimes/test_grok_adapter.py b/tests/runtimes/test_grok_adapter.py new file mode 100644 index 0000000..74f05d1 --- /dev/null +++ b/tests/runtimes/test_grok_adapter.py @@ -0,0 +1,139 @@ +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import meter +from token_meter.contracts import ( + DetailLevel, + DiscoveryContext, + EvidenceBasis, + session_source_public_dict, +) +from token_meter.runtimes.grok import GrokRuntimeAdapter, COST_TICKS_PER_USD + + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "grok" +ROOT = Path(__file__).resolve().parents[2] + + +class GrokRuntimeAdapterTests(unittest.TestCase): + def _home(self, tmp): + dest = Path(tmp) / ".grok" + shutil.copytree(FIXTURES, dest) + return dest + + def test_normalizes_measured_tokens_and_grok_recorded_cost_without_payloads(self): + with tempfile.TemporaryDirectory() as tmp: + adapter = GrokRuntimeAdapter(self._home(tmp)) + source = adapter.discover(DiscoveryContext(home=tmp))[0] + loaded = adapter.load(source, DetailLevel.FULL) + + self.assertEqual(source.runtime_id, "grok") + self.assertEqual(source.session_id, "session-1") + self.assertEqual(source.display_label, "Grok") + self.assertEqual(source.model_ref.provider_id, "xai") + self.assertEqual(source.model_ref.model_id, "grok-4.6") + self.assertEqual(loaded.usage.input_tokens.value, 100) + self.assertEqual(loaded.usage.output_tokens.value, 20) + self.assertEqual(loaded.usage.cache_read_tokens.value, 10) + self.assertEqual(loaded.usage.cache_write_tokens.value, 5) + self.assertAlmostEqual(loaded.usage.cost_usd.value, 1500000000 / COST_TICKS_PER_USD) + self.assertEqual(loaded.usage.cost_usd.basis, EvidenceBasis.ESTIMATED) + self.assertEqual([(tool.name, tool.status) for tool in loaded.tools], [("read_file", "success")]) + encoded = json.dumps(session_source_public_dict(source)) + repr(loaded) + self.assertNotIn("private grok prompt", encoded) + self.assertNotIn("private grok response", encoded) + self.assertNotIn("private grok title from prompt", encoded) + self.assertNotIn("private grok summary of the user task", encoded) + self.assertNotIn("locator", session_source_public_dict(source)) + + def test_session_totals_stay_measured_when_extra_event_turns_have_no_usage(self): + with tempfile.TemporaryDirectory() as tmp: + home = self._home(tmp) + events = home / "sessions" / "--repo--" / "session-1" / "events.jsonl" + events.write_text( + events.read_text(encoding="utf-8") + + json.dumps({ + "ts": "2026-09-04T10:02:00Z", "type": "turn_started", + "turn_number": 2, "model_id": "grok-4.6", + }) + "\n" + + json.dumps({"ts": "2026-09-04T10:02:01Z", "type": "turn_ended"}) + "\n", + encoding="utf-8", + ) + adapter = GrokRuntimeAdapter(home) + source = adapter.discover(DiscoveryContext(home=tmp))[0] + loaded = adapter.load(source, DetailLevel.SUMMARY) + + self.assertEqual(loaded.usage.input_tokens.value, 100) + self.assertEqual(loaded.usage.output_tokens.value, 20) + self.assertEqual(loaded.usage.input_tokens.basis, EvidenceBasis.MEASURED) + self.assertAlmostEqual(loaded.usage.cost_usd.value, 1500000000 / COST_TICKS_PER_USD) + self.assertEqual(loaded.usage.cost_usd.basis, EvidenceBasis.ESTIMATED) + + def test_missing_usage_keeps_the_session_with_unavailable_tokens(self): + with tempfile.TemporaryDirectory() as tmp: + home = self._home(tmp) + (home / "sessions" / "--repo--" / "session-1" / "usage.json").unlink() + adapter = GrokRuntimeAdapter(home) + source = adapter.discover(DiscoveryContext(home=tmp))[0] + loaded = adapter.load(source, DetailLevel.SUMMARY) + + self.assertEqual(source.session_id, "session-1") + self.assertEqual(loaded.usage.input_tokens.basis, EvidenceBasis.UNAVAILABLE) + self.assertEqual(loaded.usage.cost_usd.basis, EvidenceBasis.UNAVAILABLE) + self.assertEqual([tool.name for tool in loaded.tools], ["read_file"]) + + def test_ignores_sessions_outside_the_owned_grok_home(self): + with tempfile.TemporaryDirectory() as tmp: + adapter = GrokRuntimeAdapter(Path(tmp) / "empty-home") + self.assertEqual(adapter.discover(DiscoveryContext(home=tmp)), ()) + + def test_ignores_short_session_ids_and_never_opens_chat_history(self): + with tempfile.TemporaryDirectory() as tmp: + home = self._home(tmp) + short = home / "sessions" / "--repo--" / "short" + short.mkdir() + (short / "summary.json").write_text( + json.dumps({"info": {"id": "short", "cwd": "/repo"}}), + encoding="utf-8", + ) + adapter = GrokRuntimeAdapter(home) + discovered = adapter.discover(DiscoveryContext(home=tmp)) + loaded = adapter.load(discovered[0], DetailLevel.SUMMARY) + + self.assertEqual([source.session_id for source in discovered], ["session-1"]) + self.assertEqual(loaded.source.session_id, "session-1") + + def test_denies_deletion_and_server_delete_before_trash(self): + with tempfile.TemporaryDirectory() as tmp: + adapter = GrokRuntimeAdapter(self._home(tmp)) + source = adapter.discover(DiscoveryContext(home=tmp))[0] + plan = adapter.deletion_plan(source) + + self.assertEqual(plan.disposition.value, "deny") + self.assertIn("grok", meter.session_action_capability()["read_only_providers"]) + with mock.patch.object(meter, "find_session", return_value={ + "provider": "grok", "id": "session-1", + }), mock.patch.object(meter, "trash_session_log") as trash: + result = meter.request_session_delete("session-1") + self.assertFalse(result["ok"]) + self.assertEqual(result["error_code"], "read_only_provider") + self.assertIn("Grok", result["error"]) + trash.assert_not_called() + + +class GrokDocumentationTests(unittest.TestCase): + def test_docs_explain_grok_evidence_and_privacy_boundaries(self): + readme = (ROOT / "README.md").read_text() + guide = (ROOT / "specs" / "USER_GUIDE.md").read_text() + architecture = (ROOT / "specs" / "ARCHITECTURE.md").read_text() + + self.assertIn("Grok Build", readme) + self.assertIn("GROK_HOME", guide) + self.assertIn("Grok Build sessions", guide) + self.assertIn("chat_history.jsonl", guide) + self.assertIn("Grok adapter", architecture) + self.assertIn("usage.json", architecture) \ No newline at end of file diff --git a/tests/test_meter.py b/tests/test_meter.py index 2f0ba99..794d954 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -135,6 +135,11 @@ def test_codex_index_rename_refreshes_cached_session_name_without_trace_change(s mock.patch.object(meter, "CLAUDE_PROJECTS", str(root / "no-claude")), \ mock.patch.object(meter, "CURSOR_PROJECTS", str(root / "no-cursor")), \ mock.patch.object(meter, "OPENCODE_DB", str(root / "no-opencode.db")), \ + mock.patch.object(meter, "KIRO_SESSIONS", str(root / "no-kiro")), \ + mock.patch.object(meter, "KIRO_AGENT_STORAGE", str(root / "no-kiro-agent")), \ + mock.patch.object(meter, "PI_AGENT_DIR", str(root / "no-pi-agent")), \ + mock.patch.object(meter, "HERMES_STATE_DB", str(root / "no-hermes.db")), \ + mock.patch.object(meter, "GROK_HOME", str(root / "no-grok")), \ mock.patch.object(meter, "CLAUDE_DESKTOP_DATA_ROOTS", []), \ mock.patch.object(meter, "claude_desktop_index", return_value={}), \ mock.patch.object(meter, "_summary_cache", empty_summary_cache): @@ -277,6 +282,7 @@ def test_cursor_discovery_uses_sqlite_metadata_and_keeps_activity_order_session_ mock.patch.object(meter, "KIRO_AGENT_STORAGE", str(root / "no-kiro-agent")), \ mock.patch.object(meter, "PI_AGENT_DIR", str(root / "no-pi-agent")), \ mock.patch.object(meter, "HERMES_STATE_DB", str(root / "no-hermes.db")), \ + mock.patch.object(meter, "GROK_HOME", str(root / "no-grok")), \ mock.patch.object(meter, "CLAUDE_DESKTOP_DATA_ROOTS", []), \ mock.patch.object(meter, "claude_desktop_index", return_value={}): sources = meter.all_session_sources() @@ -6305,7 +6311,7 @@ def test_spend_average_reference_uses_every_calendar_day(self): def test_spend_uses_exact_calendar_ranges_and_stacked_runtime_bars(self): for marker in ( "// spend-range-logic-start", - "const SPEND_RUNTIME_COLORS={claude:'#f26722',codex:'#04a4b0',cursor:'#a974f7',opencode:'#fa5762',kiro:'#868ec2',unknown:'#889099'};", + "const SPEND_RUNTIME_COLORS={claude:'#f26722',codex:'#04a4b0',cursor:'#a974f7',opencode:'#fa5762',kiro:'#868ec2',grok:'#00bceb',unknown:'#889099'};", "function spendRangeWindow(range,from='',to='',now=new Date())", "function normalizeSpendRangeChoice(value)", "function spendCalendarRows(days,window)", @@ -12005,7 +12011,7 @@ def test_settings_derive_total_from_allocations_and_preserve_other_machine_setti self.assertEqual(stored["budgets"]["monthly_total"], 80) self.assertEqual( stored["budgets"]["allocations"], - {"claude": 50, "codex": 30, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0}, + {"claude": 50, "codex": 30, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0, "grok": 0}, ) self.assertIn("model_pricing", stored) @@ -12029,13 +12035,13 @@ def test_missing_runtime_budgets_default_to_zero_and_explicit_values_are_preserv self.assertEqual(loaded["monthly_total"], 0) self.assertEqual( loaded["allocations"], - {"claude": 0, "codex": 0, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0}, + {"claude": 0, "codex": 0, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0, "grok": 0}, ) self.assertTrue(saved["ok"]) self.assertEqual(saved["budgets"]["monthly_total"], 1490) self.assertEqual( saved["budgets"]["allocations"], - {"claude": 0, "codex": 1490, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0}, + {"claude": 0, "codex": 1490, "cursor": 0, "opencode": 0, "kiro": 0, "pi": 0, "hermes": 0, "grok": 0}, ) def test_monthly_rollup_keeps_runtime_costs_and_partial_coverage(self): @@ -12519,6 +12525,7 @@ def test_discovery_joins_all_session_sources(self): mock.patch.object(meter, "KIRO_AGENT_STORAGE", str(root / "no-kiro-agent")), \ mock.patch.object(meter, "PI_AGENT_DIR", str(root / "no-pi-agent")), \ mock.patch.object(meter, "HERMES_STATE_DB", str(root / "no-hermes.db")), \ + mock.patch.object(meter, "GROK_HOME", str(root / "no-grok")), \ mock.patch.object(meter, "CLAUDE_DESKTOP_DATA_ROOTS", []), \ mock.patch.object(meter, "claude_desktop_index", return_value={}): sources = meter.all_session_sources() diff --git a/token_meter/app.py b/token_meter/app.py index 1e159f4..e4953e4 100644 --- a/token_meter/app.py +++ b/token_meter/app.py @@ -186,6 +186,10 @@ HermesRuntimeAdapter, HermesRuntimeAdapterProxy, ) +from token_meter.runtimes.grok import ( + GrokRuntimeAdapter, + GrokRuntimeAdapterProxy, +) from token_meter.runtimes.path_cache import BoundedPathCache from token_meter.runtimes.registry import RuntimeRegistry from token_meter.mcp.service import MCPQueryService @@ -247,6 +251,11 @@ PI_AGENT_DIR = os.path.abspath(os.path.expanduser( os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent") )) +GROK_HOME = os.path.abspath(os.path.expanduser( + os.environ.get("GROK_HOME", "~/.grok") +)) + + def hermes_state_db_path(environ=None): environ = os.environ if environ is None else environ state_db = str(environ.get("HERMES_STATE_DB") or "").strip() @@ -295,7 +304,7 @@ def hermes_state_db_path(environ=None): re.IGNORECASE, ) HERMES_MODEL_IDENTITY_LABEL = "Bedrock application profile" -BUDGET_PROVIDERS = ("claude", "codex", "cursor", "opencode", "kiro", "pi", "hermes") +BUDGET_PROVIDERS = ("claude", "codex", "cursor", "opencode", "kiro", "pi", "hermes", "grok") DEFAULT_RUNTIME_BUDGET = 0.0 DEFAULT_BUDGET_THRESHOLDS = (80, 90, 100) DEFAULT_SESSION_BUDGET = 10.0 @@ -2847,6 +2856,44 @@ def recompute_hermes(source): return _hermes_native_adapter().recompute_legacy(source) +_grok_native_adapters = {} + + +def _grok_compatibility(): + return _pi_compatibility() + + +def _grok_adapter_for(grok_home=None): + path = os.path.abspath(os.path.expanduser(grok_home or GROK_HOME)) + adapter = _grok_native_adapters.get(path) + if adapter is None: + adapter = GrokRuntimeAdapter( + path, + project_resolver=home_shorten, + compatibility=_grok_compatibility(), + ) + _grok_native_adapters[path] = adapter + if len(_grok_native_adapters) > 8: + oldest = next(iter(_grok_native_adapters)) + if oldest != path: + _grok_native_adapters.pop(oldest, None) + return adapter + + +def _grok_native_adapter(): + return _grok_adapter_for() + + +def grok_session_sources(grok_home=None): + return list(_grok_adapter_for(grok_home).discover_legacy( + DiscoveryContext(home=os.path.expanduser("~")) + )) + + +def recompute_grok(source): + return _grok_native_adapter().recompute_legacy(source) + + def opencode_db_path(): path = os.path.expanduser(OPENCODE_DB) return path if os.path.isabs(path) else os.path.join(OPENCODE_DATA_ROOT, path) @@ -4877,6 +4924,7 @@ def runtime_registry(): KiroRuntimeAdapterProxy(lambda: _kiro_native_adapter()), PiRuntimeAdapterProxy(lambda: _pi_native_adapter()), HermesRuntimeAdapterProxy(lambda: _hermes_native_adapter()), + GrokRuntimeAdapterProxy(lambda: _grok_native_adapter()), )) return _RUNTIME_REGISTRY @@ -5976,7 +6024,7 @@ def session_action_capability(): "token": _ACTION_TOKEN, "recoverable": True, "destination": trash_plan.destination_label, - "read_only_providers": ["opencode", "hermes"], + "read_only_providers": ["opencode", "hermes", "grok"], } @@ -7478,6 +7526,8 @@ def _source_inventory_roots(): KIRO_AGENT_STORAGE, PI_AGENT_DIR, os.path.join(PI_AGENT_DIR, "sessions"), + GROK_HOME, + os.path.join(GROK_HOME, "sessions"), ) diff --git a/token_meter/runtimes/grok.py b/token_meter/runtimes/grok.py new file mode 100644 index 0000000..b60bb30 --- /dev/null +++ b/token_meter/runtimes/grok.py @@ -0,0 +1,980 @@ +"""Native read-only adapter for Grok Build local session evidence.""" + +import json +import math +import os +import re +import time +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from urllib.parse import unquote + +from token_meter.contracts import ( + DeletionPlan, + DetailLevel, + EvidenceBasis, + EvidenceValue, + ModelRef, + NormalizedSession, + ParseWarning, + RuntimeDescriptor, + SessionSource, + SourceLocator, + SourceRevision, + TimingEvidence, + ToolEvent, + UsageEvidence, +) +from token_meter.domain.timing import merge_execution_intervals, performance_summary + + +MAX_JSON_BYTES = 8 * 1024 * 1024 +MAX_EVENT_BYTES = 32 * 1024 * 1024 +MAX_EVENT_ROWS = 20_000 +MAX_SOURCES = 2_000 +MAX_TURNS = 2_000 +MAX_TOOLS = 2_000 +COST_TICKS_PER_USD = 1_000_000_000.0 +SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$") + + +def _file_signature(path): + try: + stat = os.stat(path) + return str(stat.st_mtime_ns), str(stat.st_size) + except OSError: + return "0", "0" + + +def _mtime(path): + try: + return os.path.getmtime(path) + except OSError: + return 0.0 + + +def _timestamp(value): + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, (int, float)): + value = float(value) + return value / 1000.0 if value > 10_000_000_000 else value + if not isinstance(value, str): + return 0.0 + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def _integer(value): + if isinstance(value, bool) or value is None: + return None + try: + number = int(value) + except (TypeError, ValueError, OverflowError): + return None + return number if number >= 0 else None + + +def _number(value): + if isinstance(value, bool) or value is None: + return None + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return None + return number if math.isfinite(number) and number >= 0 else None + + +def _read_json(path, limit=MAX_JSON_BYTES): + try: + if os.path.getsize(path) > limit: + return None, True + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + return None, False + return (value, False) if isinstance(value, dict) else (None, False) + + +_EVENT_TYPES = frozenset(( + "turn_started", "turn_ended", "first_token", + "tool_started", "tool_completed", +)) + + +def _read_events(path): + rows = [] + corrupt = 0 + truncated = False + try: + if os.path.getsize(path) > MAX_EVENT_BYTES: + return (), 0, True + with open(path, encoding="utf-8") as handle: + for line in handle: + if not line.strip(): + continue + if not any(kind in line for kind in _EVENT_TYPES): + continue + try: + row = json.loads(line) + except (TypeError, ValueError, json.JSONDecodeError): + corrupt += 1 + continue + if not isinstance(row, dict): + corrupt += 1 + continue + kind = str(row.get("type") or "") + if kind not in _EVENT_TYPES: + continue + if len(rows) >= MAX_EVENT_ROWS: + truncated = True + break + rows.append({ + "type": kind, + "ts": _timestamp(row.get("ts")), + "turn_number": _integer(row.get("turn_number")), + "model_id": str(row.get("model_id") or ""), + "tool_name": str(row.get("tool_name") or ""), + "outcome": str(row.get("outcome") or ""), + "duration_ms": _number(row.get("duration_ms")), + }) + except OSError: + return (), 0, False + return tuple(rows), corrupt, truncated + + +def _session_totals(usage): + session = usage.get("session") if isinstance(usage, dict) else None + if not isinstance(session, dict): + return None + input_tokens = _integer(session.get("inputTokens")) + output_tokens = _integer(session.get("outputTokens")) + cache_read = _integer(session.get("cachedReadTokens")) + cache_write = _integer(session.get("cacheCreationTokens")) + cost = _cost_from_ticks(session.get("costUsdTicks")) + token_available = input_tokens is not None and output_tokens is not None + cache_available = cache_read is not None and cache_write is not None + if not token_available and cost is None: + return None + return { + "input_tokens": input_tokens or 0, + "output_tokens": output_tokens or 0, + "cache_read_tokens": cache_read or 0, + "cache_write_tokens": cache_write or 0, + "cost": cost, + "token_available": token_available, + "cache_available": cache_available, + "cost_available": cost is not None, + } + + +def _normalize_model(value): + value = str(value or "").strip().lower() + return value or "unknown-model" + + +def model_ref_for(model): + model = _normalize_model(model) + if model.startswith("grok-"): + return ModelRef("xai", model) + return ModelRef("unknown-model-provider", model) + + +def _normalize_tool_name(value): + value = str(value or "tool").strip() + value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value) + value = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_").lower() + return value or "tool" + + +def _tool_category(name): + name = str(name or "").lower() + if any(part in name for part in ("command", "shell", "bash", "terminal", "exec")): + return "shell" + if any(part in name for part in ("read", "write", "file", "directory", "edit", "search_replace")): + return "filesystem" + if any(part in name for part in ("grep", "search", "find", "glob")): + return "search" + if any(part in name for part in ("browser", "web", "url")): + return "browser" + if any(part in name for part in ("fetch", "retrieve", "lookup")): + return "retrieval" + return "other" + + +def _cost_from_ticks(ticks): + ticks = _integer(ticks) + if ticks is None: + return None + return ticks / COST_TICKS_PER_USD + + +def _decode_cwd(encoded, group_dir): + cwd_file = os.path.join(group_dir, ".cwd") + if os.path.isfile(cwd_file) and not os.path.islink(cwd_file): + try: + with open(cwd_file, encoding="utf-8") as handle: + value = handle.readline().strip() + if value.startswith("/"): + return value + except OSError: + pass + decoded = unquote(str(encoded or ""), errors="replace") + return decoded if decoded.startswith("/") else "" + + +class GrokRuntimeAdapter: + """Discover Grok Build session directories and expose no message content.""" + + descriptor = RuntimeDescriptor( + "grok", + "Grok", + frozenset(("sessions", "models", "tools")), + "runtime.generic", + "runtime-neutral", + None, + ) + + def __init__(self, grok_home, project_resolver=None, compatibility=None): + self.grok_home = Path(os.path.abspath(os.path.expanduser(str(grok_home)))) + self.project_resolver = project_resolver or (lambda value: value) + self.compatibility = dict(compatibility or {}) + self._metadata_cache = {} + + def _owned_path(self, path): + path = os.path.realpath(os.path.abspath(os.path.expanduser(str(path or "")))) + root = os.path.realpath(str(self.grok_home)) + try: + return os.path.commonpath((path, root)) == root + except ValueError: + return False + + def _session_dirs(self): + root = self.grok_home / "sessions" + if not root.is_dir(): + return () + found = [] + try: + groups = sorted(root.iterdir(), key=lambda path: path.name) + except OSError: + return () + for group in groups: + if not group.is_dir() or group.is_symlink() or not self._owned_path(group): + continue + try: + children = sorted(group.iterdir(), key=lambda path: path.name) + except OSError: + continue + for child in children: + if len(found) >= MAX_SOURCES: + return tuple(found) + if not child.is_dir() or child.is_symlink(): + continue + if not SESSION_ID_RE.fullmatch(child.name): + continue + summary = child / "summary.json" + if summary.is_file() and not summary.is_symlink() and self._owned_path(summary): + found.append(child) + return tuple(found) + + def _metadata(self, session_dir): + summary_path = str(session_dir / "summary.json") + usage_path = str(session_dir / "usage.json") + signals_path = str(session_dir / "signals.json") + events_path = str(session_dir / "events.jsonl") + signature = ( + *_file_signature(summary_path), + *_file_signature(usage_path), + *_file_signature(signals_path), + *_file_signature(events_path), + ) + cached = self._metadata_cache.get(summary_path) + if cached and cached[0] == signature: + return dict(cached[1]) if cached[1] else None + summary, _ = _read_json(summary_path) + info = summary.get("info") if isinstance(summary, dict) else None + session_id = "" + cwd = "" + if isinstance(info, dict): + session_id = str(info.get("id") or "").strip() + cwd = str(info.get("cwd") or "").strip() + session_id = session_id or session_dir.name + if not SESSION_ID_RE.fullmatch(session_id): + result = None + else: + if not cwd: + cwd = _decode_cwd(session_dir.parent.name, str(session_dir.parent)) + model = "" + if isinstance(summary, dict): + model = str(summary.get("current_model_id") or "") + usage, _ = _read_json(usage_path) + if not model and isinstance(usage, dict): + session = usage.get("session") if isinstance(usage.get("session"), dict) else {} + model = str(session.get("primaryModelId") or "") + model_ref = model_ref_for(model) + mtime = max( + _mtime(summary_path), _mtime(usage_path), + _mtime(signals_path), _mtime(events_path), + ) + result = { + "provider": "grok", "client": "grok", "label": "Grok", "runtime": "Grok", + "id": session_id, "session": session_id, + "path": summary_path, + "project": self.project_resolver(cwd) or "", + "mtime": mtime, "signature_mtime": mtime, "title": "Grok session", + "model": model_ref.model_id, "model_provider": model_ref.provider_id, + "source_kind": "grok_session", + } + self._metadata_cache[summary_path] = (signature, result) + if len(self._metadata_cache) > MAX_SOURCES: + self._metadata_cache.pop(next(iter(self._metadata_cache)), None) + return dict(result) if result else None + + def _legacy_records(self): + records = [self._metadata(path) for path in self._session_dirs()] + records = [record for record in records if record is not None] + return tuple(sorted( + records, + key=lambda record: (-float(record.get("mtime") or 0), record["id"], record["path"]), + )) + + def discover_legacy(self, context): + del context + return self._legacy_records() + + def discover(self, context): + del context + result = [] + for record in self._legacy_records(): + model = model_ref_for(record.get("model")) + result.append(SessionSource( + runtime_id=self.descriptor.runtime_id, + client_id="grok", + session_id=record["id"], + display_label="Grok", + project=record.get("project") or None, + locator=SourceLocator("file", record["path"]), + activity_mtime=record["mtime"], + revision=self._revision(record["path"]), + model_ref=model, + account_provider_id=None, + )) + return tuple(result) + + def _revision(self, summary_path): + session_dir = os.path.dirname(summary_path) + return SourceRevision(( + "grok-session", + *_file_signature(summary_path), + *_file_signature(os.path.join(session_dir, "usage.json")), + *_file_signature(os.path.join(session_dir, "events.jsonl")), + )) + + def current_revision(self, source): + path = source.locator.value if isinstance(source, SessionSource) else source.get("path", "") + return self._revision(path) + + def _parsed(self, summary_path): + if not self._owned_path(summary_path): + return {"turns": (), "corrupt": 0, "truncated": False} + session_dir = os.path.dirname(summary_path) + usage, usage_truncated = _read_json(os.path.join(session_dir, "usage.json")) + events, corrupt, events_truncated = _read_events(os.path.join(session_dir, "events.jsonl")) + signals, _ = _read_json(os.path.join(session_dir, "signals.json")) + summary, _ = _read_json(summary_path) + model = "" + if isinstance(summary, dict): + model = str(summary.get("current_model_id") or "") + session_usage = usage.get("session") if isinstance(usage, dict) else None + if not model and isinstance(session_usage, dict): + model = str(session_usage.get("primaryModelId") or "") + if not model and isinstance(signals, dict): + model = str(signals.get("primaryModelId") or "") + model = model_ref_for(model).model_id + event_turns = self._event_turns(events, model) + usage_turns = [] + if isinstance(usage, dict): + for row in usage.get("turns") or (): + if not isinstance(row, dict) or len(usage_turns) >= MAX_TURNS: + continue + usage_turns.append(self._usage_turn(row, model, len(usage_turns) + 1)) + if not usage_turns and isinstance(session_usage, dict): + usage_turns.append(self._usage_turn(session_usage, model, 1)) + turns = self._merge_turns(usage_turns, event_turns, model) + return { + "turns": tuple(turns[:MAX_TURNS]), + "session_totals": _session_totals(usage), + "corrupt": corrupt, + "truncated": bool(usage_truncated or events_truncated), + } + + def _usage_turn(self, row, default_model, index): + start = _timestamp(row.get("startedAt") or row.get("started_at")) + end = _timestamp(row.get("endedAt") or row.get("ended_at")) + input_tokens = _integer(row.get("inputTokens")) + output_tokens = _integer(row.get("outputTokens")) + cache_read = _integer(row.get("cachedReadTokens")) + cache_write = _integer(row.get("cacheCreationTokens")) + reasoning = _integer(row.get("reasoningTokens")) or 0 + cost = _cost_from_ticks(row.get("costUsdTicks")) + token_available = input_tokens is not None and output_tokens is not None + cache_available = cache_read is not None and cache_write is not None + context_tokens = 0 + if token_available and cache_available: + context_tokens = input_tokens + cache_read + cache_write + return { + "index": _integer(row.get("turnNumber")) or index, + "start": start, "end": end or start, + "model": model_ref_for(row.get("primaryModelId") or default_model).model_id, + "input_tokens": input_tokens or 0, + "output_tokens": output_tokens or 0, + "reasoning_tokens": min(reasoning, output_tokens or reasoning), + "cache_read_tokens": cache_read or 0, + "cache_write_tokens": cache_write or 0, + "token_available": token_available, + "cache_available": cache_available, + "context_available": token_available and cache_available, + "context_tokens": context_tokens, + "cost": cost, + "ttft_s": None, + "tools": [], + } + + def _event_turns(self, events, default_model): + turns = [] + current = None + pending_first = None + for event in events: + kind = event["type"] + ts = event["ts"] + if kind == "turn_started" or (current is None and kind != "turn_ended"): + if kind == "turn_started" or current is None: + if kind == "turn_started": + current = { + "index": event["turn_number"] or (len(turns) + 1), + "start": ts, "end": ts, + "model": model_ref_for(event["model_id"] or default_model).model_id, + "tools": [], "ttft_s": None, + } + pending_first = ts + turns.append(current) + if current is None: + continue + if kind == "first_token" and pending_first and ts >= pending_first and current["ttft_s"] is None: + current["ttft_s"] = max(0.0, ts - pending_first) + if kind == "tool_completed" and event["tool_name"] and len(current["tools"]) < MAX_TOOLS: + current["tools"].append({ + "id": "{}:{}".format(current["index"], len(current["tools"]) + 1), + "name": _normalize_tool_name(event["tool_name"]), + "category": _tool_category(event["tool_name"]), + "result_available": True, + "error": event["outcome"] in {"error", "failed", "failure"}, + }) + if ts: + current["end"] = max(current["end"] or ts, ts) + if kind == "turn_ended": + pending_first = None + current = None + return turns + + def _merge_turns(self, usage_turns, event_turns, default_model): + if not usage_turns and not event_turns: + return [{ + "index": 1, "start": 0.0, "end": 0.0, "model": default_model, + "input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0, + "cache_read_tokens": 0, "cache_write_tokens": 0, + "token_available": False, "cache_available": False, + "context_available": False, "context_tokens": 0, "cost": None, + "ttft_s": None, "tools": [], + }] + if not usage_turns: + merged = [] + for event in event_turns: + merged.append({ + "index": event["index"], "start": event["start"], "end": event["end"], + "model": event["model"] or default_model, + "input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0, + "cache_read_tokens": 0, "cache_write_tokens": 0, + "token_available": False, "cache_available": False, + "context_available": False, "context_tokens": 0, "cost": None, + "ttft_s": event.get("ttft_s"), "tools": event.get("tools") or [], + }) + return merged + by_index = {turn["index"]: turn for turn in event_turns} + merged = [] + for usage in usage_turns: + event = by_index.get(usage["index"]) + if event: + if not usage["start"]: + usage["start"] = event["start"] + if not usage["end"] or usage["end"] < usage["start"]: + usage["end"] = event["end"] or usage["start"] + usage["ttft_s"] = event.get("ttft_s") + usage["tools"] = event.get("tools") or [] + if event.get("model"): + usage["model"] = event["model"] + merged.append(usage) + used = {turn["index"] for turn in usage_turns} + for event in event_turns: + if event["index"] in used: + continue + merged.append({ + "index": event["index"], "start": event["start"], "end": event["end"], + "model": event["model"] or default_model, + "input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0, + "cache_read_tokens": 0, "cache_write_tokens": 0, + "token_available": False, "cache_available": False, + "context_available": False, "context_tokens": 0, "cost": None, + "ttft_s": event.get("ttft_s"), "tools": event.get("tools") or [], + }) + merged.sort(key=lambda row: (row["start"] or 0, row["index"])) + for index, turn in enumerate(merged, 1): + turn["index"] = index + return merged + + @staticmethod + def _available(value, available, basis=EvidenceBasis.MEASURED): + return EvidenceValue(value, basis) if available else EvidenceValue.unavailable() + + def load(self, source, detail): + if isinstance(source, dict): + return self.recompute_legacy(source) + if not isinstance(source, SessionSource): + raise TypeError("native load requires SessionSource") + if source.runtime_id != self.descriptor.runtime_id: + raise ValueError("source belongs to another runtime") + parsed = self._parsed(source.locator.value) + turns = parsed["turns"] + session_totals = parsed.get("session_totals") or {} + usage_turns = [turn for turn in turns if turn["token_available"]] + cache_turns = [turn for turn in turns if turn["cache_available"]] + cost_turns = [turn for turn in turns if turn["cost"] is not None] + tokens_available = bool(session_totals.get("token_available") or usage_turns) + cache_available = bool(session_totals.get("cache_available") or cache_turns) + cost_available = bool(session_totals.get("cost_available") or cost_turns) + intervals = [ + (turn["start"], turn["end"]) for turn in turns + if turn["start"] and turn["end"] >= turn["start"] + ] + tools = [] + for turn in turns: + for tool in turn["tools"]: + tools.append(ToolEvent(tool["name"], tool["category"], tool.get("error") and "error" or "success")) + warning_codes = [] + if parsed["truncated"]: + warning_codes.append("truncated") + if not tokens_available: + warning_codes.append("partial") + started = min((turn["start"] for turn in turns if turn["start"]), default=0) or None + ended = max((turn["end"] for turn in turns if turn["end"]), default=0) or None + if session_totals.get("token_available"): + input_tokens = session_totals["input_tokens"] + output_tokens = session_totals["output_tokens"] + else: + input_tokens = sum(turn["input_tokens"] for turn in usage_turns) + output_tokens = sum(turn["output_tokens"] for turn in usage_turns) + if session_totals.get("cache_available"): + cache_read = session_totals["cache_read_tokens"] + cache_write = session_totals["cache_write_tokens"] + else: + cache_read = sum(turn["cache_read_tokens"] for turn in cache_turns) + cache_write = sum(turn["cache_write_tokens"] for turn in cache_turns) + if session_totals.get("cost_available"): + cost = session_totals["cost"] or 0.0 + else: + cost = sum(turn["cost"] or 0.0 for turn in cost_turns) + del detail + return NormalizedSession( + source=source, + started_at=datetime.fromtimestamp(started) if started else None, + ended_at=datetime.fromtimestamp(ended) if ended else None, + usage=UsageEvidence( + input_tokens=self._available(input_tokens, tokens_available), + output_tokens=self._available(output_tokens, tokens_available), + cache_read_tokens=self._available(cache_read, cache_available), + cache_write_tokens=self._available(cache_write, cache_available), + cost_usd=self._available(cost, cost_available, EvidenceBasis.ESTIMATED), + ), + timing=TimingEvidence( + active_seconds=self._available( + merge_execution_intervals(intervals), bool(intervals), EvidenceBasis.INFERRED, + ), + wait_seconds=EvidenceValue.unavailable(), + ttft_seconds=self._available( + next((turn["ttft_s"] for turn in reversed(turns) if turn.get("ttft_s") is not None), None), + any(turn.get("ttft_s") is not None for turn in turns), + EvidenceBasis.MEASURED, + ), + ), + tools=tuple(tools), + turns=(), + pricing_basis="Grok-recorded local cost; Token Meter did not price this runtime.", + capabilities=self.descriptor.capabilities, + warnings=tuple(ParseWarning(code, "Grok evidence is incomplete.") for code in warning_codes), + detail=DetailLevel.SUMMARY, + ) + + def _require_compatibility(self): + required = ( + "add_model_daily", "add_model_summary", "analysis_block", "build_state", + "context_sample_limit", "metric_availability", "summarize_tool_evidence", + "summary_row", "tool_identity", "tool_summary", "trace_event", + ) + missing = [name for name in required if name not in self.compatibility] + if missing: + raise RuntimeError("Grok adapter is missing compatibility helpers: {}".format( + ", ".join(missing) + )) + return self.compatibility + + def _legacy_rows(self, source): + return self._parsed(source.get("path") or "")["turns"] + + def _legacy_usage(self, turn): + return { + "input_tokens": turn["input_tokens"], + "output_tokens": turn["output_tokens"], + "cache_read_input_tokens": turn["cache_read_tokens"], + "cache_creation_input_tokens": turn["cache_write_tokens"], + } + + def recompute_legacy(self, source): + compat = self._require_compatibility() + parsed = self._parsed(source.get("path") or "") + turns = parsed["turns"] + if not turns: + return None + session_totals = parsed.get("session_totals") or {} + tot = {"input": 0, "cache_write": 0, "cache_read": 0, "output": 0} + cost = {"input": 0.0, "cache_write": 0.0, "cache_read": 0.0, "output": 0.0} + model_tok, model_cost = defaultdict(int), defaultdict(float) + series, executions, trace, wait_samples, intervals = [], [], [], [], [] + all_tokens_available = bool( + session_totals.get("token_available") + or any(turn["token_available"] for turn in turns) + ) + all_cache_available = bool( + session_totals.get("cache_available") + or any(turn["cache_available"] for turn in turns) + ) + all_cost_available = bool( + session_totals.get("cost_available") + or any(turn["cost"] is not None for turn in turns) + ) + for turn in turns: + execution_cost = float(turn["cost"] or 0.0) + cost_available = turn["cost"] is not None + usage = self._legacy_usage(turn) + tools = [] + for tool in turn["tools"]: + ident = compat["tool_identity"](tool["name"]) + tools.append({ + **ident, "id": tool["id"], "call_id": tool["id"], + "args_chars": 0, "output_chars": 0, "output_tokens": 0, + "result_available": bool(tool.get("result_available")), + "error": bool(tool.get("error")), "skills": [], + }) + total = sum(usage.values()) + timing_available = bool(turn["start"] and turn["end"] >= turn["start"]) + availability = compat["metric_availability"]( + "grok", cost=cost_available, tokens=turn["token_available"], + input_tokens=turn["token_available"], output_tokens=turn["token_available"], + cache=turn["cache_available"], throughput=False, + context=turn["context_available"], timing=timing_available, + tool_results=any(tool["result_available"] for tool in tools), + ) + duration = max(0.0, turn["end"] - turn["start"]) if timing_available else 0.0 + series.append({ + "i": turn["index"], "in": usage["input_tokens"], "out": usage["output_tokens"], + "cost": execution_cost, "fresh_input": usage["input_tokens"], + "cache": usage["cache_read_input_tokens"] + usage["cache_creation_input_tokens"], + "cache_read": usage["cache_read_input_tokens"], + "cache_write": usage["cache_creation_input_tokens"], + "think": bool(turn["reasoning_tokens"]), + "tools": len(tools), "side": False, + "reasoning": turn["reasoning_tokens"], "reasoning_ms": 0, + "context_pct": None, "context_tokens": turn["context_tokens"], + "user_message": "", "user_input": "", "availability": availability, + }) + executions.append({ + "id": "{}:{}".format(source["id"], turn["index"]), "idx": turn["index"], + "ts": turn["end"] or turn["start"], + "time": time.strftime("%H:%M", time.localtime(turn["end"] or turn["start"] or 0)), + "model": turn["model"], + "tokens": { + "input": usage["input_tokens"], "output": usage["output_tokens"], + "reasoning": turn["reasoning_tokens"], "retrieval": 0, + "fresh_input": usage["input_tokens"], + "cache": usage["cache_read_input_tokens"] + usage["cache_creation_input_tokens"], + "cache_read": usage["cache_read_input_tokens"], + "cache_write": usage["cache_creation_input_tokens"], "total": total, + }, + "cost": execution_cost, + "cost_breakdown": { + "input": execution_cost, "cache_write": 0.0, "cache_read": 0.0, "output": 0.0, + } if cost_available else { + "input": 0.0, "cache_write": 0.0, "cache_read": 0.0, "output": 0.0, + }, + "tools": tools, "tool_count": len(tools), "model_calls": 1, + "reasoning_tokens": turn["reasoning_tokens"], + "reasoning_duration_ms": 0, + "context_tokens": turn["context_tokens"], + "context_window": 0, "context_pct": None, + "duration_ms": duration * 1000 if duration else None, + "wait_duration_ms": duration * 1000 if duration else None, + "summary": "Execution {}: {} tools · {}".format( + turn["index"], len(tools), + "${:.3f} Grok estimate".format(execution_cost) + if cost_available else "cost unavailable", + ), + "user_message": "", "user_input": "", "availability": availability, + }) + trace.append(compat["trace_event"]( + turn["start"], "user", "User input", "Content excluded", turn["index"], + severity="start", model=turn["model"], native_type="user", + native_subtype="user_message", + )) + for tool in tools: + trace.append(compat["trace_event"]( + turn["end"], "tool_call", tool["display"], "Payload excluded", + turn["index"], tool=tool["name"], + severity="warn" if tool.get("error") else "tool", + model=turn["model"], native_type="tool_call", native_subtype="tool_call", + )) + trace.append(compat["trace_event"]( + turn["end"], "complete", "Execution complete", "", turn["index"], + severity="good", model=turn["model"], + cost=execution_cost if cost_available else None, + native_type="assistant", native_subtype="agent_message", + )) + tot["input"] += usage["input_tokens"] + tot["cache_write"] += usage["cache_creation_input_tokens"] + tot["cache_read"] += usage["cache_read_input_tokens"] + tot["output"] += usage["output_tokens"] + if cost_available: + cost["input"] += execution_cost + model_tok[turn["model"]] += total + model_cost[turn["model"]] += execution_cost + if timing_available: + intervals.append((turn["start"], turn["end"])) + wait_samples.append({ + "provider": "grok", "model": turn["model"], + "day": time.strftime("%Y-%m-%d", time.localtime(turn["end"])), + "ts": turn["end"], "start_ts": turn["start"], "duration_s": duration, + "generation_s": duration, "ttft_s": turn.get("ttft_s") or 0.0, + "tool_calls": len(tools), "model_calls": 1, + "output_tokens": usage["output_tokens"], + "input_tokens": (usage["input_tokens"] + + usage["cache_read_input_tokens"] + + usage["cache_creation_input_tokens"]), + "uncached_input_tokens": usage["input_tokens"], + "cache_read_tokens": usage["cache_read_input_tokens"], + "cache_write_tokens": usage["cache_creation_input_tokens"], + "peak_input_tokens": turn["context_tokens"], + "context_tokens": turn["context_tokens"], + "timing_basis": "inferred", + }) + if session_totals.get("token_available"): + tot = { + "input": session_totals["input_tokens"], + "cache_write": session_totals["cache_write_tokens"], + "cache_read": session_totals["cache_read_tokens"], + "output": session_totals["output_tokens"], + } + if session_totals.get("cost_available"): + cost = { + "input": float(session_totals["cost"] or 0.0), + "cache_write": 0.0, "cache_read": 0.0, "output": 0.0, + } + total_tokens, total_cost = sum(tot.values()), sum(cost.values()) + tool_data = compat["tool_summary"](executions) + primary_model = max(model_tok, key=model_tok.get) if model_tok else source.get("model") + analyses = compat["analysis_block"]( + tot, total_cost, 0, 0, 0.0, model_tok, model_cost, tool_data, 0.0, 0, len(executions), + ) + active = merge_execution_intervals(intervals) + source = dict(source) + source["context_latest"] = executions[-1]["context_tokens"] if executions else 0 + throughput = performance_summary(wait_samples, tot["output"]) + context_available = bool(turns) and all(turn["context_available"] for turn in turns) + availability = compat["metric_availability"]( + "grok", cost=all_cost_available, tokens=all_tokens_available, + input_tokens=all_tokens_available, output_tokens=all_tokens_available, + cache=all_cache_available, throughput=throughput["available"], + context=context_available, timing=bool(intervals), + tool_results=any( + tool.get("result_available") + for execution in executions for tool in execution["tools"] + ), + ) + biggest = max( + ({"cost": execution["cost"], "idx": execution["idx"]} for execution in executions), + key=lambda row: row["cost"], default=None, + ) if all_cost_available else None + source["cache_savings_available"] = False + state = compat["build_state"]( + source, tot, cost, total_tokens, total_cost, series, executions, trace, + {"reasoning": 0, "output": 0, "retrieval": 0, "coordination": 0}, + analyses, [], min((turn["start"] for turn in turns if turn["start"]), default=0), + max((turn["end"] for turn in turns if turn["end"]), default=0), 0, biggest, 0, + True, primary_model, + "Grok-recorded local cost; Token Meter did not price this runtime.", + {"duration_s": active, "available": bool(intervals), "reported_executions": 0, + "observed_executions": len(intervals), "execution_count": len(executions), + "basis": "inferred"}, + wait_samples, availability=availability, + ) + state["throughput"] = throughput + state["semantic_available"] = False + return state + + def summarize_legacy(self, source, unused=None): + del unused + compat = self._require_compatibility() + parsed = self._parsed(source.get("path") or "") + turns = parsed["turns"] + session_totals = parsed.get("session_totals") or {} + model_cost, model_tok, model_stats, model_daily = ( + defaultdict(float), defaultdict(int), {}, {} + ) + day_cost, tool_calls, intervals, models, wait_samples, context_samples = ( + defaultdict(float), [], [], set(), [], [] + ) + total_cost = input_tokens = output_tokens = 0 + all_tokens_available = bool( + session_totals.get("token_available") + or (turns and any(turn["token_available"] for turn in turns)) + ) + all_cache_available = bool( + session_totals.get("cache_available") + or (turns and any(turn["cache_available"] for turn in turns)) + ) + all_cost_available = bool( + session_totals.get("cost_available") + or (turns and any(turn["cost"] is not None for turn in turns)) + ) + all_context_available = bool(turns) and any(turn["context_available"] for turn in turns) + for turn in turns: + usage = self._legacy_usage(turn) + value = float(turn["cost"] or 0.0) + total = sum(usage.values()) + input_tokens += usage["input_tokens"] + output_tokens += usage["output_tokens"] + total_cost += value + model_cost[turn["model"]] += value + model_tok[turn["model"]] += total + models.add(turn["model"]) + compat["add_model_summary"]( + model_stats, turn["model"], usage, value, + cost_available=turn["cost"] is not None, + ) + compat["add_model_daily"]( + model_daily, turn["model"], usage, value, turn["end"], + cost_available=turn["cost"] is not None, + ) + if turn["end"]: + day_cost[time.strftime("%Y-%m-%d", time.localtime(turn["end"]))] += value + if turn["start"] and turn["end"] >= turn["start"]: + intervals.append((turn["start"], turn["end"])) + duration = turn["end"] - turn["start"] + context_samples.append(turn["context_tokens"]) + wait_samples.append({ + "provider": "grok", "model": turn["model"], + "day": time.strftime("%Y-%m-%d", time.localtime(turn["end"])), + "ts": turn["end"], "start_ts": turn["start"], + "duration_s": duration, "generation_s": duration, + "ttft_s": turn.get("ttft_s") or 0.0, + "tool_calls": len(turn["tools"]), "model_calls": 1, + "output_tokens": usage["output_tokens"], + "input_tokens": (usage["input_tokens"] + + usage["cache_read_input_tokens"] + + usage["cache_creation_input_tokens"]), + "uncached_input_tokens": usage["input_tokens"], + "cache_read_tokens": usage["cache_read_input_tokens"], + "cache_write_tokens": usage["cache_creation_input_tokens"], + "peak_input_tokens": turn["context_tokens"], + "context_tokens": turn["context_tokens"], + "timing_basis": "inferred", + }) + for tool in turn["tools"]: + tool_calls.append({ + "name": tool["name"], "display": tool["name"].replace("_", " ").title(), + "namespace": tool["category"], "kind": "tool", "output_tokens": 0, + "error": bool(tool.get("error")), "ts": turn["end"], "skills": [], + }) + if session_totals.get("token_available"): + input_tokens = session_totals["input_tokens"] + output_tokens = session_totals["output_tokens"] + if session_totals.get("cost_available"): + total_cost = float(session_totals["cost"] or 0.0) + throughput = performance_summary(wait_samples, output_tokens) + availability = compat["metric_availability"]( + "grok", cost=all_cost_available, tokens=all_tokens_available, + input_tokens=all_tokens_available, output_tokens=all_tokens_available, + cache=all_cache_available, throughput=throughput["available"], + context=all_context_available, timing=bool(intervals), + tool_results=False, + ) + for stats in (*model_stats.values(), *model_daily.values()): + stats["availability"] = compat["metric_availability"]( + "grok", cost=int(stats.get("cost_covered_executions") or 0) > 0, + tokens=all_tokens_available, input_tokens=all_tokens_available, + output_tokens=all_tokens_available, cache=all_cache_available, + throughput=throughput["available"], context=all_context_available, + timing=False, tool_results=False, + ) + row = compat["summary_row"]( + source, None, total_cost, sum(model_tok.values()), len(turns), models, + min((turn["start"] for turn in turns if turn["start"]), default=0), + max((turn["end"] for turn in turns if turn["end"]), default=0), + model_cost, model_tok, day_cost, True, + {"duration_s": merge_execution_intervals(intervals), "available": bool(intervals), + "basis": "inferred"}, input_tokens, output_tokens, model_stats, + list(model_daily.values()), wait_samples, wait_samples, availability, + ) + row["primary_model"] = max(model_tok, key=model_tok.get) if model_tok else source.get("model") + row["context"] = { + "latest": context_samples[-1] if context_samples else 0, + "window": None, "latest_pct": None, "estimated": False, + } + row["_context_samples"] = context_samples[-compat["context_sample_limit"]:] + row["terminal"] = False + row["_tool_evidence"] = compat["summarize_tool_evidence"](tool_calls) + return row + + def deletion_plan(self, source): + return DeletionPlan.deny("Grok sessions are read-only in Token Meter.") + + +class GrokRuntimeAdapterProxy: + descriptor = GrokRuntimeAdapter.descriptor + + def __init__(self, adapter_factory): + self._adapter_factory = adapter_factory + + def _adapter(self): + adapter = self._adapter_factory() + if getattr(adapter, "load", None) is None or getattr(adapter, "discover", None) is None: + raise TypeError("adapter factory returned an invalid Grok adapter") + return adapter + + def discover(self, context): + return self._adapter().discover(context) + + def discover_legacy(self, context): + return self._adapter().discover_legacy(context) + + def current_revision(self, source): + return self._adapter().current_revision(source) + + def load(self, source, detail): + return self._adapter().load(source, detail) + + def summarize_legacy(self, source, unused=None): + return self._adapter().summarize_legacy(source, unused) + + def deletion_plan(self, source): + return self._adapter().deletion_plan(source) From f3a40f104b89be17b640e08c6b7eec5c2d9db306 Mon Sep 17 00:00:00 2001 From: JM Date: Thu, 10 Sep 2026 13:55:30 -0700 Subject: [PATCH 2/4] Add a desktop usage widget and Grok account quotas Ship a native usage overlay with provider chips, snap it to the nearer vertical screen edge, and read Grok's provider-reported limits from the local CLI sign-in. Claude scoped weekly windows keep their model name, including Fable, instead of a generic Scoped N label. Local credential files stay gitignored and are never logged. --- menubar/TokenMeterMenuBar.swift | 69 ++ menubar/token_meter_tray.py | 118 +++- menubar/token_meter_widget.py | 887 +++++++++++++++++++++++++ page.html | 178 +++++ runtime-manifest.txt | 1 + scripts/install-linux | 2 +- scripts/run-tray.ps1 | 240 +++++++ tests/contracts/test_quota_registry.py | 6 + tests/runtimes/test_grok_adapter.py | 1 + tests/test_meter.py | 417 ++++++++++++ token_meter/app.py | 41 +- token_meter/quotas/anthropic.py | 4 +- token_meter/quotas/registry.py | 9 + token_meter/quotas/xai.py | 134 ++++ token_meter/runtimes/grok.py | 2 +- 15 files changed, 2094 insertions(+), 15 deletions(-) create mode 100755 menubar/token_meter_widget.py create mode 100644 token_meter/quotas/xai.py diff --git a/menubar/TokenMeterMenuBar.swift b/menubar/TokenMeterMenuBar.swift index 8412ad5..5eccae7 100644 --- a/menubar/TokenMeterMenuBar.swift +++ b/menubar/TokenMeterMenuBar.swift @@ -1,9 +1,11 @@ import Cocoa import Carbon.HIToolbox import Foundation +import WebKit private let tokenMeterMenubarURL = URL(string: "http://127.0.0.1:8722/menubar")! private let tokenMeterDashboardURL = URL(string: "http://127.0.0.1:8722/#sessions")! +private let tokenMeterWidgetURL = URL(string: "http://127.0.0.1:8722/#widget")! private let tokenMeterBudgetSettingsURL = URL(string: "http://127.0.0.1:8722/#settings-budgets")! private let tokenMeterUpdateSettingsURL = URL(string: "http://127.0.0.1:8722/#settings-updates")! private let tokenMeterInstallUpdateURL = URL(string: "http://127.0.0.1:8722/updates/install")! @@ -22,6 +24,7 @@ private let globalShortcutDefaultsKey = "TokenMeterGlobalShortcut" private let customShortcutKeyCodeDefaultsKey = "TokenMeterCustomShortcutKeyCode" private let customShortcutModifiersDefaultsKey = "TokenMeterCustomShortcutModifiers" private let tokenMeterMenubarBundleIdentifier = "com.token-meter.menubar" +private let usageWidgetOpenDefaultsKey = "TokenMeterUsageWidgetOpen" private let statusItemAutosaveName = "TokenMeterPrimaryStatusItem" private let statusItemPreferredPositionDefaultsKey = "NSStatusItem Preferred Position \(statusItemAutosaveName)" private let statusItemInitialPreferredPosition = 50 @@ -1077,6 +1080,7 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var snapshot = MeterSnapshot.disconnected("Waiting for http://127.0.0.1:8722/menubar") private var monthlyBudget: MonthlyBudget? private var softwareUpdate = SoftwareUpdateSnapshot.waiting + private var usageWidgetWindow: NSPanel? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -1095,6 +1099,9 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate { registerGlobalHotKey() rebuildMenu() fetchState() + if usageWidgetOpenPreference { + showUsageWidget() + } timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in self?.fetchState() } @@ -1218,6 +1225,11 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate { addConnectionRow() } + let widgetItem = NSMenuItem(title: "Usage widget", action: #selector(toggleUsageWidget), keyEquivalent: "") + widgetItem.image = menuSymbol("rectangle.trailinghalf.inset.filled", description: "Usage widget") + widgetItem.target = self + menu.addItem(widgetItem) + let limitsItem = NSMenuItem(title: "Provider limits (Beta)", action: nil, keyEquivalent: "") limitsItem.image = menuSymbol("gauge.with.dots.needle.50percent", description: "Provider limits (Beta)") limitsItem.submenu = makeLimitsMenu() @@ -2274,6 +2286,63 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate { try? process.run() } + private var usageWidgetOpenPreference: Bool { + get { + if tokenMeterDefaults.object(forKey: usageWidgetOpenDefaultsKey) == nil { return true } + return tokenMeterDefaults.bool(forKey: usageWidgetOpenDefaultsKey) + } + set { tokenMeterDefaults.set(newValue, forKey: usageWidgetOpenDefaultsKey) } + } + + private func showUsageWidget() { + if ProcessInfo.processInfo.environment["TOKEN_METER_MENUBAR_SMOKE"] == "1" { return } + if usageWidgetWindow == nil { + usageWidgetWindow = makeUsageWidgetPanel() + } + positionUsageWidget() + usageWidgetWindow?.makeKeyAndOrderFront(nil) + } + + @objc private func toggleUsageWidget() { + if ProcessInfo.processInfo.environment["TOKEN_METER_MENUBAR_SMOKE"] == "1" { return } + if usageWidgetWindow?.isVisible == true { + usageWidgetWindow?.orderOut(nil) + usageWidgetOpenPreference = false + return + } + usageWidgetOpenPreference = true + showUsageWidget() + NSApp.activate(ignoringOtherApps: true) + } + + private func makeUsageWidgetPanel() -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 520), + styleMask: [.titled, .closable, .utilityWindow, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.title = "Token Meter" + panel.isFloatingPanel = true + panel.level = .floating + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + let webView = WKWebView(frame: panel.contentView?.bounds ?? .zero) + webView.autoresizingMask = [.width, .height] + webView.load(URLRequest(url: tokenMeterWidgetURL)) + panel.contentView = webView + return panel + } + + private func positionUsageWidget() { + guard let panel = usageWidgetWindow, let screen = NSScreen.main else { return } + var frame = panel.frame + frame.origin.x = screen.visibleFrame.maxX - frame.width - 12 + frame.origin.y = screen.visibleFrame.maxY - frame.height - 72 + panel.setFrame(frame, display: true) + } + @objc private func openDashboard() { if pinnedSessionID?.isEmpty == false { openDashboardPanel("summary") diff --git a/menubar/token_meter_tray.py b/menubar/token_meter_tray.py index 82ddd9d..8850666 100755 --- a/menubar/token_meter_tray.py +++ b/menubar/token_meter_tray.py @@ -2,6 +2,7 @@ """Linux StatusNotifier/AppIndicator companion for Token Meter.""" import json import os +import signal import subprocess import sys import time @@ -22,6 +23,7 @@ ("claude", "Claude"), ("codex", "Codex"), ("cursor", "Cursor"), + ("grok", "Grok"), ) TITLE_METRICS = ( ("cost", "Cost"), @@ -41,7 +43,9 @@ "quota_notification_states": {}, "budget_notification_states": {}, "budget_exceeded_notification_months": [], + "usage_widget_open": True, } +WIDGET_APPLICATION_ID = "com.tokenmeter.usagewidget" def metric_available(availability, metric): @@ -588,11 +592,89 @@ def dashboard_url(panel, pinned_session=None, include_pinned_session=True): except (ValueError, ImportError): gi.require_version("AppIndicator3", "0.1") from gi.repository import AppIndicator3 as AppIndicator - from gi.repository import GLib, Gtk + from gi.repository import Gio, GLib, Gtk GTK_AVAILABLE = True except (ImportError, ValueError): AppIndicator = None - GLib = Gtk = None + GLib = Gtk = Gio = None + + +def usage_widget_script(): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "token_meter_widget.py") + + +def _dbus_session_call(method, params, reply_type): + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + return bus.call_sync( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + method, + params, + reply_type, + Gio.DBusCallFlags.NONE, + 500, + None, + ) + + +def usage_widget_running(): + if Gio is None or GLib is None: + return False + try: + reply = _dbus_session_call( + "NameHasOwner", + GLib.Variant("(s)", (WIDGET_APPLICATION_ID,)), + GLib.VariantType("(b)"), + ) + return bool(reply.unpack()[0]) + except Exception: + return False + + +def usage_widget_pid(): + if Gio is None or GLib is None: + return None + try: + reply = _dbus_session_call( + "GetConnectionUnixProcessID", + GLib.Variant("(s)", (WIDGET_APPLICATION_ID,)), + GLib.VariantType("(u)"), + ) + return int(reply.unpack()[0]) + except Exception: + return None + + +def spawn_usage_widget(args=None): + """Start, raise, or stop the separate desktop widget process. Never embed it here.""" + if os.environ.get("TOKEN_METER_TRAY_SMOKE") == "1": + return + if args and "--quit" in args: + pid = usage_widget_pid() + if pid: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + return + env = os.environ.copy() + if ( + env.get("XDG_SESSION_TYPE") == "wayland" + and env.get("DISPLAY") + and not env.get("GDK_BACKEND") + ): + env["GDK_BACKEND"] = "x11" + try: + subprocess.Popen( + [sys.executable, usage_widget_script()], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + env=env, + ) + except OSError as exc: + print(f"Token Meter could not open the usage widget: {exc}", file=sys.stderr) def gtk_requirements_message(): @@ -617,6 +699,7 @@ def __init__(self): self.quota_observation_established = bool( self.settings.get("quota_observation_established") ) + self.usage_widget_open = bool(self.settings.get("usage_widget_open", True)) self.snapshot = {} self.monthly_budget = None self.provider_quotas = [] @@ -687,6 +770,8 @@ def __init__(self): "pace": self._metric_item(""), }) self.actions_separator = Gtk.SeparatorMenuItem() + self.usage_widget_item = Gtk.CheckMenuItem(label="Usage widget") + self.usage_widget_item.connect("toggled", self._on_usage_widget_toggled) self.action_items = { "Open Dashboard": self._action_item( "Open Dashboard", lambda *_: self.open_dashboard(), @@ -742,6 +827,8 @@ def __init__(self): self.indicator.set_menu(self.menu) self.poll() GLib.timeout_add_seconds(2, self.poll) + if self.usage_widget_open: + GLib.idle_add(self._start_usage_widget) def _submenu_item(self, label): item = Gtk.MenuItem(label=label) @@ -785,6 +872,7 @@ def _assemble_menu(self): self.menu.append(self.provider_detail_items["coverage"]) self.menu.append(self.provider_detail_items["footer"]) self.menu.append(self.actions_separator) + self.menu.append(self.usage_widget_item) for item in self.action_items.values(): self.menu.append(item) self.menu.append(self.settings_item) @@ -801,6 +889,7 @@ def _set_visible(widget, visible): def _on_menu_show(self, _menu): self.menu_open = True + self._sync_usage_widget_item() def _on_menu_hide(self, _menu): self.menu_open = False @@ -838,6 +927,7 @@ def save_state(self): self.settings.get("budget_exceeded_notification_months") or [] ), "quota_observation_established": self.quota_observation_established, + "usage_widget_open": self.usage_widget_open, } try: os.makedirs(os.path.dirname(STATE_PATH), mode=0o700, exist_ok=True) @@ -961,6 +1051,29 @@ def _on_title_metric_toggled(self, item): def _on_quota_alerts_toggled(self, item): self.quota_alerts_enabled = item.get_active() self.save_state() + + def _sync_usage_widget_item(self): + if usage_widget_running(): + self.usage_widget_open = True + if self.usage_widget_item.get_active() != bool(self.usage_widget_open): + self.usage_widget_item.handler_block_by_func(self._on_usage_widget_toggled) + self.usage_widget_item.set_active(bool(self.usage_widget_open)) + self.usage_widget_item.handler_unblock_by_func(self._on_usage_widget_toggled) + + def _start_usage_widget(self): + if self.usage_widget_open: + spawn_usage_widget() + return False + + def _on_usage_widget_toggled(self, item): + want = bool(item.get_active()) + self.usage_widget_open = want + self.save_state() + running = usage_widget_running() + if want and not running: + spawn_usage_widget() + elif not want and running: + spawn_usage_widget(["--quit"]) self.refresh_menu_content() def _on_threshold_toggled(self, item): @@ -1014,6 +1127,7 @@ def _apply_menu_content(self): for tab_id, item in self.tab_items.items(): item.set_active(tab_id == self.selected_tab) self.quota_alerts_item.set_active(self.quota_alerts_enabled) + self._sync_usage_widget_item() for threshold, item in self.threshold_items.items(): item.set_active(threshold == self.quota_alert_threshold) for metric_id, item in self.title_metric_items.items(): diff --git a/menubar/token_meter_widget.py b/menubar/token_meter_widget.py new file mode 100755 index 0000000..e213f85 --- /dev/null +++ b/menubar/token_meter_widget.py @@ -0,0 +1,887 @@ +#!/usr/bin/env python3 +"""Native Linux desktop usage widget for Token Meter. + +Runs as a separate GTK process from the AppIndicator tray so an overlay +window cannot take down the tray (or systemd Restart=on-failure). +""" +import json +import os +import subprocess +import sys +from urllib.error import URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +BASE_URL = os.environ.get("TOKEN_METER_URL", "http://127.0.0.1:8722").rstrip("/") +STATE_URL = BASE_URL + "/menubar" +APPLICATION_ID = "com.tokenmeter.usagewidget" +UNKNOWN_RUNTIME = "unknown-runtime" +POLL_SECONDS = 4 +COLLAPSED_SIZE = (36, 168) +EXPANDED_SIZE = (300, 520) +PANEL_MARGIN_X = 12 +DEFAULT_TOP = 96 +DRAG_THRESHOLD = 6 + +GTK_AVAILABLE = False +Gdk = GLib = Gtk = Gio = None + + +def usage_widget_chips(payload): + catalog = payload.get("runtime_catalog") if isinstance(payload, dict) else None + if not isinstance(catalog, dict): + catalog = {} + quotas = payload.get("provider_quotas") if isinstance(payload, dict) else None + sessions = payload.get("recent_sessions") if isinstance(payload, dict) else None + chips = [] + seen = set() + + def add(provider_id, label=None): + provider_id = str(provider_id or "") + if not provider_id or provider_id == UNKNOWN_RUNTIME or provider_id in seen: + return + seen.add(provider_id) + meta = catalog.get(provider_id) if isinstance(catalog.get(provider_id), dict) else {} + chips.append({ + "id": provider_id, + "label": str(meta.get("label") or label or provider_id), + "has_quota": False, + }) + + for row in quotas or (): + if isinstance(row, dict): + add(row.get("id"), row.get("label")) + for row in sessions or (): + if isinstance(row, dict): + add(row.get("provider"), row.get("label")) + quota_ids = { + str(row.get("id") or "") + for row in (quotas or ()) + if isinstance(row, dict) + } + for chip in chips: + chip["has_quota"] = chip["id"] in quota_ids + return chips + + +def _quota_windows(row): + windows = row.get("windows") if isinstance(row, dict) else None + return [window for window in (windows or ()) if isinstance(window, dict)] + + +def _hottest_percent(windows): + hottest = None + for window in windows: + used = window.get("used_percent") + if used is None: + continue + try: + used = float(used) + except (TypeError, ValueError): + continue + if hottest is None or used > hottest: + hottest = used + return hottest + + +def _primary_quota(quotas): + ranked = [] + for row in quotas: + windows = _quota_windows(row) + if not windows: + continue + ranked.append(( _hottest_percent(windows) or -1, row, windows)) + if not ranked: + return None, [] + ranked.sort(key=lambda item: item[0], reverse=True) + return ranked[0][1], ranked[0][2] + + +def _chip_label(chips, provider_id, payload): + for chip in chips: + if chip["id"] == provider_id: + return chip["label"] + source = payload.get("source") if isinstance(payload.get("source"), dict) else {} + return str(source.get("label") or "Token Meter") + + +def usage_widget_view(payload, selected_id=""): + payload = payload if isinstance(payload, dict) else {} + chips = usage_widget_chips(payload) + selected_id = str(selected_id or "") + known = {chip["id"] for chip in chips} + if selected_id not in known: + selected_id = "" + quotas = [ + row for row in (payload.get("provider_quotas") or ()) + if isinstance(row, dict) + ] + sessions = [ + row for row in (payload.get("recent_sessions") or ()) + if isinstance(row, dict) + ] + if selected_id: + quotas = [row for row in quotas if str(row.get("id") or "") == selected_id] + sessions = [row for row in sessions if str(row.get("provider") or "") == selected_id] + primary = quotas[0] if quotas else None + windows = _quota_windows(primary) if primary else [] + title = _chip_label(chips, selected_id, payload) + else: + primary, windows = _primary_quota(quotas) + title = (primary or {}).get("label") or _chip_label( + chips, str((primary or {}).get("id") or ""), payload, + ) + if not primary: + title = str((payload.get("source") or {}).get("label") or "Token Meter") + live = payload.get("live_throughput") if isinstance(payload.get("live_throughput"), dict) else {} + current_provider = str(payload.get("provider") or "") + live_ok = bool(live.get("available")) and ( + not selected_id or current_provider == selected_id + ) + try: + live_tps = float(live.get("output_tps") or 0) if live_ok else None + except (TypeError, ValueError): + live_tps = None + if live_tps is not None and live_tps <= 0: + live_tps = None + return { + "chips": chips, + "selected_id": selected_id, + "title": title, + "windows": windows, + "sessions": sessions[:5], + "hottest": _hottest_percent(windows), + "quota_available": bool(windows), + "live_tps": live_tps, + "ended": bool(payload.get("ended")), + } + + +def fetch_menubar(): + request = Request(STATE_URL, headers={"Cache-Control": "no-cache", "Pragma": "no-cache"}) + with urlopen(request, timeout=5) as response: + if response.status != 200: + raise URLError(f"HTTP {response.status}") + payload = json.load(response) + if not isinstance(payload, dict): + raise ValueError("unreadable response") + return payload + + +def session_url(session_id): + return f"{BASE_URL}/sessions/{quote(str(session_id or ''), safe='')}#summary" + + +def compact_number(value): + value = float(value or 0) + magnitude = abs(value) + if magnitude >= 1_000_000: + text = f"{value / 1_000_000:.1f}".rstrip("0").rstrip(".") + return text + "M" + if magnitude >= 1_000: + text = f"{value / 1_000:.1f}".rstrip("0").rstrip(".") + return text + "K" + if magnitude >= 10: + return f"{value:.0f}" + return f"{value:.1f}".rstrip("0").rstrip(".") + + +def percent_label(value): + if value is None: + return "--" + return f"{float(value):.0f}%" + + +def widget_state_path(): + config_home = os.path.expanduser(os.environ.get("XDG_CONFIG_HOME") or "~/.config") + return os.path.join(config_home, "token-meter", "widget.json") + + +def load_widget_position(): + try: + with open(widget_state_path(), encoding="utf-8") as handle: + value = json.load(handle) + except (OSError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def save_widget_position(payload): + path = widget_state_path() + try: + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + temporary = path + ".tmp" + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + except OSError: + pass + + +def usage_widget_size(expanded): + if expanded: + return ( + COLLAPSED_SIZE[0] + EXPANDED_SIZE[0] + 2 * PANEL_MARGIN_X, + max(EXPANDED_SIZE[1], 280), + ) + return COLLAPSED_SIZE + + +def usage_widget_anchor(x, width, monitor): + mx, _my, mw, _mh = monitor + midpoint = float(x) + float(width) / 2.0 + return "right" if midpoint >= mx + mw / 2.0 else "left" + + +def usage_widget_dock_x(anchor, width, monitor): + """X origin that keeps the drawn width flush with the docked wall.""" + mx, _my, mw, _mh = [int(v) for v in (monitor or (0, 0, 1920, 1080))] + width = max(1, int(width or COLLAPSED_SIZE[0])) + if str(anchor) == "left": + return mx + return mx + mw - width + + +def usage_widget_snap(x, y, width, height, monitor): + """Pin the drawer to the nearer left or right edge; keep the drop's Y.""" + mx, my, mw, mh = [int(v) for v in (monitor or (0, 0, 1920, 1080))] + width = max(1, int(width or COLLAPSED_SIZE[0])) + height = max(1, int(height or COLLAPSED_SIZE[1])) + anchor = usage_widget_anchor(x, width, (mx, my, mw, mh)) + snap_y = max(my, min(int(y), my + mh - min(height, mh))) + return { + "x": usage_widget_dock_x(anchor, width, (mx, my, mw, mh)), + "y": snap_y, + "width": width, + "height": height, + "anchor": anchor, + } + + +def usage_widget_frame(expanded, position=None, monitor=None): + """Keep the docked wall flush; left opens rightward, right opens leftward.""" + mx, my, mw, mh = monitor or (0, 0, 1920, 1080) + mx, my, mw, mh = int(mx), int(my), int(mw), int(mh) + width, height = usage_widget_size(expanded) + height = min(height, max(120, mh - 24)) + if not isinstance(position, dict) or position.get("x") is None: + y = my + DEFAULT_TOP + anchor = "right" + else: + anchor = str(position.get("anchor") or "right") + if anchor not in ("left", "right"): + anchor = "right" + y = int(position["y"]) if position.get("y") is not None else my + DEFAULT_TOP + x = usage_widget_dock_x(anchor, width, (mx, my, mw, mh)) + y = max(my, min(y, my + mh - min(height, mh))) + return { + "x": x, + "y": y, + "width": width, + "height": height, + "anchor": anchor, + } + + +def configure_backend(): + if os.environ.get("GDK_BACKEND"): + return + if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"): + os.environ["GDK_BACKEND"] = "x11" + + +if __name__ == "__main__": + configure_backend() + +try: + import gi + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk, Gio, GLib, Gtk + GTK_AVAILABLE = True +except (ImportError, ValueError): + pass + + +if GTK_AVAILABLE: + CSS = b""" + window.usage-widget { background: #0b1016; } + .usage-tab { background: #111820; color: #ffb457; padding: 8px 0; } + .usage-title { color: #ffb457; font-weight: 700; letter-spacing: 1px; } + .usage-live { color: #66d990; } + .usage-dim { color: #8b98a8; } + .usage-fg { color: #f6f8fb; } + .usage-chip { padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.04); color: #a8b3c1; } + .usage-chip:checked { background: rgba(0,188,235,0.16); color: #f6f8fb; } + progress, trough { min-height: 7px; border-radius: 99px; } + """ + + class UsageWidgetWindow(Gtk.ApplicationWindow): + def __init__(self, application): + super().__init__(application=application, title="Token Meter") + self.selected_id = "" + self.expanded = False + self.payload = {} + self.error = "" + self.position = load_widget_position() + self._press = None + self._dragging = False + self._pinning = False + self.set_decorated(False) + self.set_keep_above(True) + self.set_skip_taskbar_hint(True) + self.set_skip_pager_hint(True) + self.set_accept_focus(True) + self.set_resizable(False) + self.set_default_size(*COLLAPSED_SIZE) + self.set_position(Gtk.WindowPosition.NONE) + try: + self.set_type_hint(Gdk.WindowTypeHint.NORMAL) + except Exception: + pass + self.get_style_context().add_class("usage-widget") + provider = Gtk.CssProvider() + provider.load_from_data(CSS) + screen = Gdk.Screen.get_default() + if screen is not None: + Gtk.StyleContext.add_provider_for_screen( + screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + self.connect("delete-event", self._on_delete) + self.connect("realize", self._on_realize) + self.connect("map-event", self._on_map) + self.connect("size-allocate", self._on_size_allocate) + self.body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + self.add(self.body) + self.tab = Gtk.EventBox() + self.tab.get_style_context().add_class("usage-tab") + self.tab.set_size_request(*COLLAPSED_SIZE) + self.tab.set_visible_window(True) + self._bind_drag(self.tab, toggle=True) + self.tab_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.tab_box.set_valign(Gtk.Align.CENTER) + self.tab_percent = Gtk.Label(label="--") + self.tab_percent.get_style_context().add_class("usage-title") + self.tab_caption = Gtk.Label(label="LIMITS") + self.tab_caption.set_angle(90) + self.tab_caption.get_style_context().add_class("usage-dim") + self.tab_box.pack_start(self.tab_percent, False, False, 0) + self.tab_box.pack_start(self.tab_caption, False, False, 0) + self.tab.add(self.tab_box) + self.panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.panel.set_margin_top(10) + self.panel.set_margin_bottom(12) + self.panel.set_margin_start(12) + self.panel.set_margin_end(12) + self.panel.set_size_request(*EXPANDED_SIZE) + self.panel.set_no_show_all(True) + self.panel.hide() + self.chip_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + self.chip_box.set_homogeneous(False) + self.head = Gtk.EventBox() + self.head.set_visible_window(False) + self._bind_drag(self.head, toggle=False) + head_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.title_label = Gtk.Label(label="TOKEN METER") + self.title_label.set_xalign(0) + self.title_label.get_style_context().add_class("usage-title") + self.live_label = Gtk.Label(label="") + self.live_label.set_xalign(1) + self.live_label.get_style_context().add_class("usage-live") + head_row.pack_start(self.title_label, True, True, 0) + head_row.pack_end(self.live_label, False, False, 0) + self.head.add(head_row) + self.bars = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.sessions = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.panel.pack_start(self.chip_box, False, False, 0) + self.panel.pack_start(self.head, False, False, 0) + self.panel.pack_start(self.bars, False, False, 0) + recent = Gtk.Label(label="RECENT") + recent.set_xalign(0) + recent.get_style_context().add_class("usage-dim") + self.panel.pack_start(recent, False, False, 0) + self.panel.pack_start(self.sessions, False, False, 0) + self.body.pack_start(self.tab, False, False, 0) + self.body.pack_start(self.panel, True, True, 0) + self.refresh() + GLib.timeout_add_seconds(POLL_SECONDS, self.refresh) + self._apply_frame() + + def _bind_drag(self, widget, toggle): + widget.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.BUTTON_MOTION_MASK + | Gdk.EventMask.POINTER_MOTION_HINT_MASK + ) + widget.connect("button-press-event", self._on_drag_press) + widget.connect("motion-notify-event", self._on_drag_motion) + widget.connect( + "button-release-event", + lambda w, event: self._on_drag_release(w, event, toggle), + ) + try: + widget.connect("enter-notify-event", self._on_drag_cursor) + except TypeError: + pass + + def _on_drag_cursor(self, widget, _event): + window = widget.get_window() or self.get_window() + display = Gdk.Display.get_default() + if window is None or display is None: + return False + try: + window.set_cursor(Gdk.Cursor.new_from_name(display, "grab")) + except Exception: + pass + return False + + def _backend_x11(self): + display = Gdk.Display.get_default() + name = display.get_name() if display is not None else "" + backend = os.environ.get("GDK_BACKEND") or "" + return name.startswith(":") or backend == "x11" + + def _monitor_geometry(self, monitor): + geo = monitor.get_geometry() + return (geo.x, geo.y, geo.width, geo.height) + + def _monitor_at(self, x, y): + display = Gdk.Display.get_default() + monitor = display.get_primary_monitor() or display.get_monitor(0) + try: + at_point = display.get_monitor_at_point(int(x), int(y)) + if at_point is not None: + monitor = at_point + except Exception: + pass + return self._monitor_geometry(monitor) + + def _monitor(self): + display = Gdk.Display.get_default() + x = y = 0 + try: + x, y = self._window_origin() + except Exception: + pass + saved = self.position if isinstance(self.position, dict) else {} + if not x and not y and saved.get("x") is not None: + x = int(saved.get("x") or 0) + y = int(saved.get("y") or 0) + if not x and not y: + try: + seat = display.get_default_seat() + _screen, x, y = seat.get_pointer().get_position() + except Exception: + pass + return self._monitor_at(x, y) + + def _on_delete(self, *_args): + self.hide() + return True + + def _on_realize(self, *_args): + gdk_win = self.get_window() + if gdk_win is None: + return + try: + if self._backend_x11(): + gdk_win.set_override_redirect(True) + gdk_win.set_keep_above(True) + except Exception: + pass + + def _on_map(self, *_args): + GLib.idle_add(self._apply_frame) + GLib.timeout_add(80, self._apply_frame) + GLib.timeout_add(400, self._apply_frame) + return False + + def _on_size_allocate(self, _widget, allocation): + if self._dragging or self._pinning: + return + if int(getattr(allocation, "width", 0) or 0) <= 1: + return + GLib.idle_add(self._pin_drawn_size) + + def _on_drag_press(self, _widget, event): + if getattr(event, "button", 0) != 1: + return False + try: + win_x, win_y = self._window_origin() + except Exception: + win_x, win_y = 0, 0 + self._press = (event.x_root, event.y_root, win_x, win_y, event.time) + self._dragging = False + return True + + def _on_drag_motion(self, _widget, event): + if self._press is None: + return False + if not (event.state & Gdk.ModifierType.BUTTON1_MASK): + return False + dx = event.x_root - self._press[0] + dy = event.y_root - self._press[1] + if not self._dragging and abs(dx) + abs(dy) < DRAG_THRESHOLD: + return False + self._dragging = True + if self._backend_x11(): + self._move_to(int(self._press[2] + dx), int(self._press[3] + dy)) + else: + try: + self.begin_move_drag( + 1, int(event.x_root), int(event.y_root), event.time, + ) + except Exception: + self._move_to(int(self._press[2] + dx), int(self._press[3] + dy)) + self._press = None + return True + + def _on_drag_release(self, _widget, event, toggle): + if getattr(event, "button", 0) != 1: + return False + dragged = self._dragging + self._press = None + self._dragging = False + if dragged: + try: + x, y = self._window_origin() + width, height = self._drawn_size() + except Exception: + return True + snapped = usage_widget_snap(x, y, width, height, self._monitor_at(x, y)) + self.position = snapped + save_widget_position(self.position) + self._apply_frame() + return True + if toggle: + self.expanded = not self.expanded + self._apply_frame() + return True + + def _drawn_size(self): + gdk_win = self.get_window() + if gdk_win is not None: + try: + width, height = gdk_win.get_width(), gdk_win.get_height() + if width > 1 and height > 1: + return int(width), int(height) + except Exception: + pass + return ( + int(self.get_allocated_width() or COLLAPSED_SIZE[0]), + int(self.get_allocated_height() or COLLAPSED_SIZE[1]), + ) + + def _window_origin(self): + gdk_win = self.get_window() + if gdk_win is not None: + try: + origin = gdk_win.get_origin() + if isinstance(origin, tuple) and len(origin) >= 2: + return int(origin[-2]), int(origin[-1]) + except Exception: + pass + try: + x, y = self.get_position() + return int(x), int(y) + except Exception: + return ( + int((self.position or {}).get("x") or 0), + int((self.position or {}).get("y") or 0), + ) + + def _move_to(self, x, y, width=None, height=None): + drawn_w, drawn_h = self._drawn_size() + width = int(width if width is not None else drawn_w) + height = int(height if height is not None else drawn_h) + self.move(x, y) + gdk_win = self.get_window() + if gdk_win is not None: + try: + gdk_win.move(x, y) + gdk_win.move_resize(x, y, width, height) + except Exception: + pass + + def _pin_drawn_size(self): + if self._dragging or self._pinning: + return False + width, height = self._drawn_size() + if width <= 1: + return False + x, y = self._window_origin() + monitor = self._monitor_at(x, y) + anchor = str((self.position or {}).get("anchor") or "right") + if anchor not in ("left", "right"): + anchor = "right" + pinned_x = usage_widget_dock_x(anchor, width, monitor) + if abs(int(x) - pinned_x) <= 1: + return False + self._pinning = True + try: + self._move_to(pinned_x, y, width, height) + self.position = { + "x": pinned_x, + "y": int(y), + "width": width, + "height": height, + "anchor": anchor, + } + save_widget_position(self.position) + finally: + self._pinning = False + return False + + def _dock_order(self, anchor): + if anchor == "left": + wanted = (self.panel, self.tab) + else: + wanted = (self.tab, self.panel) + current = tuple(self.body.get_children()) + if current == wanted: + return + for child in current: + self.body.remove(child) + for child in wanted: + expand = child is self.panel + self.body.pack_start(child, expand, expand, 0) + + def _apply_frame(self): + if self._dragging: + return False + monitor = self._monitor() + frame = usage_widget_frame(self.expanded, self.position, monitor) + self._dock_order(frame["anchor"]) + if self.expanded: + self.panel.show() + else: + self.panel.hide() + self.set_size_request(frame["width"], frame["height"]) + drawn_w, drawn_h = self._drawn_size() + if self.expanded: + width = max(frame["width"], drawn_w) + height = max(frame["height"], drawn_h) + else: + width, height = frame["width"], frame["height"] + self.resize(width, height) + x = usage_widget_dock_x(frame["anchor"], width, monitor) + y = frame["y"] + self.position = { + "x": x, + "y": y, + "width": width, + "anchor": frame["anchor"], + } + self._move_to(x, y, width, height) + save_widget_position(self.position) + GLib.idle_add(self._pin_drawn_size) + return False + + def _on_chip(self, button): + if not button.get_active(): + return + provider_id = getattr(button, "token_meter_provider", "") + if provider_id == self.selected_id: + return + self.selected_id = provider_id + self._render_body() + + def _clear(self, box): + for child in list(box.get_children()): + box.remove(child) + + def _rebuild_chips(self, chips, selected_id): + current = [ + getattr(child, "token_meter_provider", None) + for child in self.chip_box.get_children() + ] + expected = [""] + [chip["id"] for chip in chips] + if current != expected: + self._clear(self.chip_box) + group = None + for provider_id, label in [("", "All")] + [ + (chip["id"], chip["label"]) for chip in chips + ]: + button = Gtk.RadioButton.new_with_label_from_widget(group, label) + group = button + button.set_mode(False) + button.token_meter_provider = provider_id + button.get_style_context().add_class("usage-chip") + button.connect("toggled", self._on_chip) + self.chip_box.pack_start(button, False, False, 0) + self.chip_box.show_all() + for button in self.chip_box.get_children(): + want = getattr(button, "token_meter_provider", "") == selected_id + if button.get_active() != want: + button.handler_block_by_func(self._on_chip) + button.set_active(want) + button.handler_unblock_by_func(self._on_chip) + + def _bar_row(self, window): + used = window.get("used_percent") + try: + used = max(0.0, min(100.0, float(used or 0))) + except (TypeError, ValueError): + used = 0.0 + row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + meta = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + name = Gtk.Label(label=str(window.get("label") or "Limit")) + name.set_xalign(0) + name.get_style_context().add_class("usage-fg") + pct = Gtk.Label(label=percent_label(used)) + pct.set_xalign(1) + pct.get_style_context().add_class("usage-fg") + meta.pack_start(name, True, True, 0) + meta.pack_end(pct, False, False, 0) + bar = Gtk.ProgressBar() + bar.set_fraction(used / 100.0) + row.pack_start(meta, False, False, 0) + row.pack_start(bar, False, False, 0) + return row + + def _session_row(self, session): + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + name = Gtk.Label(label=str(session.get("name") or "Session")) + name.set_xalign(0) + name.get_style_context().add_class("usage-fg") + mark = Gtk.Label(label=str(session.get("label") or session.get("provider") or "")) + mark.set_xalign(1) + mark.get_style_context().add_class("usage-dim") + inner.pack_start(name, True, True, 0) + inner.pack_end(mark, False, False, 0) + button.add(inner) + session_id = str(session.get("id") or "") + button.connect("clicked", lambda *_args, sid=session_id: self._open_session(sid)) + return button + + def _open_session(self, session_id): + if not session_id: + return + try: + subprocess.Popen( + ["xdg-open", session_url(session_id)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + pass + + def _render_body(self): + view = usage_widget_view(self.payload, self.selected_id) + self.selected_id = view["selected_id"] + self._rebuild_chips(view["chips"], self.selected_id) + self.title_label.set_text(str(view["title"] or "Token Meter").upper()) + if view["live_tps"] is not None and not view["ended"]: + self.live_label.set_text( + "LIVE " + compact_number(view["live_tps"] * 60) + " tok/min" + ) + else: + self.live_label.set_text("") + self.tab_percent.set_text(percent_label(view["hottest"])) + self._clear(self.bars) + if self.error: + err = Gtk.Label(label=self.error) + err.set_xalign(0) + err.set_line_wrap(True) + err.get_style_context().add_class("usage-dim") + self.bars.pack_start(err, False, False, 0) + elif view["quota_available"]: + for window in view["windows"]: + self.bars.pack_start(self._bar_row(window), False, False, 0) + else: + empty = Gtk.Label(label="No provider limits reported.") + empty.set_xalign(0) + empty.get_style_context().add_class("usage-dim") + self.bars.pack_start(empty, False, False, 0) + self.bars.show_all() + self._clear(self.sessions) + if view["sessions"]: + for session in view["sessions"]: + self.sessions.pack_start(self._session_row(session), False, False, 0) + else: + empty = Gtk.Label(label="No recent sessions.") + empty.set_xalign(0) + empty.get_style_context().add_class("usage-dim") + self.sessions.pack_start(empty, False, False, 0) + self.sessions.show_all() + if not self._dragging: + GLib.idle_add(self._apply_frame) + + def refresh(self): + try: + self.payload = fetch_menubar() + self.error = "" + except (OSError, ValueError, URLError) as exc: + self.payload = {} + self.error = str(exc) or "Waiting for Token Meter" + self._render_body() + return True + + class UsageWidgetApplication(Gtk.Application): + def __init__(self): + super().__init__( + application_id=APPLICATION_ID, + flags=Gio.ApplicationFlags.FLAGS_NONE, + ) + self.window = None + + def do_activate(self): + if self.window is None: + self.window = UsageWidgetWindow(self) + self.add_window(self.window) + self.window.show_all() + self.window._apply_frame() + self.window.present() + GLib.idle_add(self.window._apply_frame) + + +def gtk_requirements_message(): + return ( + "Token Meter's usage widget needs GTK 3 and PyGObject.\n" + "Debian/Ubuntu: sudo apt install python3-gi\n" + "Fedora: sudo dnf install python3-gobject\n" + "Arch: sudo pacman -S python-gobject" + ) + + +def run_smoke(): + fixture = os.environ.get("TOKEN_METER_WIDGET_FIXTURE") + if fixture: + payload = json.loads(fixture) + else: + payload = fetch_menubar() + selected = os.environ.get("TOKEN_METER_WIDGET_PROVIDER") or "" + print(json.dumps(usage_widget_view(payload, selected), sort_keys=True)) + + +def main(argv=None): + argv = list(sys.argv if argv is None else argv) + if "--check" in argv: + return 0 + if "--smoke" in argv or os.environ.get("TOKEN_METER_WIDGET_SMOKE") == "1": + try: + run_smoke() + except (OSError, ValueError, URLError, json.JSONDecodeError) as exc: + print(f"Token Meter widget smoke failed: {exc}", file=sys.stderr) + return 1 + return 0 + if not GTK_AVAILABLE: + print(gtk_requirements_message(), file=sys.stderr) + return 1 + configure_backend() + GLib.set_prgname("token-meter-widget") + try: + Gdk.set_program_class("token-meter-widget") + except Exception: + pass + app = UsageWidgetApplication() + return app.run(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/page.html b/page.html index 4a8ed41..cbbd3ee 100644 --- a/page.html +++ b/page.html @@ -502,7 +502,57 @@ .frustrationSection .signalRankTrack i,.frustrationSection .chatSignalTrack i{background:linear-gradient(90deg,var(--signal-primary),var(--signal-secondary))} .frustrationSection .termPanel .signalRankTrack i{background:linear-gradient(90deg,var(--signal-secondary),var(--signal-primary))} .frustrationSection .signalRateBadge{border-color:var(--signal-edge);background:var(--signal-wash);color:var(--signal-secondary)} +.usageWidget{position:fixed;top:92px;right:0;z-index:80;display:flex;align-items:stretch;pointer-events:none} +.usageWidgetTab{pointer-events:auto;width:34px;min-height:148px;border:1px solid var(--line2);border-right:0;border-radius:10px 0 0 10px;background:linear-gradient(180deg,rgba(17,24,32,.96),rgba(11,16,22,.96));color:var(--orange3);box-shadow:var(--shadow-soft);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:10px 0} +.usageWidgetTab b{font:800 11px/1 ui-monospace,"SF Mono",Menlo,monospace} +.usageWidgetTab small{writing-mode:vertical-rl;transform:rotate(180deg);color:var(--faint);font-size:10px;font-weight:760;letter-spacing:.06em;text-transform:uppercase} +.usageWidgetPanel{pointer-events:auto;width:300px;max-height:calc(100vh - 120px);overflow:auto;border:1px solid var(--line2);border-right:0;border-radius:12px 0 0 12px;background:linear-gradient(180deg,rgba(14,20,28,.98),rgba(8,12,18,.98));box-shadow:var(--shadow);padding:12px 13px 14px} +.usageWidgetChips{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 10px} +.usageWidgetChips button{border:1px solid var(--line2);background:rgba(255,255,255,.03);color:var(--dim);border-radius:999px;padding:3px 8px;font:780 10px/1.2 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer} +.usageWidgetChips button[aria-selected=true]{border-color:var(--accent);color:var(--fg);background:rgba(0,188,235,.12)} +.usageWidgetHead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px} +.usageWidgetHead strong{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--orange3)} +.usageWidgetLive{color:var(--good);font:780 11px/1 ui-monospace,"SF Mono",Menlo,monospace} +.usageWidgetGauges{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0 0 12px} +.usageGauge{display:grid;justify-items:center;gap:4px} +.usageGauge svg{width:72px;height:72px} +.usageGauge b{font:820 15px/1 ui-monospace,"SF Mono",Menlo,monospace} +.usageGauge span{color:var(--faint);font-size:9.5px;text-transform:uppercase;letter-spacing:.04em} +.usageBars{display:grid;gap:8px;margin:0 0 12px} +.usageBar{display:grid;gap:3px} +.usageBarMeta{display:flex;justify-content:space-between;gap:8px;font-size:11px} +.usageBarMeta b{font-weight:760} +.usageBarTrack{height:7px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden} +.usageBarTrack i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,var(--accent),var(--orange3))} +.usageBar.hot .usageBarTrack i{background:linear-gradient(90deg,var(--warn),var(--bad))} +.usageBar.dead .usageBarTrack i{background:var(--bad)} +.usageSessions{display:grid;gap:4px} +.usageSessions a,.usageEmpty{display:flex;justify-content:space-between;gap:8px;padding:7px 8px;border-radius:8px;border:1px solid var(--line);color:var(--fg);text-decoration:none;font-size:12px} +.usageSessions a:hover{border-color:var(--line2);background:rgba(255,255,255,.03)} +.usageSessions small{color:var(--faint)} +.usageEmpty{color:var(--dim)} +body.usageWidgetStandalone{background:#07090c} +body.usageWidgetStandalone .wrap,body.usageWidgetStandalone .fieldTipPopup{display:none!important} +body.usageWidgetStandalone .usageWidget{top:0;height:100vh} +body.usageWidgetStandalone .usageWidgetPanel{max-height:100vh;border-radius:0;height:100vh} +@media(max-width:1024px){.usageWidget{top:78px}.usageWidgetPanel{width:260px}} +