Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</p>

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.

Expand Down Expand Up @@ -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 |
| --- | --- | --- |
Expand Down
6 changes: 3 additions & 3 deletions page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4336,9 +4336,9 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
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;
Expand Down
15 changes: 14 additions & 1 deletion specs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +109,19 @@ 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 (`costUsdTicks` at `1e10` ticks per USD), not a
Token Meter price-table lookup. Zero, partial (`costIsPartial`), and incomplete
(`usageIsIncomplete`) cost evidence stay unavailable rather than free. 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,
Expand Down
35 changes: 31 additions & 4 deletions specs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -325,7 +340,10 @@ 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` (`costUsdTicks` at `1e10` ticks per USD), never a Token Meter
price-table lookup. Zero, partial, or incomplete Grok cost stays unavailable
instead of displaying as free.
Token Meter reports recorded evidence, not a pre-flight prediction.

## Privacy
Expand Down Expand Up @@ -377,6 +395,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"role":"user","content":"private grok prompt"}
{"role":"assistant","content":"private grok response"}
5 changes: 5 additions & 0 deletions tests/fixtures/grok/sessions/--repo--/session-1/events.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
8 changes: 8 additions & 0 deletions tests/fixtures/grok/sessions/--repo--/session-1/signals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"turnCount": 1,
"toolCallCount": 1,
"toolsUsed": ["read_file"],
"primaryModelId": "grok-4.6",
"contextTokensUsed": 115,
"contextWindowTokens": 200000
}
9 changes: 9 additions & 0 deletions tests/fixtures/grok/sessions/--repo--/session-1/summary.json
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions tests/fixtures/grok/sessions/--repo--/session-1/usage.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
27 changes: 24 additions & 3 deletions tests/integration/test_runtime_dispatch_characterization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"}
Expand Down Expand Up @@ -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"}
Expand All @@ -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"],)
Expand All @@ -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(
Expand All @@ -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()
Expand All @@ -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"}
Expand All @@ -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(
Expand All @@ -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()

Expand Down
Loading