diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fa731a..2aca68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,4 @@ jobs: run: bun run lint - name: Test - run: bun test + run: bun run test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bfd7e12..4627b57 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -55,7 +55,7 @@ jobs: run: bun run lint - name: Test - run: bun test + run: bun run test # ========================================================================== # Job 2: Publish — only after verify succeeds @@ -88,12 +88,19 @@ jobs: - name: Build run: bun run build - - name: Publish @sriinnu/tokmeter - run: cd packages/tokmeter && npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Publish @sriinnu/drishti - run: cd packages/mcp && npm publish --provenance --access public + - name: Prepare and publish verified package artifacts + run: | + bash scripts/prepare-packages.sh "$RUNNER_TEMP/tokmeter-packages" + for package in tokmeter mcp; do + name=$(node -p "require('./packages/$package/package.json').name") + version=$(node -p "require('./packages/$package/package.json').version") + # A local signed release can publish npm before creating the + # GitHub release. Never attempt to overwrite that version. + if npm view "$name@$version" version 2>/dev/null | grep -Fx "$version"; then + echo "$name@$version is already published" + continue + fi + npm publish "$RUNNER_TEMP/tokmeter-packages/$package-$version.tgz" --provenance --access public + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6632e75..c444b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.10.0] — 2026-09-06 + +### Changed + +- The macOS opening view now leads with today's tokens, separates estimated API cost from tool-reported amounts, and shows today's projects. Secondary gauges and lifetime metrics live under Usage details. +- Model lists open on Today and retain all models behind Show all. Missing cost data is labeled Unavailable; an explicit known zero stays zero. +- Added a compatibility table, pricing methodology, synthetic-data walkthrough, and a one-week tester guide. + +### Fixed + +- Read Codex `token_usage_record` receipts, deduplicate mirrored legacy counters and repeated response IDs, and skip replayed parent receipts in subagents. +- Keep SQLite fallback usage separate from sessions covered by JSONL, including receipts buried before long tool output. +- Correct reasoning percentages and preserve explicit zero cache/reasoning rates and tool-reported zero costs. +- An idle local day no longer inherits the previous active day's totals in the macOS header. +- npm and macOS artifacts now include the application/core license texts and matching source; the macOS app also includes Sparkle notices and a Licenses & source control. + +### Validation + +See [release preparation](docs/release/1.10.0.md) for checks and [the tagged release](https://github.com/sriinnu/tokmeter/releases/tag/v1.10.0) for publication status and downloads. + ## [1.9.2] - 2026-07-15 ### Fixed diff --git a/README.md b/README.md index 841d37b..602757e 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,57 @@ -

- Tokmeter -

+

Tokmeter

-

tokmeter

+# Tokmeter -

Token Usage Tracker for AI Coding Agents

+**See where your AI coding usage goes—across projects, models, and agents.** -

- release - npm - node - license - bun -

+Tokmeter turns local coding-agent usage into a daily view of tokens, estimated API cost, and the projects driving it. Your history stays on your machine, including saved daily totals after old session logs are removed. ---- +Start with Claude Code and Codex on macOS. Other integrations have different levels of evidence; see the [compatibility table](docs/compatibility.md). -Tokmeter parses local session logs from 16+ AI coding agents into per-project / model / provider / day token-and-cost aggregates, and exposes them through five surfaces: CLI, TUI, web dashboard, MCP server, and a macOS menu-bar daemon. +## See it -Pricing is resolved locally via [`@sriinnu/kosha-discovery`](https://www.npmjs.com/package/@sriinnu/kosha-discovery) (20+ providers, 300+ OpenRouter models); nothing leaves the machine. +

Tokmeter showing today's tokens, estimated API cost, models, and projects with synthetic demo data

-How it stores history, keeps "today" live, and stays memory-bounded - the daemon + relay model - is in [`docs/architecture.md`](docs/architecture.md). Package layout is under [Packages](#packages); programmatic use is under [Consume Tokmeter From Other Apps](#consume-tokmeter-from-other-apps). +[Watch the 20-second walkthrough](docs/assets/demo/tokmeter-demo.mp4) · [How the numbers work](docs/how-the-numbers-work.md) -## What it looks like +The walkthrough renders the **1.10.0** macOS views using synthetic data; it is not a recording of a customer's usage. See the [release page](https://github.com/sriinnu/tokmeter/releases/tag/v1.10.0) for downloads. -Below is exactly what tokmeter prints on a real machine - same code you'd `npm install`. Project names are swapped to generic ones for privacy; spend numbers, cache rates, optimization scores, model breakdowns, and everything else are unedited. +## Try one report - - - - - -
- TokmeterBar popover -
macOS menu bar - live signals at a glance. -
- tokmeter digest --period week -
tokmeter digest - weekly cost report card with optimization grade. -
+Requires Node.js 18+ and local usage from a supported coding agent: -

- tokmeter overview -
tokmeter - per-project breakdown across all parsed agents. -

+```sh +npx @sriinnu/tokmeter --today +``` -Try it on your own data: `npx @sriinnu/tokmeter` (add `--light` to skip pricing on the first scan). More surface shots (TUI, web, Hub, statusline) are tracked in [`docs/assets/screenshots/README.md`](docs/assets/screenshots/README.md). +No provider API key is needed to read Claude Code or Codex's local usage. Pricing lookup can fetch public catalog data. Session contents are not sent to a service. To skip pricing: -## What it computes +```sh +npx @sriinnu/tokmeter --today --light +``` -From parsed session logs, per project / model / provider / day: +## Keep it in your macOS menu bar -- Cost and token totals (input / output / cache-read / cache-write / reasoning). -- Cache hit rate and cache savings. -- Daily spend trend and active-day streaks. -- Compaction overhead - the share of today's spend that went to `/compact`. -- Live burn rate and pace vs. your typical spend at this hour (from the relay's `costByHour`). -- Cheaper-model suggestions from the live kosha price registry. +Requires macOS 14+ and the local daemon: -All local; no network calls except the kosha pricing fetch. +1. Install the daemon: `npm install -g @sriinnu/drishti` +2. Start it: `drishti daemon start` +3. Download **TokmeterBar** from [GitHub Releases](https://github.com/sriinnu/tokmeter/releases/latest), move it into Applications, and open it. -## Quick Start +The menu bar shows today's tokens. Open it for estimated API cost, any tool-reported cost, and today's models and projects. Expand **Usage details** for trends and other metrics. The [macOS guide](packages/macos-bar/README.md) covers building locally. -```bash -# Run directly -npx @sriinnu/tokmeter +## Understand the dollars -# Or install globally - gives you both `tokmeter` and `tokmeter-tui` -npm install -g @sriinnu/tokmeter -tokmeter -tokmeter-tui -``` +- **Estimated API cost** values recorded usage at model rates. It is not your ChatGPT or Claude subscription bill. +- **Tool-reported cost** is an amount already present in local tool telemetry. It is not independently verified against an invoice. +- **Unavailable** means the price, token breakdown, or source information is missing. A missing price is not a free request. +- Historical totals can combine estimates and tool reports. Older saved days may lack enough information to separate them; normal refreshes preserve those days. + +## Help us test it + +The first trial focuses on macOS developers using both Claude Code and Codex. [The one-week trial guide](docs/trial/guide.md) explains what to try and how to report a mismatch without sharing a transcript. + +For developers integrating Tokmeter: the CLI, TUI, web dashboard, MCP server, and [daemon/relay architecture](docs/architecture.md) share the same accounting core. Details follow. ## Packages @@ -230,7 +212,7 @@ The `digest` command gives you a cost report card: Discipline: F (40) Tips: - - You spent $620 on GPT-5.4 today - Sonnet would've cost $124 + - The same recorded token counts estimate to $620 on model A and $124 on model B; task quality is not evaluated - Cache efficiency is solid at 98% - keep sessions active ``` @@ -430,7 +412,7 @@ works whether or not a provider reports a context window. Turn it off for a plai monochrome icon.

- TokmeterBar popover + TokmeterBar popover

```bash @@ -668,4 +650,6 @@ bun run format # Format - Application - AGPL-3.0-only: [LICENSE](./LICENSE) - Core library `@sriinnu/tokmeter-core` - MPL-2.0: [packages/core/LICENSE](./packages/core/LICENSE) +Release artifacts include the license texts and source snapshot. See [licenses and source](docs/licensing.md) for scope, bundled notices, and build instructions. + Copyright (c) 2026 Srinivas Pendela. diff --git a/docs/assets/demo/README.md b/docs/assets/demo/README.md new file mode 100644 index 0000000..5f2ad7d --- /dev/null +++ b/docs/assets/demo/README.md @@ -0,0 +1,16 @@ +# Synthetic-data walkthrough + +`tokmeter-demo.mp4` is a 20-second walkthrough rendered from the production SwiftUI `HeroHeader` and `UsageOverview` views. It is not a screen recording of a live customer session. + +The four scenes show an idle day, normal usage, a missing price, and tool-reported cost. The amounts and projects are synthetic; the generator reads no local session files or credentials. The scene captions and demo footer are presentation overlays in the renderer, not controls in the app. + +Reproduce from the repository root on macOS with Xcode, Bun, and FFmpeg: + +```sh +bun scripts/generate-demo.ts +bunx biome format --write docs/assets/demo/snapshots.json +TOKMETER_DEMO_DIR="$PWD/docs/assets/demo" swift test --package-path packages/macos-bar --filter DemoRenderTests +ffmpeg -y -framerate 1/5 -i docs/assets/demo/scene-%02d.png -c:v libx264 -r 24 -pix_fmt yuv420p -movflags +faststart docs/assets/demo/tokmeter-demo.mp4 +``` + +Inspect every PNG for clipped text and missing controls before replacing the public assets. Rendering must use the same production views as the app; do not retouch a screenshot to imply functionality that the build does not have. diff --git a/docs/assets/demo/scene-00.png b/docs/assets/demo/scene-00.png new file mode 100644 index 0000000..89f090b Binary files /dev/null and b/docs/assets/demo/scene-00.png differ diff --git a/docs/assets/demo/scene-01.png b/docs/assets/demo/scene-01.png new file mode 100644 index 0000000..39d1b9e Binary files /dev/null and b/docs/assets/demo/scene-01.png differ diff --git a/docs/assets/demo/scene-02.png b/docs/assets/demo/scene-02.png new file mode 100644 index 0000000..bce744c Binary files /dev/null and b/docs/assets/demo/scene-02.png differ diff --git a/docs/assets/demo/scene-03.png b/docs/assets/demo/scene-03.png new file mode 100644 index 0000000..9fbe68c Binary files /dev/null and b/docs/assets/demo/scene-03.png differ diff --git a/docs/assets/demo/snapshots.json b/docs/assets/demo/snapshots.json new file mode 100644 index 0000000..9e45989 --- /dev/null +++ b/docs/assets/demo/snapshots.json @@ -0,0 +1,975 @@ +[ + { + "caption": "Your day starts with a clear usage view", + "tokens": 0, + "signals": { + "costBasisToday": { + "estimatedCost": 0, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 0, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "modelCostBasisToday": {}, + "burnRate": { + "costPerHour": 0, + "tokensPerHour": 0, + "windowMinutes": 60, + "recordsInWindow": 0 + }, + "cacheHitToday": { + "rate": 0, + "canonicalRate": 0, + "readShare": 0, + "missRate": 0, + "freshInputShare": 0, + "cacheWriteShare": 0, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "inputTokens": 0, + "freshInputTokens": 0, + "totalInputTokens": 0 + }, + "contextPressure": { + "status": "none", + "dragShare": 0, + "dragTokens": 0, + "currentInputTokens": 0, + "baselineInputTokens": 0, + "turnCount": 0, + "sessionAgeMinutes": 0, + "source": "none", + "provenance": "not_exposed", + "reason": "No request input telemetry was available." + }, + "projectContextToday": [], + "pace": { + "multiple": null, + "typicalCostByNow": 0, + "actualCostByNow": 0, + "daysOfHistory": 0 + }, + "compactionToday": { + "cost": 0, + "tokens": 0, + "share": 0, + "events": 0 + }, + "subagentToday": { + "cost": 0, + "records": 0, + "share": 0 + }, + "reasoningToday": { + "tokens": 0, + "outputTokens": 0, + "share": 0, + "records": 0 + }, + "toolCallsToday": { + "byTool": [], + "totalCost": 0, + "callCount": 0, + "turnsWithTools": 0 + }, + "billingWindow": null, + "liveSession": null + }, + "models": [], + "projects": [] + }, + { + "caption": "See today's models and projects", + "tokens": 975000, + "signals": { + "costBasisToday": { + "estimatedCost": 2.05, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 2, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "modelCostBasisToday": { + "codex::gpt-6-astra": { + "estimatedCost": 1.05, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 1, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "claude-code::claude-sonnet-4-6": { + "estimatedCost": 1, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 1, + "reportedRecords": 0, + "unavailableRecords": 0 + } + }, + "burnRate": { + "costPerHour": 2.05, + "tokensPerHour": 975000, + "windowMinutes": 60, + "recordsInWindow": 2 + }, + "cacheHitToday": { + "rate": 0.9574468085106383, + "canonicalRate": 0.9574468085106383, + "readShare": 0.9574468085106383, + "missRate": 0.0425531914893617, + "freshInputShare": 0.0425531914893617, + "cacheWriteShare": 0, + "cacheReadTokens": 900000, + "cacheWriteTokens": 0, + "inputTokens": 40000, + "freshInputTokens": 40000, + "totalInputTokens": 940000 + }, + "contextPressure": { + "status": "critical", + "dragShare": 0, + "dragTokens": 0, + "currentInputTokens": 320000, + "baselineInputTokens": 320000, + "turnCount": 1, + "sessionAgeMinutes": 0, + "source": "project_provider_model", + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "project": "sample-web", + "provenance": "estimated", + "reason": "Only one active-session record is available, so Tokmeter cannot see much session growth yet." + }, + "projectContextToday": [ + { + "project": "sample-web", + "cacheHitRate": 0.9375, + "missRate": 0.0625, + "freshInputShare": 0.0625, + "cacheWriteShare": 0, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "inputTokens": 20000, + "freshInputTokens": 20000, + "totalInputTokens": 320000, + "contextStatus": "critical", + "dragShare": 0, + "dragTokens": 0, + "turnCount": 1, + "lastUsed": 1788688770000 + }, + { + "project": "sample-api", + "cacheHitRate": 0.967741935483871, + "missRate": 0.03225806451612903, + "freshInputShare": 0.03225806451612903, + "cacheWriteShare": 0, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "inputTokens": 20000, + "freshInputTokens": 20000, + "totalInputTokens": 620000, + "contextStatus": "critical", + "dragShare": 0, + "dragTokens": 0, + "turnCount": 1, + "lastUsed": 1788688740000 + } + ], + "pace": { + "multiple": null, + "typicalCostByNow": 0, + "actualCostByNow": 2.05, + "daysOfHistory": 0 + }, + "compactionToday": { + "cost": 0, + "tokens": 0, + "share": 0, + "events": 0 + }, + "subagentToday": { + "cost": 0, + "records": 0, + "share": 0 + }, + "reasoningToday": { + "tokens": 1000, + "outputTokens": 35000, + "share": 0.02857142857142857, + "records": 1 + }, + "toolCallsToday": { + "byTool": [], + "totalCost": 0, + "callCount": 0, + "turnsWithTools": 0 + }, + "billingWindow": { + "blockNumber": 1, + "blockStart": 1788688770000, + "blockEnd": 1788706770000, + "remainingSec": 17970, + "elapsedPct": 0.16666666666666669, + "cost": 1, + "tokens": 350000, + "records": 1 + }, + "liveSession": { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "project": "sample-web", + "ageSeconds": 30, + "lastRecordCost": 1 + } + }, + "models": [ + { + "provider": "codex", + "model": "gpt-6-astra", + "totalTokens": 625000, + "cost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "percentageOfTotal": 51.21951219512195 + }, + { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "totalTokens": 350000, + "cost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 48.78048780487806 + } + ], + "projects": [ + { + "project": "sample-api", + "totalTokens": 625000, + "totalCost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "models": [ + { + "provider": "codex", + "model": "gpt-6-astra", + "totalTokens": 625000, + "cost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "percentageOfTotal": 51.21951219512195 + } + ], + "providers": [ + { + "provider": "codex", + "totalTokens": 625000, + "cost": 1.05, + "models": ["gpt-6-astra"], + "percentageOfTotal": 51.21951219512195 + } + ], + "dailyBreakdown": [ + { + "date": "2026-09-06", + "totalTokens": 625000, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "cost": 1.05, + "records": 1 + } + ], + "activeDays": 1, + "firstUsed": 1788688740000, + "lastUsed": 1788688740000 + }, + { + "project": "sample-web", + "totalTokens": 350000, + "totalCost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "models": [ + { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "totalTokens": 350000, + "cost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 48.78048780487806 + } + ], + "providers": [ + { + "provider": "claude-code", + "totalTokens": 350000, + "cost": 1, + "models": ["claude-sonnet-4-6"], + "percentageOfTotal": 48.78048780487806 + } + ], + "dailyBreakdown": [ + { + "date": "2026-09-06", + "totalTokens": 350000, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 1, + "records": 1 + } + ], + "activeDays": 1, + "firstUsed": 1788688770000, + "lastUsed": 1788688770000 + } + ] + }, + { + "caption": "Missing prices stay unavailable", + "tokens": 1025000, + "signals": { + "costBasisToday": { + "estimatedCost": 2.05, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 2, + "reportedRecords": 0, + "unavailableRecords": 1 + }, + "modelCostBasisToday": { + "codex::gpt-6-astra": { + "estimatedCost": 1.05, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 1, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "claude-code::claude-sonnet-4-6": { + "estimatedCost": 1, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 1, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "codex::new-model": { + "estimatedCost": 0, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 0, + "reportedRecords": 0, + "unavailableRecords": 1 + } + }, + "burnRate": { + "costPerHour": 2.05, + "tokensPerHour": 1025000, + "windowMinutes": 60, + "recordsInWindow": 3 + }, + "cacheHitToday": { + "rate": 0.9183673469387755, + "canonicalRate": 0.9183673469387755, + "readShare": 0.9183673469387755, + "missRate": 0.08163265306122448, + "freshInputShare": 0.08163265306122448, + "cacheWriteShare": 0, + "cacheReadTokens": 900000, + "cacheWriteTokens": 0, + "inputTokens": 80000, + "freshInputTokens": 80000, + "totalInputTokens": 980000 + }, + "contextPressure": { + "status": "low", + "dragShare": 0, + "dragTokens": 0, + "currentInputTokens": 40000, + "baselineInputTokens": 40000, + "turnCount": 1, + "sessionAgeMinutes": 0, + "source": "project_provider_model", + "provider": "codex", + "model": "new-model", + "project": "sample-api", + "provenance": "estimated", + "reason": "Only one active-session record is available, so Tokmeter cannot see much session growth yet." + }, + "projectContextToday": [ + { + "project": "sample-api", + "cacheHitRate": 0.9090909090909091, + "missRate": 0.09090909090909091, + "freshInputShare": 0.09090909090909091, + "cacheWriteShare": 0, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "inputTokens": 60000, + "freshInputTokens": 60000, + "totalInputTokens": 660000, + "contextStatus": "low", + "dragShare": 0, + "dragTokens": 0, + "turnCount": 1, + "lastUsed": 1788688790000 + }, + { + "project": "sample-web", + "cacheHitRate": 0.9375, + "missRate": 0.0625, + "freshInputShare": 0.0625, + "cacheWriteShare": 0, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "inputTokens": 20000, + "freshInputTokens": 20000, + "totalInputTokens": 320000, + "contextStatus": "critical", + "dragShare": 0, + "dragTokens": 0, + "turnCount": 1, + "lastUsed": 1788688770000 + } + ], + "pace": { + "multiple": null, + "typicalCostByNow": 0, + "actualCostByNow": 2.05, + "daysOfHistory": 0 + }, + "compactionToday": { + "cost": 0, + "tokens": 0, + "share": 0, + "events": 0 + }, + "subagentToday": { + "cost": 0, + "records": 0, + "share": 0 + }, + "reasoningToday": { + "tokens": 1000, + "outputTokens": 45000, + "share": 0.022222222222222223, + "records": 1 + }, + "toolCallsToday": { + "byTool": [], + "totalCost": 0, + "callCount": 0, + "turnsWithTools": 0 + }, + "billingWindow": { + "blockNumber": 1, + "blockStart": 1788688770000, + "blockEnd": 1788706770000, + "remainingSec": 17970, + "elapsedPct": 0.16666666666666669, + "cost": 1, + "tokens": 350000, + "records": 1 + }, + "liveSession": { + "provider": "codex", + "model": "new-model", + "project": "sample-api", + "ageSeconds": 10, + "lastRecordCost": 0 + } + }, + "models": [ + { + "provider": "codex", + "model": "gpt-6-astra", + "totalTokens": 625000, + "cost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "percentageOfTotal": 51.21951219512195 + }, + { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "totalTokens": 350000, + "cost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 48.78048780487806 + }, + { + "provider": "codex", + "model": "new-model", + "totalTokens": 50000, + "cost": 0, + "inputTokens": 40000, + "outputTokens": 10000, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 0 + } + ], + "projects": [ + { + "project": "sample-api", + "totalTokens": 675000, + "totalCost": 1.05, + "inputTokens": 60000, + "outputTokens": 14000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "models": [ + { + "provider": "codex", + "model": "gpt-6-astra", + "totalTokens": 625000, + "cost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "percentageOfTotal": 51.21951219512195 + }, + { + "provider": "codex", + "model": "new-model", + "totalTokens": 50000, + "cost": 0, + "inputTokens": 40000, + "outputTokens": 10000, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 0 + } + ], + "providers": [ + { + "provider": "codex", + "totalTokens": 675000, + "cost": 1.05, + "models": ["gpt-6-astra", "new-model"], + "percentageOfTotal": 51.21951219512195 + } + ], + "dailyBreakdown": [ + { + "date": "2026-09-06", + "totalTokens": 675000, + "inputTokens": 60000, + "outputTokens": 14000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "cost": 1.05, + "records": 2 + } + ], + "activeDays": 1, + "firstUsed": 1788688740000, + "lastUsed": 1788688790000 + }, + { + "project": "sample-web", + "totalTokens": 350000, + "totalCost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "models": [ + { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "totalTokens": 350000, + "cost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 48.78048780487806 + } + ], + "providers": [ + { + "provider": "claude-code", + "totalTokens": 350000, + "cost": 1, + "models": ["claude-sonnet-4-6"], + "percentageOfTotal": 48.78048780487806 + } + ], + "dailyBreakdown": [ + { + "date": "2026-09-06", + "totalTokens": 350000, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 1, + "records": 1 + } + ], + "activeDays": 1, + "firstUsed": 1788688770000, + "lastUsed": 1788688770000 + } + ] + }, + { + "caption": "Tool reports stay separate from estimates", + "tokens": 1000000, + "signals": { + "costBasisToday": { + "estimatedCost": 2.05, + "reportedCost": 0.4, + "unclassifiedCost": 0, + "estimatedRecords": 2, + "reportedRecords": 1, + "unavailableRecords": 0 + }, + "modelCostBasisToday": { + "codex::gpt-6-astra": { + "estimatedCost": 1.05, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 1, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "claude-code::claude-sonnet-4-6": { + "estimatedCost": 1, + "reportedCost": 0, + "unclassifiedCost": 0, + "estimatedRecords": 1, + "reportedRecords": 0, + "unavailableRecords": 0 + }, + "cursor::tool-reported-model": { + "estimatedCost": 0, + "reportedCost": 0.4, + "unclassifiedCost": 0, + "estimatedRecords": 0, + "reportedRecords": 1, + "unavailableRecords": 0 + } + }, + "burnRate": { + "costPerHour": 2.4499999999999997, + "tokensPerHour": 1000000, + "windowMinutes": 60, + "recordsInWindow": 3 + }, + "cacheHitToday": { + "rate": 0.9375, + "canonicalRate": 0.9375, + "readShare": 0.9375, + "missRate": 0.0625, + "freshInputShare": 0.0625, + "cacheWriteShare": 0, + "cacheReadTokens": 900000, + "cacheWriteTokens": 0, + "inputTokens": 60000, + "freshInputTokens": 60000, + "totalInputTokens": 960000 + }, + "contextPressure": { + "status": "low", + "dragShare": 0, + "dragTokens": 0, + "currentInputTokens": 20000, + "baselineInputTokens": 20000, + "turnCount": 1, + "sessionAgeMinutes": 0, + "source": "project_provider_model", + "provider": "cursor", + "model": "tool-reported-model", + "project": "sample-web", + "provenance": "estimated", + "reason": "Only one active-session record is available, so Tokmeter cannot see much session growth yet." + }, + "projectContextToday": [ + { + "project": "sample-web", + "cacheHitRate": 0.8823529411764706, + "missRate": 0.11764705882352941, + "freshInputShare": 0.11764705882352941, + "cacheWriteShare": 0, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "inputTokens": 40000, + "freshInputTokens": 40000, + "totalInputTokens": 340000, + "contextStatus": "low", + "dragShare": 0, + "dragTokens": 0, + "turnCount": 1, + "lastUsed": 1788688795000 + }, + { + "project": "sample-api", + "cacheHitRate": 0.967741935483871, + "missRate": 0.03225806451612903, + "freshInputShare": 0.03225806451612903, + "cacheWriteShare": 0, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "inputTokens": 20000, + "freshInputTokens": 20000, + "totalInputTokens": 620000, + "contextStatus": "critical", + "dragShare": 0, + "dragTokens": 0, + "turnCount": 1, + "lastUsed": 1788688740000 + } + ], + "pace": { + "multiple": null, + "typicalCostByNow": 0, + "actualCostByNow": 2.4499999999999997, + "daysOfHistory": 0 + }, + "compactionToday": { + "cost": 0, + "tokens": 0, + "share": 0, + "events": 0 + }, + "subagentToday": { + "cost": 0, + "records": 0, + "share": 0 + }, + "reasoningToday": { + "tokens": 1000, + "outputTokens": 40000, + "share": 0.025, + "records": 1 + }, + "toolCallsToday": { + "byTool": [], + "totalCost": 0, + "callCount": 0, + "turnsWithTools": 0 + }, + "billingWindow": { + "blockNumber": 1, + "blockStart": 1788688770000, + "blockEnd": 1788706770000, + "remainingSec": 17970, + "elapsedPct": 0.16666666666666669, + "cost": 1, + "tokens": 350000, + "records": 1 + }, + "liveSession": { + "provider": "cursor", + "model": "tool-reported-model", + "project": "sample-web", + "ageSeconds": 5, + "lastRecordCost": 0.4 + } + }, + "models": [ + { + "provider": "codex", + "model": "gpt-6-astra", + "totalTokens": 625000, + "cost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "percentageOfTotal": 42.85714285714287 + }, + { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "totalTokens": 350000, + "cost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 40.81632653061225 + }, + { + "provider": "cursor", + "model": "tool-reported-model", + "totalTokens": 25000, + "cost": 0.4, + "inputTokens": 20000, + "outputTokens": 5000, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 16.3265306122449 + } + ], + "projects": [ + { + "project": "sample-api", + "totalTokens": 625000, + "totalCost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "models": [ + { + "provider": "codex", + "model": "gpt-6-astra", + "totalTokens": 625000, + "cost": 1.05, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "percentageOfTotal": 42.85714285714287 + } + ], + "providers": [ + { + "provider": "codex", + "totalTokens": 625000, + "cost": 1.05, + "models": ["gpt-6-astra"], + "percentageOfTotal": 42.85714285714287 + } + ], + "dailyBreakdown": [ + { + "date": "2026-09-06", + "totalTokens": 625000, + "inputTokens": 20000, + "outputTokens": 4000, + "cacheReadTokens": 600000, + "cacheWriteTokens": 0, + "reasoningTokens": 1000, + "cost": 1.05, + "records": 1 + } + ], + "activeDays": 1, + "firstUsed": 1788688740000, + "lastUsed": 1788688740000 + }, + { + "project": "sample-web", + "totalTokens": 375000, + "totalCost": 1.4, + "inputTokens": 40000, + "outputTokens": 35000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "models": [ + { + "provider": "claude-code", + "model": "claude-sonnet-4-6", + "totalTokens": 350000, + "cost": 1, + "inputTokens": 20000, + "outputTokens": 30000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 40.81632653061225 + }, + { + "provider": "cursor", + "model": "tool-reported-model", + "totalTokens": 25000, + "cost": 0.4, + "inputTokens": 20000, + "outputTokens": 5000, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "percentageOfTotal": 16.3265306122449 + } + ], + "providers": [ + { + "provider": "claude-code", + "totalTokens": 350000, + "cost": 1, + "models": ["claude-sonnet-4-6"], + "percentageOfTotal": 40.81632653061225 + }, + { + "provider": "cursor", + "totalTokens": 25000, + "cost": 0.4, + "models": ["tool-reported-model"], + "percentageOfTotal": 16.3265306122449 + } + ], + "dailyBreakdown": [ + { + "date": "2026-09-06", + "totalTokens": 375000, + "inputTokens": 40000, + "outputTokens": 35000, + "cacheReadTokens": 300000, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 1.4, + "records": 2 + } + ], + "activeDays": 1, + "firstUsed": 1788688770000, + "lastUsed": 1788688795000 + } + ] + } +] diff --git a/docs/assets/demo/tokmeter-demo.mp4 b/docs/assets/demo/tokmeter-demo.mp4 new file mode 100644 index 0000000..5d062ac Binary files /dev/null and b/docs/assets/demo/tokmeter-demo.mp4 differ diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..abf394e --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,28 @@ +# Integration coverage and verification + +Checked for the 1.10.0 release candidate on 2026-09-06. An implemented parser is not a promise that every current version of that tool is supported. + +**Live checked** means local numeric usage was inspected during this release work. **Fixture tested** means the parser is exercised using controlled input. Neither means invoice reconciliation, cross-platform testing, or an exhaustive audit of every historical session. + +| Integration | Evidence for this candidate | Scope and limits | +|---|---|---| +| Codex CLI | Live checked + fixtures | New response receipts and legacy cumulative counters; real receipt totals reconciled. Replays and mixed formats covered. | +| Codex Desktop / VS Code | Live checked for response receipts; SQLite fallback fixture tested | New receipts use the granular parser. Opaque SQLite totals are baseline deltas with no invented cost; cannot reconstruct usage before observation. | +| Claude Code | Live numeric reconciliation + fixture coverage in scan/relay tests | Existing day totals preserved during a raw-data rebuild. No paid request or invoice check performed. | +| Cursor | Fixture tested | Local SQLite formats; a current live installation was not validated in this release. | +| Gemini CLI | Fixture tested | Local token buckets; current live installation not validated here. | +| Qwen | Fixture tested | Local usage only; no provider/model completion performed. | +| Roo Code | Fixture tested | Local usage and reported cost when exposed. | +| Zed | Fixture tested | Public-schema-based reader; not claimed as current live verification. | +| VS Code Copilot | Fixture tested | Activity/model metadata; tokens and cost may not be exposed. | +| Antigravity | Fixture tested | Local activity parsing; opaque data is not converted into invented tokens/cost. Optional live-credit path has separate tests, not live verification here. | +| OpenCode, Amp, Droid, OpenClaw, Pi, Kimi, Kilo, Kilo CLI, Mux, Synthetic | Implemented; not individually validated in this candidate | Generic aggregation tests do not establish current parser compatibility. Treat as provisional until a local numeric sample is checked. | + +## Keeping this table current + +1. Capture only structural event fields and numeric usage, with synthetic identifiers and project names. +2. Add a regression fixture for any newly observed format. The [current Codex fixture](../packages/core/src/parsers/fixtures/codex-response-usage.json) is an example. +3. Verify totals independently of the parser and test duplicate/replay behavior. +4. Record the observation date and what was actually checked. Do not promote fixture-only coverage to live verification because tests passed. + +The suite retains pre-existing TODO tests. A passing run is evidence for the tested behavior, not a blanket compatibility certificate. diff --git a/docs/how-the-numbers-work.md b/docs/how-the-numbers-work.md new file mode 100644 index 0000000..8c3f51c --- /dev/null +++ b/docs/how-the-numbers-work.md @@ -0,0 +1,33 @@ +# How Tokmeter's numbers work + +Tokmeter reads local usage records and keeps daily summaries. It does not inspect your subscription invoice or reconcile charges with a billing account. + +## Tokens + +Input, cached input, cache writes, visible output, and reasoning are separate ledger buckets where the source provides enough information. Codex includes cached input within input, and reasoning within output; Tokmeter separates those buckets before summing them. + +New Codex response receipts are preferred within a covered turn. Mirrored cumulative events are excluded, repeated response IDs are deduplicated, and a subagent's replay of a parent receipt is excluded. Older turns still use their cumulative counters. + +Some tools expose only an activity signal or a lifetime token total. Tokmeter does not invent an input/output breakdown to attach a dollar cost. See [coverage and verification](compatibility.md). + +## Costs + +| Display | What supports it | What it does not establish | +|---|---|---| +| Estimated API cost | Token buckets multiplied by catalog or local override rates | Your subscription payment or invoice | +| Tool-reported cost | A numeric cost exposed by the local tool | That the provider actually charged that amount | +| Unavailable | Missing rates, missing breakdown, skipped pricing, or absent provenance | That usage was free | + +An explicit zero from the tool or a known zero pricing rate is retained. Missing optional cache-read rates currently fall back to 10% of input; reasoning uses output rates unless a dedicated rate exists. These are estimation rules, not provider billing guarantees. Long-context tiers, service tiers, negotiated discounts, and other fees are not fully modeled by the current calculator. + +Tokmeter uses kosha's catalog and optional `~/.tokmeter/pricing-overrides.json` overrides. A gateway's rate can be used when a usable origin rate is absent. This is another reason to read the result as an estimate. + +Today exposes cost provenance in `/api/statbar-signals`: the estimated and reported amounts, unavailable count, and per-model breakdown. Model/project cost totals may combine different bases. Existing historical rollups do not contain enough provenance for a reliable retrospective split, so normal refreshes retain their original values. + +## History and corrections + +Completed days are stored in the local relay. Normal refreshes update today's usage and read saved history. A deliberate rescan can rebuild a bounded historical window; retention guards protect against replacing a day with incomplete input. Back up a day before applying a reviewed correction. + +## Reporting a mismatch + +Send the app/agent version, local date and timezone, model, expected token count, displayed token count, and whether the source is CLI or desktop. Start with those numeric facts. Do not upload a whole session transcript, database, API key, or credential file. diff --git a/docs/licensing.md b/docs/licensing.md new file mode 100644 index 0000000..58351ac --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,26 @@ +# Licenses and source + +Copyright (c) 2026 Srinivas Pendela and contributors. + +Tokmeter's applications (CLI, TUI, web dashboard, daemon/MCP server, and macOS app) are licensed under **AGPL-3.0-only**. The core library's source under `packages/core` is licensed under **MPL-2.0**. Bundling the core in the application does not remove its MPL source license. Other dependencies retain their own licenses and copyright notices. + +## Included materials + +The npm distributions include `dist/licenses/`. The macOS app includes `Contents/Resources/Licenses/`, accessible using **Licenses & source** in the popup footer. Each contains: + +- `AGPL-3.0-only.txt` — the application license. +- `MPL-2.0.txt` — the core source license, including when the core is bundled in `@sriinnu/tokmeter`. +- `tokmeter-source.tar.gz` — local source and build inputs collected when this artifact was packaged. The application and core source retain the licenses described above. +- In the macOS app, `Sparkle.txt` — the complete notices supplied with the bundled Sparkle artifact, including its embedded third-party components. + +JavaScript dependencies are installed separately by the package manager; their notices reside in their respective installed packages. The source snapshot includes the dependency manifests and lockfile. Build tools and platform SDKs are obtained separately. + +Extract the source archive, install Bun and Node.js, and run `bun install --frozen-lockfile` followed by `bun run build` from its root. For the native app, install Xcode on macOS and run `swift build -c release --package-path packages/macos-bar`. Use `bash packages/macos-bar/bundle.sh --no-install` for a local ad-hoc bundle. Apple distribution credentials are not needed for a local build and are never included. + +Upstream source: https://github.com/sriinnu/tokmeter. Public releases should point to the matching source tag; a local candidate may contain changes not yet published there. Rebuild artifacts after source changes so the enclosed source matches the binaries. + +## Release checks + +Run `bash scripts/prepare-packages.sh` after the workspace build. Both npm packages also prepare these materials in their `prepack` hook. The macOS bundle script adds them before signing. Verify the source archive and license files in the final artifacts, not only in the repository. + +These instructions preserve the project's existing license choices. They are not a contributor-ownership audit or a claim that every dependency version has received legal review. The relevant license texts govern; see the [GNU AGPL](https://www.gnu.org/licenses/agpl-3.0.html.en) and [Mozilla's MPL guidance](https://www.mozilla.org/en-US/MPL/2.0/FAQ/). diff --git a/docs/release/1.10.0.md b/docs/release/1.10.0.md new file mode 100644 index 0000000..d2a5641 --- /dev/null +++ b/docs/release/1.10.0.md @@ -0,0 +1,27 @@ +# Tokmeter 1.10.0 — release preparation + +Status: local release candidate. Public publishing, notarization, invitations, and user-trial results are not complete. + +## Release description draft + +Tokmeter now opens on today's usage: tokens first, estimated API cost and tool-reported amounts shown separately, followed by today's models and projects. Expand Usage details for historical totals and secondary gauges. + +This release also handles newer Codex response receipts, prevents duplicate accounting across mirrored telemetry and SQLite fallback, preserves explicit zero costs, and fixes the previous-active-day display on idle days. All models remain accessible through Show all. + +The README includes a walkthrough rendered from the actual macOS views using synthetic data, along with compatibility evidence and a guide to interpreting the numbers. + +Release packaging now includes the AGPL application license, MPL core license, and matching source archive. The macOS app also preserves Sparkle's complete notices and provides a **Licenses & source** control. + +## Verification record + +The final local test/build results are recorded in `validation.md` next to this file. They describe this candidate only. Older sealed days have not been globally re-audited or rewritten. + +## Public-release handoff + +- Review the source diff and prepare a signed commit; preserve unrelated workspace changes. +- Check the npm and macOS package contents and versions against the candidate. +- Developer ID signing, notarization, and Sparkle update verification are separate gates. The local ad-hoc build is not a substitute for them. +- Publish npm packages and the GitHub release only after explicit authorization. Verify both install paths from a clean machine before sending trial invitations. +- Use the [trial invitation draft](../trial/outreach-draft.md) and [roster](../trial/roster.md) after selecting recipients. No messages have been sent by this work. + +See the existing [macOS release process](../../packages/macos-bar/RELEASE.md) for signing and publishing commands. Do not run its combined ship command merely to perform a local build. diff --git a/docs/release/validation.md b/docs/release/validation.md new file mode 100644 index 0000000..880b069 --- /dev/null +++ b/docs/release/validation.md @@ -0,0 +1,42 @@ +# 1.10.0 local validation + +Checked on 2026-09-06 on Apple Silicon macOS. These results describe the uncommitted local candidate, not a published release or a signed source revision. + +| Check | Result | +|---|---| +| JavaScript tests | 360 passed; 11 existing TODOs; 35 test files passed and 2 skipped. | +| Swift tests | 13 passed, including idle-day handling and production-view demo rendering. | +| Repository lint | Biome checked 170 files successfully. | +| Workspace build | TypeScript packages and web build passed. Vite retains a roughly 5 MB minified bundle warning. | +| Script syntax and whitespace | Release/packaging shell syntax and `git diff --check` passed. | +| npm artifacts | Both 1.10.0 tarballs prepared locally; manifests contain no unresolved `workspace:` dependencies. | +| Isolated npm install | Both local tarballs installed into a fresh temporary directory with an isolated npm cache and `--ignore-scripts`. This is not a clean-machine installer test. | +| Packaged entry points | Tokmeter and Drishti help commands passed under Node 26.0.0. The packaged Codex parser produced exactly one Astra record and matched all five expected fixture fields. | +| Secret guard | Repository guard passed, including npm pack surfaces. Its ZIP check covered the existing 1.9.2 archive; no 1.10.0 release ZIP was produced. | +| Installed app | `/Applications/TokmeterBar.app` reports 1.10.0, build 46; deep/strict code-signature verification passed for the local ad-hoc build. | +| Live runtime | Installed app process and restarted source-backed daemon observed. HTTP readiness was true; live cost-basis fields and today's project endpoint returned data. This was a short functional check, not a soak. | +| Demo | Four production-view renders visually inspected; a 20-second synthetic-data MP4 generated. No customer session content used. | + +The accounting work reconciled newer Codex receipts and repaired the specifically inspected September 5 totals with a backup. This does not establish correctness of every older sealed day. See [compatibility](../compatibility.md) for per-integration evidence and [number semantics](../how-the-numbers-work.md) for accounting limits. + +## Remaining release and trial gates + +- Review and signed source commit; changes remain local and uncommitted. +- Developer ID signing, notarization, Sparkle update validation, and a 1.10.0 release archive. +- Public npm/GitHub publishing and verification of published downloads on a clean machine. +- Testing on other Node versions, Intel macOS, and other operating systems. +- Five named participants, invitations, a week of usage, and real feedback. The [trial kit](../trial/guide.md) is prepared; no invitations were sent and no trial results are claimed. + +No provider/model completion calls were used for validation. + +## License-packaging follow-up + +The original candidates omitted the application/core license texts, and the app omitted Sparkle's notices. The packaging scripts now include them alongside a local source archive; the app exposes a **Licenses & source** footer control. Existing AGPL-3.0-only application and MPL-2.0 core licensing is unchanged. + +- Repacked both npm candidates into `/tmp/tokmeter-license-candidate`; verified both license texts against the repository and the enclosed source against local files. +- Extracted the source into a temporary directory, installed dependencies using the frozen lockfile, and built the full workspace successfully. The existing Vite bundle-size warning remains. +- Rebuilt and installed 1.10.0 (46); verified its signature and exact AGPL, MPL, and Sparkle notice contents. The installed source snapshot matched 253 source/build files, excluding personal notes and credentials. +- Native follow-up: 12 tests passed, with the optional demo-render test skipped; lint and whitespace checks passed. +- The isolated npm installation's package license declarations were MIT, MIT OR CC0-1.0, ISC, BSD-2-Clause, BSD-3-Clause, or AGPL-3.0-only. No non-private installed package lacked a declaration. This is a metadata inventory, not a source-ownership or exhaustive dependency legal audit. + +See [licenses and source](../licensing.md). Signing a source commit and public publishing remain pending. diff --git a/docs/trial/feedback.md b/docs/trial/feedback.md new file mode 100644 index 0000000..53d711c --- /dev/null +++ b/docs/trial/feedback.md @@ -0,0 +1,31 @@ +# Tokmeter trial feedback + +Tester alias: +App build / daemon version: +macOS version: +Agents used and versions: +Trial dates / timezone: + +## First run + +- Did installation finish? If not, which step stopped you? +- What did you think “Estimated API cost” meant? +- Could you find your latest model and project? +- Roughly how long until the first useful answer? + +## Actual use + +- On which days did you open Tokmeter without a reminder? +- What question brought you back most recently? +- What did you do after seeing the answer? +- Would you keep it installed? Why? + +## Trust and friction + +- Which number, if any, did you distrust? +- Expected / displayed value, date/timezone, agent, model: +- What was confusing or unnecessarily busy? +- What would you remove? +- One change that would make it more useful: + +Share numeric details first. No transcripts, raw databases, tokens, or credentials. diff --git a/docs/trial/guide.md b/docs/trial/guide.md new file mode 100644 index 0000000..3148238 --- /dev/null +++ b/docs/trial/guide.md @@ -0,0 +1,31 @@ +# A one-week Tokmeter trial + +For five macOS developers who use both Claude Code and Codex. The goal is to learn whether the numbers are understandable, reliable, and useful enough to revisit. + +This is a trial plan, not a claim that five people have been recruited or that the candidate is publicly released. Use a signed public build once release checks are complete, or clearly identify a local development build when testing it yourself. + +## Before installing + +You need macOS 14+, Node.js 18+, and some local Claude Code or Codex usage. Tokmeter reads usage records locally; public pricing data may be fetched. It is not a subscription billing dashboard. See [how the numbers work](../how-the-numbers-work.md). + +Follow the [README install steps](../../README.md#keep-it-in-your-macos-menu-bar). Record the exact app version and daemon package version in your feedback. + +## First ten minutes + +1. Open Tokmeter and explain, in your own words, what the main number and the dollar amounts mean. +2. Find the model and project you most recently used. Switch Today/All and try Show all when available. +3. Do normal coding work in Claude Code and Codex. Check that fresh usage appears after the next refresh. You do not need to run extra paid requests just for this trial. +4. If there is no activity today, check that the app says so instead of displaying yesterday as today. +5. Open Usage details only if you want more context. Note anything you expected to find sooner. + +## During the week + +Use it when you naturally need to check usage. Do not set reminders to open it for the study. Jot down what question brought you back and whether the app answered it. + +If a number looks wrong, record the date/timezone, agent and app versions, model, expected count, and observed count. Avoid sharing transcripts, raw databases, account identities, or credentials. + +## At the end + +Copy the [feedback template](feedback.md). The most useful answers are concrete: where you got stuck, which number you distrusted, and the last time you opened it without a reminder. + +A missing feature is useful feedback. So is deciding you do not need a separate usage app. diff --git a/docs/trial/outreach-draft.md b/docs/trial/outreach-draft.md new file mode 100644 index 0000000..0d1b0b7 --- /dev/null +++ b/docs/trial/outreach-draft.md @@ -0,0 +1,13 @@ +# Invitation draft — not sent + +Hey — I’m testing Tokmeter with a few Mac developers who use both Claude Code and Codex. + +It shows where local AI coding usage goes by project and model. It separates API-cost estimates from tool-reported amounts and keeps daily history on your machine. + +Would you try it during your normal work for a week? I’d like honest feedback on installation, whether the numbers make sense, and whether it’s useful enough to reopen. No special paid requests or transcript sharing needed. + +I’ll send the verified build and a short guide when it’s ready for the trial. + +--- + +Maintainer handoff: replace the final sentence with the verified release link after signing/release checks. Send individually to willing participants. Do not send the local ad-hoc candidate as though it were a notarized public release. diff --git a/docs/trial/roster.md b/docs/trial/roster.md new file mode 100644 index 0000000..3918fd6 --- /dev/null +++ b/docs/trial/roster.md @@ -0,0 +1,15 @@ +# Trial coordination + +Target: five macOS developers using Claude Code and Codex. No invitations have been sent and no participants are enrolled yet. + +| Alias | Invitation | Installed | First useful answer | Unprompted returns | Trust issue | Keep installed? | +|---|---|---|---|---|---|---| +| Tester 1 | Not sent | — | — | — | — | — | +| Tester 2 | Not sent | — | — | — | — | — | +| Tester 3 | Not sent | — | — | — | — | — | +| Tester 4 | Not sent | — | — | — | — | — | +| Tester 5 | Not sent | — | — | — | — | — | + +Use aliases here. Keep contact details out of the repository. + +After the week, group observations into installation friction, incorrect or unclear numbers, and useful repeat tasks. Choose the next change from those observations. Stars and clone counts do not substitute for actual use; five participants are qualitative feedback, not a statistically representative sample. diff --git a/packages/cli/package.json b/packages/cli/package.json index 40e7127..cd3d861 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-cli", - "version": "1.9.2", + "version": "1.10.0", "private": true, "description": "Token usage tracking CLI and automation helpers", "type": "module", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a4709ec..4983902 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -461,7 +461,7 @@ function renderStats(stats: ReturnType) { table.push( ["Total Tokens", formatNumber(stats.totalTokens)], - ["Total Cost", formatCost(stats.totalCost)], + ["Cost total (not a bill)", formatCost(stats.totalCost)], ["Input Tokens", formatNumber(stats.inputTokens)], ["Output Tokens", formatNumber(stats.outputTokens)], ["Cache Read", formatNumber(stats.cacheReadTokens)], @@ -702,7 +702,9 @@ async function runRoutes(options: { `${totals.cacheWrite.toLocaleString()} cacheWrite · ` + `${totals.reasoning.toLocaleString()} reasoning` ); - console.log(`Actual cost (historical pricing): $${actualCost.toFixed(2)}`); + console.log( + `Recorded cost total (historical estimates and tool reports): $${actualCost.toFixed(2)}` + ); console.log(""); console.log("Projected cost on today's kosha (sorted, cheapest first):"); console.log(""); diff --git a/packages/cli/src/digest.ts b/packages/cli/src/digest.ts index 652ebec..45a80f2 100644 --- a/packages/cli/src/digest.ts +++ b/packages/cli/src/digest.ts @@ -206,7 +206,10 @@ function renderDigest( console.log(chalk.cyan(`\u255A${border}\u255D`)); console.log(""); - console.log(` ${chalk.dim("Total Spend:")} ${chalk.white.bold(formatCost(totalCost))}`); + console.log( + chalk.dim(" Costs combine API-rate estimates and tool reports; not a subscription bill.") + ); + console.log(` ${chalk.dim("Cost Total:")} ${chalk.white.bold(formatCost(totalCost))}`); if (prevRecords.length > 0) { const pctChange = prevTotalCost > 0 ? ((totalCost - prevTotalCost) / prevTotalCost) * 100 : 0; console.log( @@ -337,7 +340,7 @@ function renderDigest( if (expensiveCost > 0 && cheapCost > 0) { tips.push( - `You spent ${chalk.yellow(formatCost(expensiveCost))} on premium models (Opus/GPT-4). Consider using Sonnet/Haiku/Flash for routine tasks.` + `Your usage has a ${chalk.yellow(formatCost(expensiveCost))} cost total on premium models (Opus/GPT-4). Consider using Sonnet/Haiku/Flash for routine tasks.` ); } else if (expensiveCost > 0 && cheapCost === 0) { tips.push( diff --git a/packages/core/package.json b/packages/core/package.json index 65a174e..8c8d79c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-core", - "version": "1.9.2", + "version": "1.10.0", "private": true, "description": "Token usage tracking core — session parsers, aggregation, and pricing", "type": "module", diff --git a/packages/core/src/cost-basis.test.ts b/packages/core/src/cost-basis.test.ts new file mode 100644 index 0000000..11036d8 --- /dev/null +++ b/packages/core/src/cost-basis.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { modelCostBasis, summarizeCostBasis } from "./cost-basis.js"; +import { createRecord } from "./parsers/utils.js"; + +describe("cost provenance", () => { + it("keeps the same model's reported and estimated costs separate across tools", () => { + const records = [ + createRecord({ timestamp: 0, provider: "codex", model: "shared", cost: 1 }), + createRecord({ + timestamp: 0, + provider: "cursor", + model: "shared", + cost: 2, + usage: { cost: "direct" }, + }), + ]; + const grouped = modelCostBasis(records); + expect(grouped["codex::shared"].estimatedCost).toBe(1); + expect(grouped["cursor::shared"].reportedCost).toBe(2); + }); + it("separates estimates, tool reports, and unavailable costs without inventing charges", () => { + const record = (cost: number, basis: "calculated" | "direct" | "not_exposed") => + createRecord({ + timestamp: 0, + provider: "codex", + model: "demo", + inputTokens: 100, + cost, + usage: { cost: basis }, + }); + expect( + summarizeCostBasis([ + record(1.25, "calculated"), + record(0.5, "direct"), + record(0, "direct"), + record(0, "not_exposed"), + { ...record(0, "calculated"), costEligible: false }, + { ...record(0.75, "not_exposed"), usage: undefined }, + ]) + ).toEqual({ + estimatedCost: 1.25, + reportedCost: 0.5, + unclassifiedCost: 0.75, + estimatedRecords: 1, + reportedRecords: 2, + unavailableRecords: 3, + }); + }); + + it("keeps an intentionally free estimate distinct from missing data", () => { + const free = createRecord({ + timestamp: 0, + provider: "codex", + model: "free", + usage: { cost: "calculated" }, + }); + expect(summarizeCostBasis([free])).toMatchObject({ + estimatedCost: 0, + estimatedRecords: 1, + unavailableRecords: 0, + }); + }); +}); diff --git a/packages/core/src/cost-basis.ts b/packages/core/src/cost-basis.ts new file mode 100644 index 0000000..2cb597a --- /dev/null +++ b/packages/core/src/cost-basis.ts @@ -0,0 +1,40 @@ +import type { CostBasis, TokenRecord } from "./types.js"; + +/** Summarize what the ledger actually knows; a tool's cost is not an invoice. */ +export function summarizeCostBasis(records: TokenRecord[]): CostBasis { + const result: CostBasis = { + estimatedCost: 0, + reportedCost: 0, + unclassifiedCost: 0, + estimatedRecords: 0, + reportedRecords: 0, + unavailableRecords: 0, + }; + for (const record of records) { + const cost = Number.isFinite(record.cost) ? Math.max(0, record.cost) : 0; + if (record.costEligible === false) { + result.unavailableRecords++; + } else if (record.usage?.cost === "direct") { + result.reportedCost += cost; + result.reportedRecords++; + } else if (record.usage?.cost === "calculated" || record.usage?.cost === "estimated") { + result.estimatedCost += cost; + result.estimatedRecords++; + } else { + result.unclassifiedCost += cost; + result.unavailableRecords++; + } + } + return result; +} + +export function modelCostBasis(records: TokenRecord[]): Record { + const groups = new Map(); + for (const record of records) { + const key = `${record.provider}::${record.model}`; + const group = groups.get(key) ?? []; + group.push(record); + groups.set(key, group); + } + return Object.fromEntries([...groups].map(([key, group]) => [key, summarizeCostBasis(group)])); +} diff --git a/packages/core/src/parsers/codex-desktop.test.ts b/packages/core/src/parsers/codex-desktop.test.ts index a66814c..a858b3a 100644 --- a/packages/core/src/parsers/codex-desktop.test.ts +++ b/packages/core/src/parsers/codex-desktop.test.ts @@ -1,8 +1,8 @@ /** * Codex SQLite-state fallback parser regression tests. * - * Codex Desktop / VS Code-extension sessions never emit token_count events - * in their rollout JSONL, but every Codex thread (CLI included) gets a row + * Older Codex Desktop / VS Code sessions lack granular usage events in + * their rollout JSONL, but every Codex thread (CLI included) gets a row * in local state_5.sqlite with a real cumulative tokens_used total — this * parser fills exactly the gap CodexParser's JSONL-only reading leaves. * @@ -30,7 +30,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { localDateKey } from "../date-utils.js"; -import { CodexDesktopParser } from "./codex-desktop.js"; +import { CodexDesktopParser, hasJsonlTokenCoverage } from "./codex-desktop.js"; import { CodexParser } from "./codex.js"; let tmpDir: string; @@ -240,6 +240,81 @@ describe("CodexDesktopParser (SQLite state fallback)", () => { expect(cliRecords[0].provider).toBe("codex"); }); + it("skips a growing SQLite thread covered by new response receipts", async () => { + const rolloutPath = writeRollout("rollout-astra.jsonl"); + seedStateDb([ + { + id: "thread-astra", + tokensUsed: 1000, + model: "gpt-6-astra", + cwd: "/tmp/demo", + rolloutPath, + updatedAtSec: nowSec(), + }, + ]); + const parser = new CodexDesktopParser(); + await parser.scan(tmpDir); + updateTokensUsed("thread-astra", 5000); + writeFileSync( + rolloutPath, + `${JSON.stringify({ + type: "token_usage_record", + payload: { + thread_id: "thread-astra", + usage: { input_tokens: 3500, output_tokens: 500 }, + }, + })}\n` + ); + expect(await parser.scan(tmpDir)).toEqual([]); + }); + + it.each(["token_count", "token_usage_record"])( + "finds %s coverage buried before a large tool output", + async (type) => { + const rolloutPath = join(tmpDir, "buried.jsonl"); + const event = + type === "token_count" + ? { + type: "event_msg", + payload: { + type, + info: { total_token_usage: { input_tokens: 100, output_tokens: 10 } }, + }, + } + : { + type, + payload: { thread_id: "own", usage: { input_tokens: 100, output_tokens: 10 } }, + }; + writeFileSync( + rolloutPath, + `${JSON.stringify(event)}\n${JSON.stringify({ type: "response_item", payload: { text: "x".repeat(100_000) } })}\n` + ); + expect(await hasJsonlTokenCoverage(rolloutPath, "own")).toBe(true); + // A rewrite invalidates cached coverage. + writeFileSync( + rolloutPath, + `${JSON.stringify({ type: "token_usage_record", payload: { usage: {} } })}\n` + ); + expect(await hasJsonlTokenCoverage(rolloutPath, "own")).toBe(false); + } + ); + + it("does not treat a parent's replayed receipts as coverage for the child", async () => { + const rolloutPath = join(tmpDir, "parent-only.jsonl"); + writeFileSync( + rolloutPath, + `${JSON.stringify({ + type: "token_usage_record", + payload: { + thread_id: "parent", + usage: { input_tokens: 100, output_tokens: 10 }, + }, + })}\n` + ); + expect(await hasJsonlTokenCoverage(rolloutPath, "child")).toBe(false); + expect(await hasJsonlTokenCoverage(rolloutPath, "parent")).toBe(true); + }); + it("does not mistake a literal 'token_count' substring in session text for a real event", async () => { // A Desktop session can legitimately read/write source referencing the // literal string "token_count" (e.g. a coding session touching this diff --git a/packages/core/src/parsers/codex-desktop.ts b/packages/core/src/parsers/codex-desktop.ts index 587625b..adc1d5a 100644 --- a/packages/core/src/parsers/codex-desktop.ts +++ b/packages/core/src/parsers/codex-desktop.ts @@ -16,8 +16,9 @@ * granular per-turn input/output/cache/reasoning breakdowns from JSONL * token_count events, which is strictly better data where it exists (real * CLI sessions always have it). This parser is a pure fallback for threads - * whose rollout JSONL has NO token_count events at all — Codex Desktop / - * VS Code-extension-sourced threads, confirmed to never emit them locally. + * whose rollout JSONL has neither token_count nor token_usage_record + * telemetry. Newer Desktop / VS Code sessions expose per-response receipts + * and are handled by CodexParser. * A thread already covered by CodexParser is explicitly skipped here so a * session can never be double-counted under both provider ids. * @@ -35,12 +36,14 @@ * the same delta-tracking shape as antigravity-live.ts's credit deltas. */ +import { createReadStream } from "node:fs"; import { open, stat } from "node:fs/promises"; +import { createInterface } from "node:readline"; import { localDateKey } from "../date-utils.js"; import { canonicalizeProjectName } from "../project-name.js"; import type { SessionParser, TokenRecord } from "../types.js"; import { readCheckpoints, writeCheckpoints } from "./codex-sqlite-checkpoint.js"; -import { codexHomeDir } from "./codex.js"; +import { codexHomeDir, isCodexTokenUsage } from "./codex.js"; import { type ReadonlySqlite, createRecord, @@ -87,15 +90,31 @@ async function openStateDb(homeDir: string): Promise { interface CodexEventShape { type?: string; - payload?: { type?: string }; + payload?: { type?: string; usage?: unknown; thread_id?: string }; +} + +const coverageCache = new Map(); + +function isTokenCoverage(line: string, threadId?: string): boolean { + try { + const evt = JSON.parse(line) as CodexEventShape; + return ( + (evt.type === "event_msg" && evt.payload?.type === "token_count") || + (evt.type === "token_usage_record" && + isCodexTokenUsage(evt.payload?.usage) && + (!threadId || !evt.payload?.thread_id || evt.payload.thread_id === threadId)) + ); + } catch { + return false; + } } /** * True if this rollout file already carries at least one real token_count - * event — meaning CodexParser already covers it with granular data and this - * fallback must stay out of the way. A tail read is enough: an actively - * logging CLI session writes token_count events steadily, so one appears in - * the last 64 KB whenever the file genuinely has them. + * or token_usage_record event — meaning CodexParser already covers it with + * granular data and this fallback must stay out of the way. Try the tail + * first, then stream the file: a long tool output can bury the last receipt + * beyond 64 KB. Cache unchanged files to keep repeated polls cheap. * * Parses each tail line as JSON and checks the STRUCTURED * `payload.type === "token_count"` field, not a raw substring match — a @@ -105,10 +124,21 @@ interface CodexEventShape { * that substring and get permanently, silently excluded from this * fallback with no other source ever covering it. */ -async function hasJsonlTokenCoverage(rolloutPath: string): Promise { +export async function hasJsonlTokenCoverage( + rolloutPath: string, + threadId?: string +): Promise { try { const st = await stat(rolloutPath); if (st.size === 0) return false; + const cacheKey = JSON.stringify([rolloutPath, threadId]); + const cached = coverageCache.get(cacheKey); + if (cached?.size === st.size && cached.mtimeMs === st.mtimeMs) return cached.covered; + const remember = (covered: boolean) => { + if (coverageCache.size >= 2048) coverageCache.clear(); + coverageCache.set(cacheKey, { size: st.size, mtimeMs: st.mtimeMs, covered }); + return covered; + }; const fd = await open(rolloutPath, "r"); try { const tail = Math.min(TAIL_CHECK_BYTES, st.size); @@ -117,17 +147,24 @@ async function hasJsonlTokenCoverage(rolloutPath: string): Promise { const lines = buf.toString("utf-8").split("\n"); for (const line of lines) { if (!line.trim()) continue; - try { - const evt = JSON.parse(line) as CodexEventShape; - if (evt.type === "event_msg" && evt.payload?.type === "token_count") return true; - } catch { - // partial line (tail read can start mid-record) — skip - } + if (isTokenCoverage(line, threadId)) return remember(true); } - return false; } finally { await fd.close(); } + if (st.size > TAIL_CHECK_BYTES) { + const stream = createReadStream(rolloutPath, { encoding: "utf-8" }); + const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY }); + try { + for await (const line of lines) { + if (isTokenCoverage(line, threadId)) return remember(true); + } + } finally { + lines.close(); + stream.destroy(); + } + } + return remember(false); } catch { // Missing/unreadable rollout file — nothing for CodexParser to have // covered, so this fallback should still consider the thread. @@ -164,7 +201,7 @@ export class CodexDesktopParser implements SessionParser { // total — prunes the (large, mostly historical) thread table down // to today's handful before the pricier per-file coverage check runs. if (localDateKey(row.updated_at * 1000) !== today) continue; - if (!row.rollout_path || (await hasJsonlTokenCoverage(row.rollout_path))) continue; + if (!row.rollout_path || (await hasJsonlTokenCoverage(row.rollout_path, row.id))) continue; const existing = checkpoints[row.id]; if (!existing || existing.baselineDate !== today) { diff --git a/packages/core/src/parsers/codex.test.ts b/packages/core/src/parsers/codex.test.ts index 4726789..50e436e 100644 --- a/packages/core/src/parsers/codex.test.ts +++ b/packages/core/src/parsers/codex.test.ts @@ -10,12 +10,141 @@ * These tests pin that contract to a fixture so we never regress. */ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { CodexParser, parseCodexFile } from "./codex.js"; +describe("Codex per-response usage receipts", () => { + it("reconciles the documented current-format compatibility fixture", async () => { + const fixture = JSON.parse( + readFileSync(new URL("./fixtures/codex-response-usage.json", import.meta.url), "utf8") + ); + const file = join(tmpDir, "current-format.jsonl"); + writeFileSync(file, fixture.events.map((event: unknown) => JSON.stringify(event)).join("\n")); + const records = await parseCodexFile(file, 1000); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject(fixture.expected); + }); + const usage = { + input_tokens: 1000, + cached_input_tokens: 800, + output_tokens: 100, + reasoning_output_tokens: 40, + total_tokens: 1100, + }; + const context = (turn = "turn-1", model = "gpt-6-astra") => ({ + type: "turn_context", + payload: { turn_id: turn, model }, + }); + const receipt = (response = "response-1", turn = "turn-1") => ({ + timestamp: "2026-09-05T12:00:00Z", + type: "token_usage_record", + payload: { + thread_id: "own-thread", + turn_id: turn, + response_id: response, + usage, + thread_token_usage: { ...usage, input_tokens: 101000, total_tokens: 101100 }, + }, + }); + const legacy = (total = usage) => ({ + timestamp: "2026-09-05T12:00:01Z", + type: "event_msg", + payload: { type: "token_count", info: { total_token_usage: total } }, + }); + async function parse(events: unknown[], streamed = false) { + const file = join(tmpDir, "receipts.jsonl"); + writeFileSync( + file, + [{ type: "session_meta", payload: { id: "own-thread", cwd: "/tmp/demo" } }, ...events] + .map((e) => JSON.stringify(e)) + .join("\n") + ); + return parseCodexFile(file, streamed ? 8_000_000 : 1000); + } + + it.each([false, true])( + "reads Astra receipts without booking historical thread totals (streamed=%s)", + async (streamed) => { + const records = await parse([context(), receipt()], streamed); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + model: "gpt-6-astra", + inputTokens: 200, + cacheReadTokens: 800, + outputTokens: 60, + reasoningTokens: 40, + }); + } + ); + + it.each([false, true])( + "counts mirrored legacy and structured telemetry once (legacy first=%s)", + async (legacyFirst) => { + const events = legacyFirst ? [legacy(), receipt()] : [receipt(), legacy()]; + const records = await parse([context(), ...events, receipt()]); + expect(records).toHaveLength(1); + expect( + records[0].inputTokens + + records[0].cacheReadTokens + + records[0].outputTokens + + records[0].reasoningTokens + ).toBe(1100); + } + ); + + it("retains old-format turns and their models across a session upgrade", async () => { + const records = await parse([context("old", "gpt-5.6-sol"), legacy(), context(), receipt()]); + expect(records.map((r) => r.model)).toEqual(["gpt-5.6-sol", "gpt-6-astra"]); + }); + + it("retains the cumulative baseline when a later turn uses only legacy events", async () => { + const r = receipt(); + r.payload.thread_token_usage = usage; + const records = await parse([ + context(), + r, + context("later"), + legacy({ + ...usage, + input_tokens: 2000, + cached_input_tokens: 1600, + output_tokens: 200, + reasoning_output_tokens: 80, + total_tokens: 2200, + }), + ]); + expect(records).toHaveLength(2); + expect(records[1]).toMatchObject({ + inputTokens: 200, + cacheReadTokens: 800, + outputTokens: 60, + reasoningTokens: 40, + }); + }); + + it("ignores parent receipts replayed in a child rollout", async () => { + const parent = receipt(); + parent.payload.thread_id = "parent"; + const records = await parse([context(), parent, receipt()]); + expect(records).toHaveLength(1); + }); + + it("does not let malformed new telemetry suppress valid legacy accounting", async () => { + const malformed = { + ...receipt(), + payload: { ...receipt().payload, usage: { input_tokens: "1000", output_tokens: 100 } }, + }; + expect(await parse([context(), legacy(), malformed])).toHaveLength(1); + }); + + it("labels missing model metadata honestly", async () => { + expect((await parse([receipt()]))[0].model).toBe("unknown"); + }); +}); + let tmpDir: string; beforeEach(() => { diff --git a/packages/core/src/parsers/codex.ts b/packages/core/src/parsers/codex.ts index 0d68cbd..b8d2953 100644 --- a/packages/core/src/parsers/codex.ts +++ b/packages/core/src/parsers/codex.ts @@ -5,7 +5,8 @@ * Sessions are stored in YYYY/MM/DD/ subdirectories as .jsonl files. * * Format: RolloutItem events (session_meta, turn_context, event_msg, etc.) - * Token data comes from event_msg with type "token_count". + * Token data comes from token_usage_record (per response), falling back to + * event_msg/token_count cumulative counters for older turns. * * ─── Fork dedup ───────────────────────────────────────────────────────── * Codex writes a separate rollout file for every `codex resume` or branched @@ -91,6 +92,22 @@ interface CodexTokenUsage { total_tokens?: number; } +/** Only complete numeric receipts may supersede the legacy turn counters. */ +export function isCodexTokenUsage(value: unknown): value is CodexTokenUsage { + if (!value || typeof value !== "object") return false; + const usage = value as Record; + return ( + ["input_tokens", "output_tokens"].every( + (key) => typeof usage[key] === "number" && Number.isFinite(usage[key]) && usage[key] >= 0 + ) && + ["cached_input_tokens", "reasoning_output_tokens"].every( + (key) => + usage[key] === undefined || + (typeof usage[key] === "number" && Number.isFinite(usage[key]) && usage[key] >= 0) + ) + ); +} + interface CodexTokenCountInfo { total_token_usage?: CodexTokenUsage; last_token_usage?: CodexTokenUsage; @@ -103,6 +120,11 @@ interface CodexEvent { payload?: { type?: string; info?: CodexTokenCountInfo; + usage?: CodexTokenUsage; + thread_token_usage?: CodexTokenUsage; + thread_id?: string; + turn_id?: string; + response_id?: string; // session_meta fields (payload when type is "session_meta") id?: string; forked_from_id?: string; @@ -132,14 +154,23 @@ interface CodexParseState { project: string; cwd: string; prevTotal: CodexTokenUsage; + sessionId?: string; + currentTurn: string; + structuredTurns: Set; + responseIds: Set; + legacyRecordTurns: Map; } function defaultState(): CodexParseState { return { - currentModel: "gpt-4o", + currentModel: "unknown", project: "codex", cwd: "", prevTotal: {}, + currentTurn: "unscoped", + structuredTurns: new Set(), + responseIds: new Set(), + legacyRecordTurns: new Map(), }; } @@ -385,6 +416,9 @@ function foldCodexEvent( out: TokenRecord[] ): void { if (!evt.type) return; + if (evt.type === "session_meta" && evt.payload?.id && !state.sessionId) { + state.sessionId = evt.payload.id; + } if (evt.type === "session_meta" && evt.payload?.cwd) { state.project = projectFromCwd(evt.payload.cwd); state.cwd = evt.payload.cwd; @@ -392,26 +426,44 @@ function foldCodexEvent( if (evt.type === "turn_context" && evt.payload?.model) { state.currentModel = evt.payload.model; } - if (evt.type !== "event_msg") return; const payload = evt.payload; - if (!payload || payload.type !== "token_count") return; - const info = payload.info; - if (!info) return; + if (!payload) return; + if ( + evt.type === "turn_context" || + (evt.type === "event_msg" && payload.type === "task_started") + ) { + if (payload.turn_id) state.currentTurn = payload.turn_id; + } let usage: CodexTokenUsage; - if (info.total_token_usage) { - usage = computeDelta(info.total_token_usage, state.prevTotal); - state.prevTotal = { ...info.total_token_usage }; - const deltaSum = - (usage.input_tokens ?? 0) + - (usage.output_tokens ?? 0) + - (usage.cached_input_tokens ?? 0) + - (usage.reasoning_output_tokens ?? 0); - if (deltaSum === 0) return; - } else if (info.last_token_usage) { - usage = info.last_token_usage; + const structured = evt.type === "token_usage_record"; + if (structured) { + if (!isCodexTokenUsage(payload.usage)) return; + // Forked subagents may replay their parent's history. Only the owning + // thread's response receipts represent fresh work in this rollout. + if (state.sessionId && payload.thread_id && payload.thread_id !== state.sessionId) return; + const turn = payload.turn_id ?? state.currentTurn; + state.structuredTurns.add(turn); + if (payload.response_id) { + if (state.responseIds.has(payload.response_id)) return; + state.responseIds.add(payload.response_id); + } + // These are per-response facts. thread_token_usage can begin with a + // historical baseline or reset on resume; never book it as new usage. + usage = payload.usage; + if (payload.thread_token_usage) state.prevTotal = { ...payload.thread_token_usage }; } else { - return; + if (evt.type !== "event_msg" || payload.type !== "token_count") return; + const info = payload.info; + if (!info) return; + if (info.total_token_usage) { + usage = computeDelta(info.total_token_usage, state.prevTotal); + state.prevTotal = { ...info.total_token_usage }; + } else if (info.last_token_usage) { + usage = info.last_token_usage; + } else { + return; + } } const totalInput = usage.input_tokens ?? 0; @@ -427,6 +479,7 @@ function foldCodexEvent( const outputTokens = rawOutputTokens - reasoningTokens; if (inputTokens === 0 && outputTokens === 0 && cached === 0 && reasoningTokens === 0) return; + if (!structured) state.legacyRecordTurns.set(out.length, payload.turn_id ?? state.currentTurn); out.push( createRecord({ timestamp: evt.timestamp ? new Date(evt.timestamp).getTime() : Date.now(), @@ -493,7 +546,13 @@ export async function parseCodexFile( }); for (const evt of events) foldCodexEvent(evt, state, file, out); } - return out; + // Recent CLI versions mirror the same responses in BOTH event formats. + // Choose the response ledger for each covered turn, independent of event + // ordering, while retaining old-format turns in long-lived sessions. + return out.filter((_, index) => { + const turn = state.legacyRecordTurns.get(index); + return turn === undefined || !state.structuredTurns.has(turn); + }); } export class CodexParser implements SessionParser { diff --git a/packages/core/src/parsers/fixtures/codex-response-usage.json b/packages/core/src/parsers/fixtures/codex-response-usage.json new file mode 100644 index 0000000..bebf6ef --- /dev/null +++ b/packages/core/src/parsers/fixtures/codex-response-usage.json @@ -0,0 +1,53 @@ +{ + "description": "Synthetic numeric fixture shaped from Codex CLI/Desktop receipts observed 2026-09-06. No conversation content or real identifiers.", + "expected": { + "model": "gpt-6-astra", + "inputTokens": 200, + "cacheReadTokens": 800, + "outputTokens": 60, + "reasoningTokens": 40 + }, + "events": [ + { "type": "session_meta", "payload": { "id": "fixture-thread", "cwd": "/demo/project" } }, + { "type": "turn_context", "payload": { "turn_id": "fixture-turn", "model": "gpt-6-astra" } }, + { + "timestamp": "2026-09-06T10:00:00Z", + "type": "token_usage_record", + "payload": { + "thread_id": "fixture-thread", + "turn_id": "fixture-turn", + "response_id": "fixture-response", + "usage": { + "input_tokens": 1000, + "cached_input_tokens": 800, + "output_tokens": 100, + "reasoning_output_tokens": 40, + "total_tokens": 1100 + }, + "thread_token_usage": { + "input_tokens": 1000, + "cached_input_tokens": 800, + "output_tokens": 100, + "reasoning_output_tokens": 40, + "total_tokens": 1100 + } + } + }, + { + "timestamp": "2026-09-06T10:00:01Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "total_token_usage": { + "input_tokens": 1000, + "cached_input_tokens": 800, + "output_tokens": 100, + "reasoning_output_tokens": 40, + "total_tokens": 1100 + } + } + } + } + ] +} diff --git a/packages/core/src/parsers/utils.ts b/packages/core/src/parsers/utils.ts index 0affca0..654c54a 100644 --- a/packages/core/src/parsers/utils.ts +++ b/packages/core/src/parsers/utils.ts @@ -115,7 +115,9 @@ const CACHE_FILE = join(CACHE_DIR, "scan-cache.json"); * output bucket to visible output so aggregate totals and pricing do not * count the same generated tokens twice. */ -const CACHE_VERSION = 10; +// 11 — Codex per-response token_usage_record support and unknown model fallback. +// 12 — Preserve explicit tool-reported zero cost instead of repricing it. +const CACHE_VERSION = 12; function loadRecordCache(): Map { if (recordCache) return recordCache; diff --git a/packages/core/src/pricing-enrichment.test.ts b/packages/core/src/pricing-enrichment.test.ts index fa7acc8..3b3f65c 100644 --- a/packages/core/src/pricing-enrichment.test.ts +++ b/packages/core/src/pricing-enrichment.test.ts @@ -42,6 +42,15 @@ function makeRecord(overrides: Partial = {}): TokenRecord { } describe("enrichCosts — costEligible", () => { + it("preserves an explicit tool-reported zero instead of repricing it", async () => { + const pricing = new PricingService(); + pricing.seedPricing("gpt-5.6-sol", { inputPerMillion: 5, outputPerMillion: 30 }); + const record = makeRecord(); + record.usage!.cost = "direct"; + await enrichCosts([record], pricing, "today", []); + expect(record.cost).toBe(0); + expect(record.usage?.cost).toBe("direct"); + }); it("prices a record normally when the model has real pricing and costEligible is unset", async () => { const pricing = new PricingService(); pricing.seedPricing("gpt-5.6-sol", { inputPerMillion: 5, outputPerMillion: 30 }); diff --git a/packages/core/src/pricing-enrichment.ts b/packages/core/src/pricing-enrichment.ts index 0900921..1f0f360 100644 --- a/packages/core/src/pricing-enrichment.ts +++ b/packages/core/src/pricing-enrichment.ts @@ -61,7 +61,9 @@ export async function enrichCosts( unpricedTracker?: UnpricedTracker ): Promise { const costPromises = records.map(async (r) => { - if (r.cost > 0) return; + // An explicit tool-reported $0 is still a fact. Do not replace it with + // an API-rate estimate or relabel it as missing pricing. + if (r.cost > 0 || r.usage?.cost === "direct") return; if (r.costEligible === false) { // Not a missing-pricing-data case (kosha may well have real rates for // this model) — an explicit per-record decision not to guess a cost diff --git a/packages/core/src/pricing.test.ts b/packages/core/src/pricing.test.ts index 8ca4644..960a4b7 100644 --- a/packages/core/src/pricing.test.ts +++ b/packages/core/src/pricing.test.ts @@ -2,6 +2,25 @@ import { describe, expect, it } from "vitest"; import { PricingService } from "./pricing.js"; describe("PricingService", () => { + it("preserves explicit free cache and reasoning rates instead of charging fallback rates", async () => { + const pricing = new PricingService(); + pricing.seedPricing("free-buckets", { + inputPerMillion: 10, + outputPerMillion: 50, + cacheReadPerMillion: 0, + reasoningOutputPerMillion: 0, + }); + expect( + await pricing.calculateCost("free-buckets", 1000, 100, 1_000_000, 0, 1_000_000) + ).toBeCloseTo(0.015, 10); + }); + + it("rejects negative rates instead of subtracting from spending totals", async () => { + const pricing = new PricingService(); + pricing.seedPricing("invalid-rates", { inputPerMillion: 10, outputPerMillion: -50 }); + expect(await pricing.getPricing("invalid-rates")).toBeNull(); + }); + it("should create instance without errors", () => { const pricing = new PricingService(); expect(pricing).toBeDefined(); diff --git a/packages/core/src/pricing.ts b/packages/core/src/pricing.ts index bde6ed2..cc1b9f7 100644 --- a/packages/core/src/pricing.ts +++ b/packages/core/src/pricing.ts @@ -29,7 +29,8 @@ * * Keys are exact model ids. Values are partial ModelPricing objects * (input/output required; cache + reasoning fields optional). Missing - * fields default to 0 — set them explicitly if your contract differs. + * cache reads default to 10% of input and reasoning to the output rate; + * cache writes default to 0. Explicit zero rates are respected. * * Why kosha is the single source of truth (otherwise): * @@ -613,7 +614,7 @@ export class PricingService { /** * Round all pricing fields to 6 decimal places to eliminate float noise. - * Returns null if any required field is NaN or Infinity (treat as unpriced). + * Returns null for invalid or negative rates (treat as unpriced). */ private roundPricing(p: ModelPricing): FullPricing | null { const r = (n: number) => Math.round(n * 1_000_000) / 1_000_000; @@ -627,17 +628,21 @@ export class PricingService { p.reasoningOutputPerMillion, ]; for (const v of allValues) { - if (v !== undefined && !Number.isFinite(v)) return null; + if (v !== undefined && (!Number.isFinite(v) || v < 0)) return null; } return { inputPerMillion: r(p.inputPerMillion), outputPerMillion: r(p.outputPerMillion), - ...(p.cacheReadPerMillion ? { cacheReadPerMillion: r(p.cacheReadPerMillion) } : {}), - ...(p.cacheWritePerMillion ? { cacheWritePerMillion: r(p.cacheWritePerMillion) } : {}), - ...(p.reasoningInputPerMillion + ...(p.cacheReadPerMillion !== undefined + ? { cacheReadPerMillion: r(p.cacheReadPerMillion) } + : {}), + ...(p.cacheWritePerMillion !== undefined + ? { cacheWritePerMillion: r(p.cacheWritePerMillion) } + : {}), + ...(p.reasoningInputPerMillion !== undefined ? { reasoningInputPerMillion: r(p.reasoningInputPerMillion) } : {}), - ...(p.reasoningOutputPerMillion + ...(p.reasoningOutputPerMillion !== undefined ? { reasoningOutputPerMillion: r(p.reasoningOutputPerMillion) } : {}), }; diff --git a/packages/core/src/scan-lifetime.test.ts b/packages/core/src/scan-lifetime.test.ts index 12be2a8..62da70f 100644 --- a/packages/core/src/scan-lifetime.test.ts +++ b/packages/core/src/scan-lifetime.test.ts @@ -49,6 +49,8 @@ describe("scanLifetimeRaw — cleanup sees beyond the 14-day window", () => { const hasOld = (rs: TokenRecord[]) => rs.some((r) => localDateKey(r.timestamp) === oldKey); const recent = await core.scan(); + expect(core.getAllProjects({ today: true })[0].totalTokens).toBe(120); + expect(core.getAllProjects()[0].totalTokens).toBe(240); const lifetime = await core.scanLifetimeRaw(); expect(hasOld(recent)).toBe(false); // 14-day window excludes the 30-day record diff --git a/packages/core/src/signals.test.ts b/packages/core/src/signals.test.ts index fc1e233..031594a 100644 --- a/packages/core/src/signals.test.ts +++ b/packages/core/src/signals.test.ts @@ -245,19 +245,21 @@ describe("computeStatbarSignals", () => { test("reasoning share is reasoning tokens over today's total output tokens", () => { const records = [ - // 400 output, 240 of it reasoning + // 400 visible output plus 240 reasoning r({ timestamp: now - 1 * HOUR, outputTokens: 400, reasoningTokens: 240 }), - // 200 output, 60 reasoning + // 200 visible output plus 60 reasoning r({ timestamp: now - 30 * MIN, outputTokens: 200, reasoningTokens: 60 }), // Output-only turn, no reasoning — denominator climbs, numerator doesn't r({ timestamp: now - 10 * MIN, outputTokens: 100, reasoningTokens: 0 }), + // SQLite lifetime deltas have no output breakdown and must not dilute the ratio. + r({ timestamp: now - 5 * MIN, outputTokens: 1_000_000, costEligible: false }), // Yesterday — must not contribute r({ timestamp: now - 30 * HOUR, outputTokens: 5000, reasoningTokens: 5000 }), ]; const s = computeStatbarSignals(records, now); expect(s.reasoningToday.tokens).toBe(300); - expect(s.reasoningToday.outputTokens).toBe(700); - expect(s.reasoningToday.share).toBeCloseTo(300 / 700, 5); + expect(s.reasoningToday.outputTokens).toBe(1000); + expect(s.reasoningToday.share).toBeCloseTo(300 / 1000, 5); // Only the two records with reasoningTokens > 0 count toward the record tally. expect(s.reasoningToday.records).toBe(2); }); diff --git a/packages/core/src/signals.ts b/packages/core/src/signals.ts index 6ac53ed..2f79a11 100644 --- a/packages/core/src/signals.ts +++ b/packages/core/src/signals.ts @@ -8,6 +8,7 @@ */ import type { DailyAggregate } from "./aggregates.js"; +import { modelCostBasis, summarizeCostBasis } from "./cost-basis.js"; import { localDateKey } from "./date-utils.js"; import type { StatbarSignals, TokenRecord } from "./types.js"; import { deriveUsage, sumUsage } from "./usage.js"; @@ -343,15 +344,18 @@ export function computeStatbarSignals( }; // ── Reasoning share (today) ──────────────────────────────────────────── - // Reasoning tokens are a subset of output tokens for OpenAI-style providers - // (Codex et al.). Surfacing the share tells the user "your effort:low or + // The ledger separates visible output and reasoning. Add both buckets to + // reconstruct total generated output. Surfacing the share tells the user "your effort:low or // explicit-model choice is making this much of your output invisible // thinking" — actionable for routing decisions on routine tasks. let reasoningTokens = 0; let reasoningOutputTokens = 0; let reasoningRecords = 0; for (const r of todayRecords) { - reasoningOutputTokens += r.outputTokens; + // SQLite fallback totals have no output breakdown and must not dilute + // this ratio by presenting a thread's entire usage as generated output. + if (r.costEligible === false) continue; + reasoningOutputTokens += r.outputTokens + r.reasoningTokens; if (r.reasoningTokens > 0) { reasoningTokens += r.reasoningTokens; reasoningRecords++; @@ -360,10 +364,6 @@ export function computeStatbarSignals( const reasoningToday = { tokens: reasoningTokens, outputTokens: reasoningOutputTokens, - // Clamp at 1.0 — some Codex variants over-report reasoning tokens (the - // count isn't always strictly nested inside output_tokens). Without the - // clamp the UI would render ">100% reasoning", which is technically - // honest but reads as a bug. share: reasoningOutputTokens > 0 ? Math.min(1, reasoningTokens / reasoningOutputTokens) : 0, records: reasoningRecords, }; @@ -503,6 +503,8 @@ export function computeStatbarSignals( } return { + costBasisToday: summarizeCostBasis(todayRecords), + modelCostBasisToday: modelCostBasis(todayRecords), burnRate, cacheHitToday, contextPressure: estimateContextPressure(records, now), diff --git a/packages/core/src/tokmeter-core.ts b/packages/core/src/tokmeter-core.ts index b60eceb..0887e2f 100644 --- a/packages/core/src/tokmeter-core.ts +++ b/packages/core/src/tokmeter-core.ts @@ -413,8 +413,12 @@ export class TokmeterCore { return { ...this.scanMeta, warnings: [...this.scanMeta.warnings] }; } - getAllProjects(): ProjectSummary[] { - return computeAllProjectsFromState(this.aggregates, this.todayAccumulator, this.getAliases()); + getAllProjects(options?: { today?: boolean }): ProjectSummary[] { + return computeAllProjectsFromState( + options?.today ? new Map() : this.aggregates, + this.todayAccumulator, + this.getAliases() + ); } getRawProjectNames(): string[] { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 451b8f0..81986ad 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -309,7 +309,20 @@ export interface TokmeterStats { * - liveSession: the most recently-active record within the freshness * window (5 min) — null when nothing's live */ +/** Monetary provenance, without treating local tool totals as invoice charges. */ +export interface CostBasis { + estimatedCost: number; + reportedCost: number; + unclassifiedCost: number; + estimatedRecords: number; + reportedRecords: number; + unavailableRecords: number; +} + export interface StatbarSignals { + /** Optional for compatibility with older daemons and cached summaries. */ + costBasisToday?: CostBasis; + modelCostBasisToday?: Record; burnRate: { /** USD per hour over the recent window. */ costPerHour: number; diff --git a/packages/core/src/usage.test.ts b/packages/core/src/usage.test.ts index 7c45d07..5f7fb55 100644 --- a/packages/core/src/usage.test.ts +++ b/packages/core/src/usage.test.ts @@ -38,7 +38,7 @@ describe("deriveUsage", () => { expect(usage.hasCacheTelemetry).toBe(true); }); - test("estimates visible output when reasoning is a reported sub-bucket", () => { + test("does not subtract reasoning again from normalized visible output", () => { const usage = deriveUsage({ inputTokens: 75, cacheReadTokens: 0, @@ -47,7 +47,7 @@ describe("deriveUsage", () => { reasoningTokens: 1_024, }); - expect(usage.visibleOutputTokensApprox).toBe(162); + expect(usage.visibleOutputTokensApprox).toBe(1_186); expect(usage.ledgerTotalTokens).toBe(2_285); }); }); diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index 4850946..7cc7fc3 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -27,8 +27,7 @@ export interface DerivedUsage { outputTokens: number; reasoningTokens: number; /** - * Best-effort visible output estimate when reasoning is reported as an - * output sub-bucket by the upstream provider. + * Visible output from the normalized ledger (reasoning is already separate). */ visibleOutputTokensApprox?: number; /** @@ -75,8 +74,7 @@ export function deriveUsage(breakdown: UsageBreakdown): DerivedUsage { totalInputTokens, outputTokens, reasoningTokens, - visibleOutputTokensApprox: - reasoningTokens > 0 ? Math.max(0, outputTokens - reasoningTokens) : undefined, + visibleOutputTokensApprox: reasoningTokens > 0 ? outputTokens : undefined, ledgerTotalTokens, cacheHitRate: totalInputTokens > 0 ? cacheReadTokens / totalInputTokens : 0, cacheMissRate: totalInputTokens > 0 ? uncachedInputTokens / totalInputTokens : 0, diff --git a/packages/macos-bar/README.md b/packages/macos-bar/README.md index d48677f..027a4a5 100644 --- a/packages/macos-bar/README.md +++ b/packages/macos-bar/README.md @@ -48,14 +48,17 @@ ad-hoc signs it so macOS Gatekeeper allows local execution. drishti daemon start # Launch the app -open TokmeterBar.app +open /Applications/TokmeterBar.app ``` -The menubar icon shows `♾️ $X.YY` (today's cost). Click it to see: -- Today / Total Tokens / Total Cost -- Top 3 models bar chart -- 7-day cost line chart -- Projects / Active Days / Streak +The menubar icon shows today's tokens. Click it to see: +- Today's tokens, estimated API cost, and tool-reported cost separately +- Today's models, with Today/All and Show all controls +- Today's projects +- Usage details: lifetime totals, trends, cache and other signals + +Costs are not a verified subscription bill. Missing cost data is shown as unavailable. +See [how the numbers work](../../docs/how-the-numbers-work.md). It refreshes every 30 seconds. diff --git a/packages/macos-bar/Sources/TokmeterBar/DaemonClient.swift b/packages/macos-bar/Sources/TokmeterBar/DaemonClient.swift index 2b69d4e..5fb142d 100644 --- a/packages/macos-bar/Sources/TokmeterBar/DaemonClient.swift +++ b/packages/macos-bar/Sources/TokmeterBar/DaemonClient.swift @@ -154,8 +154,8 @@ final class DaemonClient { /// All sessions across all providers, up to 50 items, sorted by recency. /// Used for the expandable session list in the popover. - func fetchSessions() async throws -> [ProjectData] { - try await get("/api/sessions", as: [ProjectData].self) + func fetchSessions(today: Bool = false) async throws -> [ProjectData] { + try await get(today ? "/api/sessions?today=true" : "/api/sessions", as: [ProjectData].self) } func fetchProjectDetail(_ projectName: String) async throws -> ProjectDetailData { diff --git a/packages/macos-bar/Sources/TokmeterBar/DataSections.swift b/packages/macos-bar/Sources/TokmeterBar/DataSections.swift index fdca192..2da1b9c 100644 --- a/packages/macos-bar/Sources/TokmeterBar/DataSections.swift +++ b/packages/macos-bar/Sources/TokmeterBar/DataSections.swift @@ -19,17 +19,26 @@ struct ModelsSection: View { @ObservedObject var loader: TokmeterLoader let theme: AppTheme - @State private var showToday: Bool = false + @State private var showToday: Bool = true + @State private var showAllModels: Bool = false private var c: ThemeColors { theme.colors } - private var activeModels: [ModelUsage] { showToday ? loader.todayModels : loader.topModels } + private var availableModels: [ModelUsage] { showToday ? loader.todayModels : loader.topModels } + private var activeModels: [ModelUsage] { + if showAllModels { return availableModels } + let ranked = Array(availableModels.prefix(5)) + guard showToday else { return ranked } + // Preserve visibility for activity whose cost isn't exposed. + let rankedIDs = Set(ranked.map(\.id)) + return ranked + availableModels.filter { $0.cost == 0 && !rankedIDs.contains($0.id) }.prefix(3) + } var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center) { SectionHeader( label: showToday ? "TODAY'S MODELS" : "TOP MODELS", - count: activeModels.count, + count: availableModels.count, theme: theme ) Spacer() @@ -57,6 +66,14 @@ struct ModelsSection: View { ForEach(activeModels) { model in modelRow(model, maxCost: maxCost, showProvider: dupNames.contains(model.model)) } + if showAllModels || availableModels.count > activeModels.count { + Button(showAllModels ? "Show fewer" : "Show all \(availableModels.count) models") { + showAllModels.toggle() + } + .buttonStyle(.plain) + .font(.system(size: 10, design: theme.fonts.bodyDesign)) + .foregroundColor(theme.backgroundMode.secondaryTextColor) + } } } } @@ -80,7 +97,7 @@ struct ModelsSection: View { .padding(.vertical, 3) .background(active ? Capsule().fill(c.accent.opacity(0.18)) : nil) } - .buttonStyle(.borderless) + .buttonStyle(.plain) } /// True whenever cost is honestly $0 rather than guessed — covers both @@ -91,7 +108,11 @@ struct ModelsSection: View { /// guessing an input/output split to price it from). Drawn distinctly so /// neither case reads as "$0.00 = confirmed free." private func isActivityOnly(_ model: ModelUsage) -> Bool { - model.cost == 0 + guard model.cost == 0 else { return false } + if showToday, let basis = loader.statbarSignals?.modelCostBasisToday?[model.id] { + return basis.unavailableRecords > 0 || basis.estimatedRecords + basis.reportedRecords == 0 + } + return true } private func modelRow(_ model: ModelUsage, maxCost: Double, showProvider: Bool = false) -> some View { @@ -149,7 +170,7 @@ struct ModelsSection: View { .help( activityOnly ? (model.tokens > 0 - ? "Real token count from this provider's local state — cost isn't shown because there's no reliable input/output split to price it from." + ? "\(Fmt.number(model.tokens)) tokens. Cost is unavailable because pricing or a reliable token breakdown is missing." : "This provider doesn't expose token counts or cost locally — only that you used it.") : compositionTooltip( output: model.outputTokens, @@ -164,7 +185,7 @@ struct ModelsSection: View { // count (e.g. Codex Desktop's SQLite-sourced total) — show // that number rather than an unhelpful "no cost data" when we // actually have real data, just not a priceable one. - Text(model.tokens > 0 ? "\(Fmt.number(model.tokens)) tok" : "no cost data") + Text("Unavailable") .font(.system(size: 9, weight: .medium, design: theme.fonts.bodyDesign)) .foregroundColor(theme.backgroundMode.secondaryTextColor) .frame(width: 56, alignment: .trailing) @@ -366,23 +387,23 @@ struct SessionsSection: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - SectionHeader(label: "SESSIONS", count: loader.sessions.count, theme: theme) + SectionHeader(label: "TODAY’S PROJECTS", count: loader.todayProjects.count, theme: theme) if loader.isWarming { ForEach(0..<5, id: \.self) { _ in ShimmerBar(width: 340, height: 28, breathToggle: true) } } else { - let visible = showAll ? loader.sessions : Array(loader.sessions.prefix(8)) + let visible = showAll ? loader.todayProjects : Array(loader.todayProjects.prefix(8)) ForEach(visible) { session in row(session) } - if loader.sessions.count > 8 && !showAll { + if loader.todayProjects.count > 8 && !showAll { Button { withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) { showAll = true } } label: { HStack(spacing: 4) { - Text("Show all \(loader.sessions.count)") + Text("Show all \(loader.todayProjects.count)") .font(.system(size: 11, weight: .medium, design: theme.fonts.bodyDesign)) Image(systemName: "chevron.down").font(.system(size: 9)) } @@ -419,7 +440,7 @@ struct SessionsSection: View { .foregroundColor(theme.backgroundMode.primaryTextColor) .lineLimit(1) .help(session.project) - Text("\(session.activeDays)d · \(Fmt.number(session.totalTokens)) tokens") + Text("\(Fmt.number(session.totalTokens)) tokens today") .font(.system(size: 10, design: theme.fonts.bodyDesign)) .foregroundColor(theme.backgroundMode.secondaryTextColor) } diff --git a/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift b/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift index d24762c..7f9a12e 100644 --- a/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift +++ b/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift @@ -60,6 +60,15 @@ struct FooterBar: View { Text("v\(appVersion)") .font(.system(size: 10, design: theme.fonts.bodyDesign)) .foregroundColor(theme.backgroundMode.secondaryTextColor) + if let resources = Bundle.main.resourceURL { + Button("Licenses & source") { + NSWorkspace.shared.open(resources.appendingPathComponent("Licenses")) + } + .buttonStyle(.plain) + .font(.system(size: 9, design: theme.fonts.bodyDesign)) + .foregroundColor(theme.backgroundMode.secondaryTextColor) + .help("Open license texts, third-party notices, and the source archive") + } Spacer() // Amber pill when today's records contain models with no resolved // pricing — silent $0 leaks would otherwise hide in the totals. diff --git a/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift b/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift index d7bf8e2..7741683 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift @@ -30,12 +30,6 @@ struct HeroHeader: View { /// overlay on the top-level VStack so it floats above the scroll content. @Binding var showCachePanel: Bool - /// Briefly bumped to >1 / non-zero degrees when `todayCost` changes so - /// the hero number reacts visibly to fresh data — secondary action that - /// reinforces the ECG's "live" message. - @State private var costWiggleScale: CGFloat = 1.0 - @State private var costWiggleAngle: Double = 0 - private var c: ThemeColors { theme.colors } var body: some View { @@ -125,67 +119,60 @@ struct HeroHeader: View { } } - /// Main value row. Hero number + "today" inline at the value baseline so - /// we spend one row instead of two. Tight line-spacing keeps the whole - /// hero from ballooning with leading whitespace around the glyphs. + /// Usage is the headline; monetary estimates and tool reports stay separate. private var valueRow: some View { HStack(alignment: .lastTextBaseline, spacing: 6) { if loader.isWarming { skeletonHero } else { - Text(Fmt.cost(loader.todayCost)) + Text(Fmt.number(loader.todayTokens)) .font(theme.fonts.hero(size: heroFontSize)) .foregroundColor(foreground) .contentTransition(.numericText()) - .scaleEffect(costWiggleScale) - .rotationEffect(.degrees(costWiggleAngle)) - // Tighten the text's intrinsic leading so large fonts don't - // leave vertical padding around glyphs. Caps the line height - // to the actual font size. - .fixedSize(horizontal: false, vertical: true) .lineLimit(1) - .animation(.spring(response: 0.55, dampingFraction: 0.70), value: loader.todayCost) - .onChange(of: loader.todayCost) { _, _ in - // Two-step wiggle: pop up + tilt, then settle back. - // The seed-driven tilt direction adds organic variance. - let tiltDirection: Double = Bool.random() ? 1.0 : -1.0 - costWiggleScale = 1.04 - costWiggleAngle = 0.6 * tiltDirection - withAnimation(.spring(response: 0.32, dampingFraction: 0.50)) { - costWiggleScale = 1.0 - costWiggleAngle = 0 - } - } - Text("today") + .minimumScaleFactor(0.6) + Text("tokens today") .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) - .italic() .foregroundColor(foreground.opacity(0.65)) } } } - /// Compact per-tier line under the hero cost — "64.5M tok · 787K in · - /// 276K out · 62.4M cached". Hidden while warming or when the daemon - /// response predates the breakdown fields (all tiers zero). @ViewBuilder private var tokenBreakdownRow: some View { - if !loader.isWarming, loader.todayTokens > 0, - loader.todayInputTokens + loader.todayOutputTokens + loader.todayCachedTokens > 0 { - Text( - "\(Fmt.number(loader.todayTokens)) tok · \(Fmt.number(loader.todayInputTokens)) in" - + " · \(Fmt.number(loader.todayOutputTokens)) out" - + " · \(Fmt.number(loader.todayCachedTokens)) cached" - ) - .font(.system(size: 9, weight: .medium, design: theme.fonts.bodyDesign)) - .foregroundColor(foreground.opacity(0.55)) - .lineLimit(1) - .padding(.top, 2) - .contentTransition(.numericText()) - .animation(.default, value: loader.todayTokens) + if !loader.isWarming { + VStack(alignment: .leading, spacing: 4) { + if let basis = loader.statbarSignals?.costBasisToday { + if basis.estimatedRecords > 0 { + costLine("Estimated API cost", value: basis.estimatedCost) + } + if basis.reportedRecords > 0 { + costLine("Tool-reported cost", value: basis.reportedCost) + } + if basis.unavailableRecords > 0 { + Text("Cost unavailable for some usage") + .foregroundColor(Color.tokWarning) + } else if basis.estimatedRecords + basis.reportedRecords == 0 { + Text("No usage recorded today") + } + } else { + Text("Cost breakdown unavailable") + } + } + .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) + .foregroundColor(foreground.opacity(0.75)) + .padding(.top, 4) + .help("API estimates value usage at model rates. Tool-reported costs come from local usage records. Neither is a verified subscription bill.") } } - // MARK: - Status indicator (pill or live ECG) + private func costLine(_ label: String, value: Double) -> some View { + HStack { + Text(label) + Spacer() + Text(Fmt.cost(value)).monospacedDigit() + } + } @ViewBuilder private var statusIndicator: some View { diff --git a/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift b/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift index 7a85d49..4f1d44a 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift @@ -77,7 +77,7 @@ struct HubOverviewPanel: View { Text("Overview") .font(.system(size: 24, weight: .bold, design: theme.fonts.heroDesign)) .foregroundColor(bg.primaryTextColor) - Text("Everything you've spent, everywhere — at a glance.") + Text("Usage across your projects. Costs include estimates and tool reports.") .font(.system(size: 12, design: theme.fonts.bodyDesign)) .foregroundColor(bg.secondaryTextColor) } diff --git a/packages/macos-bar/Sources/TokmeterBar/Models.swift b/packages/macos-bar/Sources/TokmeterBar/Models.swift index 80a2c1c..b408410 100644 --- a/packages/macos-bar/Sources/TokmeterBar/Models.swift +++ b/packages/macos-bar/Sources/TokmeterBar/Models.swift @@ -425,7 +425,18 @@ struct CrossToolComparison: Codable, Equatable { let projections: [CrossToolProjection] } +struct CostBasis: Codable, Equatable { + let estimatedCost: Double + let reportedCost: Double + let unclassifiedCost: Double + let estimatedRecords: Int + let reportedRecords: Int + let unavailableRecords: Int +} + struct StatbarSignals: Codable, Equatable { + var costBasisToday: CostBasis? = nil + var modelCostBasisToday: [String: CostBasis]? = nil let burnRate: BurnRate let cacheHitToday: CacheHitToday let contextPressure: ContextPressure? diff --git a/packages/macos-bar/Sources/TokmeterBar/StatCards.swift b/packages/macos-bar/Sources/TokmeterBar/StatCards.swift index 01870fe..95446e9 100644 --- a/packages/macos-bar/Sources/TokmeterBar/StatCards.swift +++ b/packages/macos-bar/Sources/TokmeterBar/StatCards.swift @@ -64,7 +64,7 @@ struct StatsGrid: View { ) StatCard( icon: "dollarsign.circle.fill", - label: "SPENT", + label: "COST TOTAL", value: Fmt.cost(loader.totalCost), role: c.highlight, delta: weekDelta { $0.cost }, diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterBarApp.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterBarApp.swift index 6367005..a82d85e 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterBarApp.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterBarApp.swift @@ -45,7 +45,7 @@ struct TokmeterBarApp: App { if let pct = activePct { return "\(Int(pct.rounded()))%" } - return String(format: "$%.2f", loader.todayCost) + return "\(Fmt.number(loader.todayTokens)) tok" } /// The percentage for the selected live source, if it has data right now. @@ -85,7 +85,7 @@ struct TokmeterBarApp: App { if loader.lastError != nil { return "Tokmeter: daemon offline" } - let base = String(format: "Tokmeter: today's cost is $%.2f", loader.todayCost) + let base = "Tokmeter: \(Fmt.number(loader.todayTokens)) tokens today" guard let band = menubarBand, let pct = activePct else { return base } let sourceName: String switch config.config.colorSource { diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift index d48b445..c1f8063 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift @@ -30,7 +30,6 @@ struct TokmeterBarView: View { @AppStorage("appTheme") var theme: AppTheme = .nebula /// Local UI state — never persisted. - @State private var showAllSessions = false @State private var breathToggle = false /// Tracks whether this popover's window is actually on screen — see /// PanelVisibility.swift. Every ambient animation in the hero/footer is @@ -67,28 +66,7 @@ struct TokmeterBarView: View { .cascadeIn(delay: 0.08) ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 16) { - // Thin "right now" telemetry strip — burn rate, cache hit, - // compaction tax. Self-hides when there's no live signal. - SignalsRibbon(loader: loader, theme: theme) - .cascadeIn(delay: 0.10) - // CACHE & CONTEXT is no longer inline — it lives in the - // wallet drawer, opened from the hero header icon. - StatsGrid(loader: loader, theme: theme) - .cascadeIn(delay: 0.14) - if !loader.topModels.isEmpty || loader.isWarming { - ModelsSection(loader: loader, theme: theme) - .cascadeIn(delay: 0.22) - } - if loader.recentDaily.count > 1 || loader.isWarming { - WeekSection(loader: loader, theme: theme) - .cascadeIn(delay: 0.30) - } - if !loader.sessions.isEmpty || loader.isWarming { - SessionsSection(loader: loader, theme: theme, showAll: $showAllSessions) - .cascadeIn(delay: 0.38) - } - } + UsageOverview(loader: loader, theme: theme) .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 10) @@ -109,7 +87,7 @@ struct TokmeterBarView: View { .cascadeIn(delay: 0.46) } .frame(width: 400) - .frame(minHeight: 620, maxHeight: 820) + .frame(minHeight: 520, maxHeight: 780) .background(popoverBackground) .trackPanelVisibility(panelVisibility) // Cache "wallet" drawer — slides in from the trailing edge over the diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift index 26990f5..934ca11 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift @@ -29,6 +29,7 @@ final class TokmeterLoader: ObservableObject { @Published var recentDaily: [DailyUsage] = [] @Published var allDaily: [DailyUsage] = [] @Published var sessions: [ProjectData] = [] + @Published var todayProjects: [ProjectData] = [] /// Live "right now" signals — burn rate, cache hit, pace vs typical, /// compaction tax, live session. nil until the first phase-2 fetch. @Published var statbarSignals: StatbarSignals? @@ -112,7 +113,8 @@ final class TokmeterLoader: ObservableObject { /// a PID singleton on disk; this just stops the bar from spamming spawns. var isStartingDaemon: Bool = false - init() { + init(startPolling: Bool = true) { + guard startPolling else { return } Task { await loadData() } let initial = HubConfigStore.shared.config.bar.refreshSeconds restartTimer(interval: TimeInterval(initial)) @@ -126,6 +128,19 @@ final class TokmeterLoader: ObservableObject { startColorTimer() } + func applyToday(from daily: [DailyData], now: Date = Date()) { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + let today = daily.first { $0.date == formatter.string(from: now) } + todayCost = today?.cost ?? 0 + todayTokens = today?.totalTokens ?? 0 + todayInputTokens = today?.inputTokens ?? 0 + todayOutputTokens = today?.outputTokens ?? 0 + todayCachedTokens = (today?.cacheReadTokens ?? 0) + (today?.cacheWriteTokens ?? 0) + } + private func startColorTimer() { colorTimer?.invalidate() colorTimer = Timer.scheduledTimer( @@ -242,6 +257,7 @@ final class TokmeterLoader: ObservableObject { async let modelsTask = fetchModelsSafe() async let todayModelsTask = fetchTodayModelsSafe() async let sessionsTask = fetchSessionsSafe() + async let todayProjectsTask = try? client.fetchSessions(today: true) async let pricingStatusTask = fetchPricingStatusSafe() async let cronStatusTask = fetchCronStatusSafe() async let healthTask = fetchHealthSafe() @@ -257,6 +273,7 @@ final class TokmeterLoader: ObservableObject { modelsResult, todayModelsResult, sessionsResult, + todayProjectsResult, pricingStatusResult, cronStatusResult, healthResult, @@ -265,45 +282,26 @@ final class TokmeterLoader: ObservableObject { crossToolResult, antigravityLiveResult ) = await ( - dailyTask, modelsTask, todayModelsTask, sessionsTask, pricingStatusTask, + dailyTask, modelsTask, todayModelsTask, sessionsTask, todayProjectsTask, pricingStatusTask, cronStatusTask, healthTask, anomaliesTask, signalsTask, crossToolTask, antigravityLiveTask ) withTransaction(noAnim) { if let daily = dailyResult { - if let today = daily.last { - self.todayCost = today.cost - self.todayTokens = today.totalTokens - self.todayInputTokens = today.inputTokens ?? 0 - self.todayOutputTokens = today.outputTokens ?? 0 - self.todayCachedTokens = (today.cacheReadTokens ?? 0) + (today.cacheWriteTokens ?? 0) - } + self.applyToday(from: daily) let mapped = daily.map { DailyUsage(date: $0.date, tokens: $0.totalTokens, cost: $0.cost) } self.allDaily = mapped self.recentDaily = Array(mapped.suffix(7)) } if let models = modelsResult { - self.topModels = models.prefix(5).map(Self.toUsage) + self.topModels = models.map(Self.toUsage) } if let todayMs = todayModelsResult { - // Quota-billed/activity-only clients (VS Code Copilot, - // Antigravity) and real-but-unpriced totals (Codex Desktop's - // SQLite fallback — genuine non-zero tokens, cost honestly - // left unexposed) both report cost == 0 — a pure cost - // ranking always buries them under same-day providers that - // DO report dollars, so "I used X today" silently never - // shows up. Top 5 by cost stays the primary ranking; up to 3 - // cost==0 entries are appended so today's real usage is - // never invisible just because it isn't priced. - let all = todayMs.map(Self.toUsage) - let ranked = Array(all.prefix(5)) - let rankedKeys = Set(ranked.map { "\($0.provider)/\($0.model)" }) - let activityOnly = all - .filter { $0.cost == 0 && !rankedKeys.contains("\($0.provider)/\($0.model)") } - .prefix(3) - self.todayModels = ranked + activityOnly + // Keep the complete list; the view owns its collapsed limit. + self.todayModels = todayMs.map(Self.toUsage) } + if let projects = todayProjectsResult { self.todayProjects = projects } if let sessionsList = sessionsResult { self.sessions = sessionsList } diff --git a/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift b/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift new file mode 100644 index 0000000..1954bb3 --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift @@ -0,0 +1,35 @@ +// Shared by the live popover and the fixture-backed visual demo. +import SwiftUI + +struct UsageOverview: View { + @ObservedObject var loader: TokmeterLoader + let theme: AppTheme + @State private var showAllSessions = false + @State private var showUsageDetails = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if !loader.topModels.isEmpty || !loader.todayModels.isEmpty || loader.isWarming { + ModelsSection(loader: loader, theme: theme) + } + if !loader.todayProjects.isEmpty || loader.isWarming { + SessionsSection(loader: loader, theme: theme, showAll: $showAllSessions) + } + Text("Model and project costs may combine estimates and tool reports.") + .font(.system(size: 9, design: theme.fonts.bodyDesign)) + .foregroundColor(theme.backgroundMode.secondaryTextColor) + DisclosureGroup("Usage details", isExpanded: $showUsageDetails) { + VStack(spacing: 14) { + SignalsRibbon(loader: loader, theme: theme) + StatsGrid(loader: loader, theme: theme) + if loader.recentDaily.count > 1 || loader.isWarming { + WeekSection(loader: loader, theme: theme) + } + } + .padding(.top, 10) + } + .font(.system(size: 11, weight: .medium, design: theme.fonts.bodyDesign)) + .tint(theme.backgroundMode.secondaryTextColor) + } + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift new file mode 100644 index 0000000..4c4fe89 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TokmeterBar + +private struct DemoScene: Decodable { + let caption: String + let tokens: Int + let signals: StatbarSignals + let models: [ModelData] + let projects: [ProjectData] +} + +final class DemoRenderTests: XCTestCase { + /// Opt-in artifact rendering uses the production SwiftUI views and synthetic data. + @MainActor + func testRenderWalkthrough() throws { + guard let directory = ProcessInfo.processInfo.environment["TOKMETER_DEMO_DIR"] else { + throw XCTSkip("Set TOKMETER_DEMO_DIR to render the public walkthrough") + } + let root = URL(fileURLWithPath: directory) + let scenes = try JSONDecoder().decode([DemoScene].self, from: Data(contentsOf: root.appendingPathComponent("snapshots.json"))) + for (index, scene) in scenes.enumerated() { + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = false + loader.hasFreshData = true + loader.todayTokens = scene.tokens + loader.statbarSignals = scene.signals + loader.todayModels = scene.models.map(TokmeterLoader.toUsage) + loader.topModels = loader.todayModels + loader.todayProjects = scene.projects + let view = VStack(alignment: .leading, spacing: 0) { + Text(scene.caption) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .padding(16) + HeroHeader(loader: loader, theme: .nebula, breathToggle: false, isVisible: false, showCachePanel: .constant(false)) + UsageOverview(loader: loader, theme: .nebula) + .padding(16) + Spacer(minLength: 8) + Text("DEMO DATA · API estimates are not your subscription bill") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .padding(16) + } + .frame(width: 400, height: 560, alignment: .topLeading) + .background(Color(red: 0.035, green: 0.04, blue: 0.07)) + .environment(\.colorScheme, .dark) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.nsImage) + let tiff = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiff)) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try png.write(to: root.appendingPathComponent(String(format: "scene-%02d.png", index))) + } + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/TodayUsageTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/TodayUsageTests.swift new file mode 100644 index 0000000..7298b69 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/TodayUsageTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import TokmeterBar + +final class TodayUsageTests: XCTestCase { + @MainActor + func testIdleTodayDoesNotInheritLastActiveDay() throws { + let loader = TokmeterLoader(startPolling: false) + let old = try JSONDecoder().decode(DailyData.self, from: Data(""" + {"date":"2026-09-04","totalTokens":1100,"cost":1.25} + """.utf8)) + let now = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 6, hour: 12))! + loader.todayTokens = 999 + loader.todayCost = 9 + loader.applyToday(from: [old], now: now) + XCTAssertEqual(loader.todayTokens, 0) + XCTAssertEqual(loader.todayCost, 0) + } + + func testCostBreakdownPreservesUnavailableAndReportedZero() throws { + let basis = try JSONDecoder().decode(CostBasis.self, from: Data(""" + {"estimatedCost":1.25,"reportedCost":0,"unclassifiedCost":0,"estimatedRecords":2,"reportedRecords":1,"unavailableRecords":3} + """.utf8)) + XCTAssertEqual(basis.reportedRecords, 1) + XCTAssertEqual(basis.unavailableRecords, 3) + XCTAssertEqual(basis.estimatedCost, 1.25) + } +} diff --git a/packages/macos-bar/bundle.sh b/packages/macos-bar/bundle.sh index 0aa72c7..0c5e719 100755 --- a/packages/macos-bar/bundle.sh +++ b/packages/macos-bar/bundle.sh @@ -60,8 +60,8 @@ MACOS_DIR="${CONTENTS}/MacOS" RESOURCES_DIR="${CONTENTS}/Resources" FRAMEWORKS_DIR="${CONTENTS}/Frameworks" ENTITLEMENTS="entitlements.plist" -SHORT_VERSION="${CFBundleShortVersionString:-1.9.2}" -BUILD_VERSION="${CFBundleVersion:-44}" +SHORT_VERSION="${CFBundleShortVersionString:-1.10.0}" +BUILD_VERSION="${CFBundleVersion:-46}" SUFEED_URL="${SUFEED_URL:-https://raw.githubusercontent.com/sriinnu/tokmeter/main/packages/macos-bar/appcast.xml}" SUPUBLIC_KEY="${SUPUBLIC_KEY:-}" # populated below if private key is present @@ -110,6 +110,9 @@ if [[ -d "${SPARKLE_XC}" ]]; then fi fi +# License notices and matching source travel with the signed app. +python3 ../../scripts/prepare-license-materials.py macos --destination "${RESOURCES_DIR}/Licenses" + # ─── 4b. Copy the app icon so Finder/Dock don't show a grey placeholder ── # AppIcon.icns is produced by ./generate-icon.sh and committed to the repo. # If it's missing, fall back to generating it on the fly. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 477886d..4b882a2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/drishti", - "version": "1.9.2", + "version": "1.10.0", "description": "दृष्टि — MCP server + live token observatory for AI coding agents", "type": "module", "bin": { @@ -32,6 +32,7 @@ }, "files": ["dist"], "scripts": { + "prepack": "python3 ../../scripts/prepare-license-materials.py npm", "build": "tsc", "dev": "bun src/cli.ts", "serve": "bun src/cli.ts serve", diff --git a/packages/mcp/src/daemon/server.ts b/packages/mcp/src/daemon/server.ts index 35998fc..7ca9df3 100644 --- a/packages/mcp/src/daemon/server.ts +++ b/packages/mcp/src/daemon/server.ts @@ -1017,7 +1017,9 @@ function startHttpApi(): void { // All projects across providers, sorted by most-recently-used descending. // Used by the menubar's expandable session list — supports 10/20/50+ items. const projects = core - .getAllProjects() + .getAllProjects({ + today: new URL(url, "http://localhost").searchParams.get("today") === "true", + }) .slice() .sort((a: { lastUsed: number }, b: { lastUsed: number }) => b.lastUsed - a.lastUsed); json(res, projects.slice(0, 50)); diff --git a/packages/tokmeter/package.json b/packages/tokmeter/package.json index deb6f73..56f4f5f 100644 --- a/packages/tokmeter/package.json +++ b/packages/tokmeter/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter", - "version": "1.9.2", + "version": "1.10.0", "description": "Token usage tracking for AI coding agents — parsers, CLI, and TUI", "type": "module", "main": "dist/core/index.js", @@ -25,6 +25,7 @@ }, "files": ["dist"], "scripts": { + "prepack": "python3 ../../scripts/prepare-license-materials.py npm", "build": "rm -rf dist && mkdir -p dist/core dist/cli dist/tui && cp -r ../core/dist/* dist/core/ && cp -r ../cli/dist/* dist/cli/ && cp -r ../tui/dist/* dist/tui/" }, "dependencies": { diff --git a/packages/tui/package.json b/packages/tui/package.json index c28de42..9a89a64 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-tui", - "version": "1.9.2", + "version": "1.10.0", "private": true, "description": "Token usage tracking TUI \u2014 interactive terminal UI with charts", "type": "module", diff --git a/packages/web/package.json b/packages/web/package.json index 6c77cbf..6ae7b50 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-web", - "version": "1.9.2", + "version": "1.10.0", "private": true, "description": "Token usage tracking web dashboard \u2014 React + Plotly", "type": "module", diff --git a/packages/web/src/pages/dashboard/DashboardOverviewSection.tsx b/packages/web/src/pages/dashboard/DashboardOverviewSection.tsx index cdbb378..16b280c 100644 --- a/packages/web/src/pages/dashboard/DashboardOverviewSection.tsx +++ b/packages/web/src/pages/dashboard/DashboardOverviewSection.tsx @@ -37,6 +37,10 @@ export const DashboardOverviewSection = memo(function DashboardOverviewSection({
{insights.spotlight.eyebrow}

{insights.spotlight.title}

{insights.spotlight.body}

+

+ Cost totals combine API-rate estimates and tool-reported amounts; they are not a + verified subscription bill. Missing prices are excluded. +

{insights.spotlight.chips.map((chip) => ( @@ -135,7 +139,7 @@ export const DashboardOverviewSection = memo(function DashboardOverviewSection({
diff --git a/scripts/generate-demo.ts b/scripts/generate-demo.ts new file mode 100644 index 0000000..c9b95f7 --- /dev/null +++ b/scripts/generate-demo.ts @@ -0,0 +1,84 @@ +/** Offline, synthetic data for the native walkthrough. Never reads user logs. */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + computeAllProjectsFromState, + computeModelCostsFromState, +} from "../packages/core/src/aggregate-consumers.js"; +import { DailyAccumulator } from "../packages/core/src/aggregates-store.js"; +import { createRecord } from "../packages/core/src/parsers/utils.js"; +import { computeStatbarSignals } from "../packages/core/src/signals.js"; +const out = resolve("docs/assets/demo"); +mkdirSync(out, { recursive: true }); +const now = new Date(2026, 8, 6, 12).getTime(); +const records = [ + createRecord({ + timestamp: now - 60000, + provider: "codex", + model: "gpt-6-astra", + project: "sample-api", + inputTokens: 20000, + cacheReadTokens: 600000, + outputTokens: 4000, + reasoningTokens: 1000, + cost: 1.05, + }), + createRecord({ + timestamp: now - 30000, + provider: "claude-code", + model: "claude-sonnet-4-6", + project: "sample-web", + inputTokens: 20000, + cacheReadTokens: 300000, + outputTokens: 30000, + cost: 1, + usage: { cost: "calculated" }, + }), +]; +const missing = createRecord({ + timestamp: now - 10000, + provider: "codex", + model: "new-model", + project: "sample-api", + inputTokens: 40000, + outputTokens: 10000, + usage: { cost: "not_exposed" }, +}); +const reported = createRecord({ + timestamp: now - 5000, + provider: "cursor", + model: "tool-reported-model", + project: "sample-web", + inputTokens: 20000, + outputTokens: 5000, + cost: 0.4, + usage: { cost: "direct" }, +}); +const scenes = [ + { caption: "Your day starts with a clear usage view", records: [] }, + { caption: "See today's models and projects", records }, + { caption: "Missing prices stay unavailable", records: [...records, missing] }, + { caption: "Tool reports stay separate from estimates", records: [...records, reported] }, +]; +const snapshots = scenes.map((scene) => { + const acc = new DailyAccumulator("2026-09-06"); + acc.foldAll(scene.records); + return { + caption: scene.caption, + tokens: scene.records.reduce( + (n, r) => + n + + r.inputTokens + + r.outputTokens + + r.cacheReadTokens + + r.cacheWriteTokens + + r.reasoningTokens, + 0 + ), + signals: computeStatbarSignals(scene.records, now), + models: computeModelCostsFromState(new Map(), acc, {}), + projects: computeAllProjectsFromState(new Map(), acc, {}), + }; +}); +writeFileSync(resolve(out, "snapshots.json"), `${JSON.stringify(snapshots, null, 2)}\n`); +console.log(`Wrote ${snapshots.length} synthetic scenes. No provider or user-data access.`); diff --git a/scripts/prepare-license-materials.py b/scripts/prepare-license-materials.py new file mode 100644 index 0000000..25f25a7 --- /dev/null +++ b/scripts/prepare-license-materials.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Attach license texts and local build source to distributable artifacts.""" + +import argparse +import shutil +import sys +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def source_files(): + # Explicit source/build inputs only: never sweep the working directory, + # credentials, local usage, personal notes, or generated build trees. + files = set() + for name in ("LICENSE", "README.md", "CHANGELOG.md", "package.json", "bun.lock", + "tsconfig.base.json", "biome.json", "vitest.config.ts", + "docs/licensing.md"): + path = ROOT / name + if path.is_file(): + files.add(path) + for path in (ROOT / "scripts").iterdir(): + if path.suffix in (".sh", ".ts", ".py"): + files.add(path) + for package in (ROOT / "packages").iterdir(): + if not package.is_dir(): + continue + for directory in ("src", "Sources", "Tests", "scripts"): + base = package / directory + if base.exists(): + for path in base.rglob("*"): + if path.is_file() and path.suffix in ( + ".ts", ".tsx", ".swift", ".json", ".css", ".html", ".svg", ".sh", ".py" + ): + files.add(path) + for pattern in ("package.json", "tsconfig*.json", "vite.config.*", "index.html", + "LICENSE", "README.md", "Package.swift", "Package.resolved", + "*.sh", "entitlements.plist", "AppIcon.icns"): + files.update(path for path in package.glob(pattern) if path.is_file()) + for path in sorted(files): + if path.is_symlink(): + raise RuntimeError(f"Source input must not be a symlink: {path.relative_to(ROOT)}") + yield path + + +def prepare(destination, sparkle_license=None): + destination.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / "LICENSE", destination / "AGPL-3.0-only.txt") + shutil.copyfile(ROOT / "packages/core/LICENSE", destination / "MPL-2.0.txt") + shutil.copyfile(ROOT / "docs/licensing.md", destination / "README.md") + if sparkle_license is not None: + if not sparkle_license.is_file(): + raise RuntimeError("The bundled Sparkle artifact must supply its LICENSE") + shutil.copyfile(sparkle_license, destination / "Sparkle.txt") + with tarfile.open(destination / "tokmeter-source.tar.gz", "w:gz") as archive: + for path in source_files(): + archive.add(path, arcname=Path("tokmeter-source") / path.relative_to(ROOT), recursive=False) + print(f"License texts and source prepared: {destination.relative_to(ROOT) if destination.is_relative_to(ROOT) else destination}", file=sys.stderr) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("npm", "macos")) + parser.add_argument("--destination", type=Path) + args = parser.parse_args() + if args.mode == "npm": + for package in ("tokmeter", "mcp"): + dist = ROOT / "packages" / package / "dist" + if not dist.is_dir(): + raise RuntimeError(f"Build {package} before preparing its licenses") + prepare(dist / "licenses") + else: + if args.destination is None: + parser.error("macos requires --destination") + prepare(args.destination.resolve(), ROOT / "packages/macos-bar/.build/artifacts/sparkle/Sparkle/LICENSE") diff --git a/scripts/prepare-packages.sh b/scripts/prepare-packages.sh new file mode 100644 index 0000000..730039f --- /dev/null +++ b/scripts/prepare-packages.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Create reviewable npm artifacts without publishing. Run bun run build first. +set -euo pipefail +cd "$(dirname "$0")/.." +candidate_dir="${1:-/tmp/tokmeter-candidate}" +mkdir -p "$candidate_dir" +candidate_dir="$(cd "$candidate_dir" && pwd)" +python3 scripts/prepare-license-materials.py npm +for package in tokmeter mcp; do + version="$(node -p "require('./packages/$package/package.json').version")" + artifact="$candidate_dir/$package-$version.tgz" + [[ -d "packages/$package/dist" ]] || { echo "Missing build for $package" >&2; exit 1; } + (cd "packages/$package" && bun pm pack --filename "$artifact" --ignore-scripts --quiet) + # Bun resolves workspace:* to the actual release version. Verify that the + # tarball is installable by npm before a release can publish it. + tar -xOf "$artifact" package/package.json | node -e ' + let input=""; + process.stdin.on("data",d=>input+=d).on("end",()=>{ + const p=JSON.parse(input); + for(const section of ["dependencies","optionalDependencies","peerDependencies"]) + for(const [name,version] of Object.entries(p[section]??{})) + if(version.startsWith("workspace:")) throw new Error(`Unresolved workspace dependency: ${name}`); + console.log(`Verified ${p.name}@${p.version}`); + });' +done diff --git a/scripts/release.sh b/scripts/release.sh index 612557d..ab3aeb0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -105,11 +105,13 @@ fi if [[ $SKIP_NPM -eq 0 ]]; then say "8/10 npm publish" if confirm "Publish public packages to npm?"; then - for pj in packages/*/package.json; do - node -e 'process.exit(JSON.parse(require("fs").readFileSync(process.argv[1])).private?0:1)' "$pj" && continue - name="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).name)' "$pj")" - echo " → $name" - run "(cd '$(dirname "$pj")' && npm publish --access public)" + # Pack with Bun first so workspace:* dependencies become installable + # semver ranges. npm publish directly from a workspace does not do this. + candidate_dir="${TMPDIR:-/tmp}/tokmeter-release-${VERSION}-$$" + run "bash scripts/prepare-packages.sh '$candidate_dir'" + # Publish the shared core/CLI distribution before the daemon that needs it. + for package in tokmeter mcp; do + run "npm publish '$candidate_dir/$package-$VERSION.tgz' --access public" done else echo " npm publish skipped."; fi else say "8/10 npm publish — skipped (--skip-npm)"; fi