From d8e9a398b808d89222ad7eaba2c66040a9d79570 Mon Sep 17 00:00:00 2001 From: soumyadebroy3 <43921833+soumyadebroy3@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:31:27 +0530 Subject: [PATCH 1/2] =?UTF-8?q?release:=20v2.5.0=20=E2=80=94=20upstream=20?= =?UTF-8?q?parity,=20device=20sharing,=20web=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major upstream-parity release. Adds new providers and cost-accuracy fixes, the context/acting features, and two greenfield subsystems — opt-in LAN device sharing (mutual-TLS) and a loopback-only web dashboard. Nothing network-facing is on by default. - Device sharing: `codeburn share` / `codeburn devices`, mDNS discovery, mutual-TLS + trust-on-first-use cert pinning, 6-digit PIN and approve-style matching-code pairing, bearer tokens bound to the peer cert. Only sanitized aggregates leave the machine — never project names, paths, or session detail. - Web dashboard: `codeburn web`, loopback-only (127.0.0.1, Host/Origin-guarded) React SPA with a granular usage timeline and By Tool / Top Projects / Model Efficiency / Skills / MCP / Subagents panels plus a context explorer. - Context token breakdown, acting layer (optimize --apply, act list|undo|report), overview/audit/price-override commands, Codex credits + a menubar credits hero, Lifetime (all-time) period, and new providers (Grok, zcode, Hermes, Open Design, Zed, LingTai, Devin, ZeroStack). Cursor real composer-context tokens. - Security: pairing confirmation code widened 3 -> 6 digits (collision-resistant); manual host+PIN pairing warns to verify the typed address. tsc clean, 1121 tests pass, Swift menubar and web SPA build. --- CHANGELOG.md | 50 + README.md | 9 + dash/index.html | 13 + dash/package-lock.json | 2949 +++++++++++++++++ dash/package.json | 30 + dash/public/codeburn-logo.png | Bin 0 -> 127999 bytes dash/src/App.tsx | 717 ++++ dash/src/components/BarList.tsx | 28 + dash/src/components/ContextExplorer.tsx | 222 ++ dash/src/components/DataTable.tsx | 38 + dash/src/components/DeviceSearchModal.tsx | 123 + dash/src/components/MetricCard.tsx | 24 + dash/src/components/UsageChart.tsx | 385 +++ dash/src/components/ui/card.tsx | 6 + dash/src/components/ui/skeleton.tsx | 5 + dash/src/index.css | 140 + dash/src/lib/api.ts | 299 ++ dash/src/lib/utils.ts | 69 + dash/src/main.tsx | 47 + dash/tsconfig.json | 23 + dash/vite.config.ts | 25 + docs/providers/devin.md | 191 ++ docs/providers/grok.md | 45 + docs/providers/hermes.md | 67 + docs/providers/lingtai-tui.md | 88 + docs/providers/open-design.md | 54 + docs/providers/zcode.md | 89 + docs/providers/zed.md | 84 + docs/providers/zerostack.md | 48 + gnome/indicator.js | 1 + mac/Scripts/package-app.sh | 19 + mac/Sources/CodeBurnMenubar/AppStore.swift | 10 +- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 59 +- .../CodeBurnMenubar/CurrencyState.swift | 3 +- .../Data/ClaudeSubscriptionService.swift | 50 +- .../CodeBurnMenubar/Data/DataClient.swift | 28 +- .../CodeBurnMenubar/Data/MenubarPayload.swift | 9 +- .../Data/SubscriptionUsage.swift | 10 + .../Data/UsageRefreshCadence.swift | 42 + .../CodeBurnMenubar/RefreshCadence.swift | 50 + .../Security/CodeburnCLI.swift | 2 +- .../Views/HeatmapSection.swift | 83 +- .../CodeBurnMenubar/Views/HeroSection.swift | 76 +- .../CodeBurnMenubar/Views/SettingsView.swift | 21 + .../ClaudeSubscriptionParsingTests.swift | 77 + .../DataClientProcessTests.swift | 30 + .../RefreshCadenceTests.swift | 73 + package-lock.json | 335 +- package.json | 8 +- src/act/apply.ts | 100 + src/act/backup.ts | 78 + src/act/cli.ts | 85 + src/act/journal.ts | 93 + src/act/optimize-apply.ts | 186 ++ src/act/plans.ts | 446 +++ src/act/report.ts | 590 ++++ src/act/types.ts | 66 + src/act/undo.ts | 77 + src/audit-report.ts | 220 ++ src/bash-utils.ts | 23 +- src/cli-date.ts | 62 +- src/codex-cache.ts | 4 +- src/codex-credits.ts | 57 + src/config.ts | 2 + src/context-tree-codex.ts | 341 ++ src/context-tree.ts | 739 +++++ src/context-tui.tsx | 203 ++ src/currency.ts | 2 +- src/cursor-cache.ts | 9 +- src/daily-cache.ts | 36 +- src/dashboard.tsx | 4 + src/device-name.ts | 32 + src/export.ts | 22 + src/fs-utils.ts | 17 +- src/main.ts | 530 ++- src/menubar-json.ts | 112 + src/models.ts | 238 +- src/optimize.ts | 189 +- src/overview.ts | 237 ++ src/parser.ts | 4 +- src/providers/codex.ts | 11 + src/providers/cursor-agent.ts | 6 +- src/providers/cursor.ts | 297 +- src/providers/devin.ts | 555 ++++ src/providers/grok.ts | 273 ++ src/providers/hermes.ts | 471 +++ src/providers/index.ts | 54 +- src/providers/kiro.ts | 4 + src/providers/lingtai-tui.ts | 426 +++ src/providers/open-design.ts | 259 ++ src/providers/opencode-file-parser.ts | 154 + src/providers/opencode.ts | 14 + src/providers/pi.ts | 1 + src/providers/session-message.ts | 168 + src/providers/zcode.ts | 227 ++ src/providers/zed.ts | 233 ++ src/providers/zerostack.ts | 171 + src/sharing/client.ts | 111 + src/sharing/discovery.ts | 85 + src/sharing/host.ts | 245 ++ src/sharing/identity.ts | 58 + src/sharing/pairing.ts | 120 + src/sharing/prompt.ts | 30 + src/sharing/sanitize.ts | 45 + src/sharing/share-controller.ts | 151 + src/sharing/share-run.ts | 94 + src/sharing/share-server.ts | 193 ++ src/sharing/store.ts | 64 + src/text-table.ts | 43 + src/usage-payload.ts | 197 ++ src/usage-timeline.ts | Bin 0 -> 5687 bytes src/web-dashboard.ts | 419 +++ tests/act-journal.test.ts | 215 ++ tests/act-report.test.ts | 435 +++ tests/act-undo.test.ts | 307 ++ tests/audit-report.test.ts | 112 + tests/bash-commands.test.ts | 37 + tests/cli-date.test.ts | 2 +- tests/cli-export-date-range.test.ts | 2 +- tests/cli-plan.test.ts | 4 +- tests/cli-provider-validation.test.ts | 66 + tests/codex-credits.test.ts | 53 + tests/context-tree.test.ts | 205 ++ tests/currency-rounding.test.ts | 1 + tests/daily-cache.test.ts | 57 +- tests/export.test.ts | 32 +- .../data/runs/run-mixed/events.jsonl | 5 + .../data/runs/run-no-usage/events.jsonl | 3 + .../data/runs/run-start-seeded/events.jsonl | 3 + tests/menubar-snapshot-contract.test.ts | 2 + tests/models.test.ts | 71 + tests/optimize-apply.test.ts | 655 ++++ tests/overview.test.ts | 100 + tests/price-override.test.ts | 154 + tests/provider-registry.test.ts | 57 +- tests/providers/codex.test.ts | 53 + tests/providers/cursor-agent.test.ts | 22 + tests/providers/cursor-large-db-cap.test.ts | 146 + tests/providers/cursor-real-tokens.test.ts | 230 ++ tests/providers/devin.test.ts | 693 ++++ tests/providers/grok.test.ts | 185 ++ tests/providers/hermes.test.ts | 548 +++ tests/providers/lingtai-tui.test.ts | 204 ++ tests/providers/open-design.test.ts | 159 + tests/providers/opencode-file.test.ts | 202 ++ tests/providers/pi.test.ts | 1 + tests/providers/zcode.test.ts | 141 + tests/providers/zed.test.ts | 268 ++ tests/providers/zerostack.test.ts | 177 + tests/sharing/approve.test.ts | 62 + tests/sharing/connect-timeout.test.ts | 15 + tests/sharing/host.test.ts | 258 ++ tests/sharing/pairing.test.ts | 112 + tests/sharing/sanitize.test.ts | 64 + tests/sharing/transport.test.ts | 154 + tests/web-dashboard.test.ts | 175 + vitest.config.ts | 13 + windows/package.json | 2 +- 158 files changed, 23071 insertions(+), 228 deletions(-) create mode 100644 dash/index.html create mode 100644 dash/package-lock.json create mode 100644 dash/package.json create mode 100644 dash/public/codeburn-logo.png create mode 100644 dash/src/App.tsx create mode 100644 dash/src/components/BarList.tsx create mode 100644 dash/src/components/ContextExplorer.tsx create mode 100644 dash/src/components/DataTable.tsx create mode 100644 dash/src/components/DeviceSearchModal.tsx create mode 100644 dash/src/components/MetricCard.tsx create mode 100644 dash/src/components/UsageChart.tsx create mode 100644 dash/src/components/ui/card.tsx create mode 100644 dash/src/components/ui/skeleton.tsx create mode 100644 dash/src/index.css create mode 100644 dash/src/lib/api.ts create mode 100644 dash/src/lib/utils.ts create mode 100644 dash/src/main.tsx create mode 100644 dash/tsconfig.json create mode 100644 dash/vite.config.ts create mode 100644 docs/providers/devin.md create mode 100644 docs/providers/grok.md create mode 100644 docs/providers/hermes.md create mode 100644 docs/providers/lingtai-tui.md create mode 100644 docs/providers/open-design.md create mode 100644 docs/providers/zcode.md create mode 100644 docs/providers/zed.md create mode 100644 docs/providers/zerostack.md create mode 100644 mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift create mode 100644 mac/Sources/CodeBurnMenubar/RefreshCadence.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift create mode 100644 src/act/apply.ts create mode 100644 src/act/backup.ts create mode 100644 src/act/cli.ts create mode 100644 src/act/journal.ts create mode 100644 src/act/optimize-apply.ts create mode 100644 src/act/plans.ts create mode 100644 src/act/report.ts create mode 100644 src/act/types.ts create mode 100644 src/act/undo.ts create mode 100644 src/audit-report.ts create mode 100644 src/codex-credits.ts create mode 100644 src/context-tree-codex.ts create mode 100644 src/context-tree.ts create mode 100644 src/context-tui.tsx create mode 100644 src/device-name.ts create mode 100644 src/overview.ts create mode 100644 src/providers/devin.ts create mode 100644 src/providers/grok.ts create mode 100644 src/providers/hermes.ts create mode 100644 src/providers/lingtai-tui.ts create mode 100644 src/providers/open-design.ts create mode 100644 src/providers/opencode-file-parser.ts create mode 100644 src/providers/session-message.ts create mode 100644 src/providers/zcode.ts create mode 100644 src/providers/zed.ts create mode 100644 src/providers/zerostack.ts create mode 100644 src/sharing/client.ts create mode 100644 src/sharing/discovery.ts create mode 100644 src/sharing/host.ts create mode 100644 src/sharing/identity.ts create mode 100644 src/sharing/pairing.ts create mode 100644 src/sharing/prompt.ts create mode 100644 src/sharing/sanitize.ts create mode 100644 src/sharing/share-controller.ts create mode 100644 src/sharing/share-run.ts create mode 100644 src/sharing/share-server.ts create mode 100644 src/sharing/store.ts create mode 100644 src/text-table.ts create mode 100644 src/usage-payload.ts create mode 100644 src/usage-timeline.ts create mode 100644 src/web-dashboard.ts create mode 100644 tests/act-journal.test.ts create mode 100644 tests/act-report.test.ts create mode 100644 tests/act-undo.test.ts create mode 100644 tests/audit-report.test.ts create mode 100644 tests/cli-provider-validation.test.ts create mode 100644 tests/codex-credits.test.ts create mode 100644 tests/context-tree.test.ts create mode 100644 tests/fixtures/open-design/namespaces/release-stable/data/runs/run-mixed/events.jsonl create mode 100644 tests/fixtures/open-design/namespaces/release-stable/data/runs/run-no-usage/events.jsonl create mode 100644 tests/fixtures/open-design/namespaces/release-stable/data/runs/run-start-seeded/events.jsonl create mode 100644 tests/optimize-apply.test.ts create mode 100644 tests/overview.test.ts create mode 100644 tests/price-override.test.ts create mode 100644 tests/providers/cursor-large-db-cap.test.ts create mode 100644 tests/providers/cursor-real-tokens.test.ts create mode 100644 tests/providers/devin.test.ts create mode 100644 tests/providers/grok.test.ts create mode 100644 tests/providers/hermes.test.ts create mode 100644 tests/providers/lingtai-tui.test.ts create mode 100644 tests/providers/open-design.test.ts create mode 100644 tests/providers/opencode-file.test.ts create mode 100644 tests/providers/zcode.test.ts create mode 100644 tests/providers/zed.test.ts create mode 100644 tests/providers/zerostack.test.ts create mode 100644 tests/sharing/approve.test.ts create mode 100644 tests/sharing/connect-timeout.test.ts create mode 100644 tests/sharing/host.test.ts create mode 100644 tests/sharing/pairing.test.ts create mode 100644 tests/sharing/sanitize.test.ts create mode 100644 tests/sharing/transport.test.ts create mode 100644 tests/web-dashboard.test.ts create mode 100644 vitest.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58760b7..9ce3c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## 2.5.0 - 2026-07-23 + +Major upstream-parity release. Alongside new providers and a batch of +cost-accuracy fixes, this adds the context/acting features and two greenfield +subsystems: opt-in LAN **device sharing** (mutual-TLS) and a local **web +dashboard**. Nothing network-facing is on by default. + +### Added +- **Device sharing (opt-in, local network).** `codeburn share` advertises this + Mac over mDNS; `codeburn devices` discovers, pairs with, and pulls usage from + your other Macs, and `status --scope combined` folds them into one view. + Mutual-TLS with trust-on-first-use certificate pinning, a 6-digit PIN and an + approve-style matching-code flow, and bearer tokens bound to the peer's cert + (a stolen token replayed from another device is refused). Only sanitized + aggregates ever leave the machine — never project names, paths, or + per-session detail. (upstream #532/#536/#567/#568) +- **Local web dashboard.** `codeburn web` opens a **loopback-only** (127.0.0.1, + Host/Origin-guarded) React dashboard: hero + metric cards, a granular usage + timeline, and By Tool / Top Projects / Model Efficiency / Skills / MCP / + Subagents panels, plus a context explorer and the device-sharing UI. + (upstream #573/#582/#589/#533/#534/#554/#586) +- **Context token breakdown** — `codeburn context [session]`: what fills the + window, by role, block type, and tool (Claude + Codex). +- **Acting layer** — `codeburn optimize --apply` and `codeburn act + list|undo|report`: apply optimize findings as journaled, reversible edits. +- **Overview, audit, and price-override commands**; Codex credits (CLI + a + cost → tokens → **credits** hero metric in the macOS menubar). +- **Lifetime (all-time) period** across the CLI and web dashboard, with weekly + timeline bucketing for multi-year spans. +- **New providers**: Grok, zcode, Hermes, Open Design, Zed, LingTai, Devin, and + ZeroStack. (upstream Tier 4) +- **Cursor real composer-context tokens** and Cursor-published composer pricing. + (upstream #512/#575) + +### Fixed +- **Cost-accuracy / data-integrity** batch: model-alias and pricing-variant + fixes, per-provider daily-cache accounting (cache-version bump forces a + one-time re-hydration), and menubar-json fast-path parse-once. (Tiers 1–2) +- **ZeroStack** no longer ~10× undercounts (cache tokens were dropped); **Zed** + invalid-UTF-8 threads no longer crash the run; **Grok** prices correctly + (dead alias → `grok-code-fast-1`). + +### Security +- Device-sharing pairing confirmation code widened **3 → 6 digits** + (collision-resistant); the manual host+PIN pairing path now prints a + trust-the-address warning and points at discover-and-approve as the safer + default. Egress boundary verified: the sharing path never computes the + enriched web-only breakdowns, and payloads are sanitized on send and re-sanitized + on receipt. + ## 2.4.9 - 2026-06-12 Sync with upstream: ten ported fixes and features, including a critical diff --git a/README.md b/README.md index 695c96d..4c0ef55 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,14 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr | | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) | | | Crush | Yes | [crush.md](docs/providers/crush.md) | | | Mux (coder) | Yes | [mux.md](docs/providers/mux.md) | +| | Devin | Yes | [devin.md](docs/providers/devin.md) | +| | Grok Build | Yes | [grok.md](docs/providers/grok.md) | +| | Hermes | Yes | [hermes.md](docs/providers/hermes.md) | +| | LingTai | Yes | [lingtai-tui.md](docs/providers/lingtai-tui.md) | +| | Open Design | Yes | [open-design.md](docs/providers/open-design.md) | +| | Zed | Yes | [zed.md](docs/providers/zed.md) | +| | ZCode (z.ai) | Yes | [zcode.md](docs/providers/zcode.md) | +| | Zerostack | Yes | [zerostack.md](docs/providers/zerostack.md) | Paths shown are for macOS. Linux and Windows equivalents are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/soumyadebroy3/codeburn/issues). @@ -268,6 +276,7 @@ codeburn currency GBP # set to British Pounds codeburn currency AUD # set to Australian Dollars codeburn currency JPY # set to Japanese Yen codeburn currency CNY # set to Chinese Yuan +codeburn currency RON # set to Romanian Leu codeburn currency # show current setting codeburn currency --reset # back to USD ``` diff --git a/dash/index.html b/dash/index.html new file mode 100644 index 0000000..978f7b9 --- /dev/null +++ b/dash/index.html @@ -0,0 +1,13 @@ + + + + + + + CodeBurn - Local Dashboard + + +
+ + + diff --git a/dash/package-lock.json b/dash/package-lock.json new file mode 100644 index 0000000..9999f55 --- /dev/null +++ b/dash/package-lock.json @@ -0,0 +1,2949 @@ +{ + "name": "codeburn-dash", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeburn-dash", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.62.7", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^3.3.0", + "tailwind-merge": "^3.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.13", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.13", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/dash/package.json b/dash/package.json new file mode 100644 index 0000000..bbdd3fc --- /dev/null +++ b/dash/package.json @@ -0,0 +1,30 @@ +{ + "name": "codeburn-dash", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.7", + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^3.3.0", + "tailwind-merge": "^3.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.13", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.13", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/dash/public/codeburn-logo.png b/dash/public/codeburn-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f0a8cdc12f523156a0ec085eeea188e1292ad2ee GIT binary patch literal 127999 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%0DVElMMHrEsu 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

{title}

+ {children} + + ) +} + +function SideLink({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ) +} + +function Switch({ on }: { on: boolean }) { + return ( + + + + ) +} + +function Stat({ label: lbl, value }: { label: string; value: string }) { + return ( +
+ {lbl} + {value} +
+ ) +} + +// One device's full dashboard. Remote devices arrive sanitized, so their +// project and session detail is intentionally absent. +function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: boolean; unit: Unit }) { + const c = payload?.current + // Cache cards read the period-scoped `current` totals, matching Cost/Calls/ + // Tokens. `history.daily` is the 365-day backfill that feeds the trend chart + // only; summing it here over-counted the cards for shorter periods (#583). + const cacheWrite = c?.cacheWriteTokens ?? 0 + const cacheRead = c?.cacheReadTokens ?? 0 + const toolBars: BarItem[] = c + ? Object.entries(c.providers).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, value: v, display: usd(v) })) + : [] + const activityBars: BarItem[] = c + ? c.topActivities.filter((a) => a.cost > 0).map((a) => ({ name: a.name, value: a.cost, display: usd(a.cost) })) + : [] + + return ( + <> + +
+
+
+ {c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '} +
+
+ {c ? (unit === 'tokens' ? fmtTokens(c.inputTokens + c.outputTokens) : usd(c.cost)) : } +
+
+
+
+ {!payload ? : ( + + )} +
+
+ +
+ {c ? ( + <> + + + + + + + + + + ) : ( + Array.from({ length: 8 }).map((_, i) => ) + )} +
+ +
+ + + + + m.cost > 0).slice(0, 8).map((m) => ({ + name: m.name, + cost: usd(m.cost), + calls: fmtNum(m.calls), + savings: usd(m.savingsUSD), + }))} + /> + +
+ +
+ + ({ + name: m.name, + costPerEdit: usd(m.costPerEdit), + oneShot: `${Math.round(m.oneShotRate * 100)}%`, + }))} + /> + +
+ +
+ + {isRemote ? ( +

+ Project and session detail stays on that device. Only totals are shared. +

+ ) : ( + ({ + name: p.name, + cost: usd(p.cost), + sessions: fmtNum(p.sessions), + avgCost: usd(p.avgCostPerSession), + }))} + /> + )} +
+ + + +
+ +
+ + ({ name: s.name, calls: fmtNum(s.calls), cost: usd(s.cost) }))} + /> + + + ({ name: s.name, turns: fmtNum(s.turns), cost: usd(s.cost) }))} + /> + +
+ +
+ + ({ name: m.name, calls: fmtNum(m.calls) }))} + /> + + + {c ? ( +
+ + + +
+ ) : ( + + )} +
+
+ + + ({ name: t.name, calls: fmtNum(t.calls) }))} + /> + + + ) +} + +// The "All devices" view: combined totals plus a per-device breakdown. Devices +// are summed for display only; nothing is merged on the server. +function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit }) { + const rows = devices.map((d) => { + const c = d.payload?.current + return { + name: d.name, + local: d.local, + cost: n(c?.cost), + tokens: n(c?.inputTokens) + n(c?.outputTokens), + calls: n(c?.calls), + sessions: n(c?.sessions), + error: d.error, + } + }) + const total = rows.reduce( + (a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }), + { cost: 0, tokens: 0, calls: 0, sessions: 0 }, + ) + const reachable = devices.filter((d) => d.payload).length + + const providers = new Map() + const models = new Map() + const activities = new Map() + let inTok = 0 + let outTok = 0 + let cacheWrite = 0 + let cacheRead = 0 + for (const d of devices) { + const c = d.payload?.current + if (!c) continue + inTok += c.inputTokens + outTok += c.outputTokens + // Period-scoped per device (was summing each device's 365-day backfill, #583). + // `?? 0` mirrors DeviceView and guards the un-normalized bootstrap payload, + // where an older peer may not carry these fields yet (avoids NaN). + cacheWrite += c.cacheWriteTokens ?? 0 + cacheRead += c.cacheReadTokens ?? 0 + for (const [k, v] of Object.entries(c.providers)) providers.set(k, (providers.get(k) ?? 0) + v) + for (const m of c.topModels) models.set(m.name, (models.get(m.name) ?? 0) + m.cost) + for (const a of c.topActivities) activities.set(a.name, (activities.get(a.name) ?? 0) + a.cost) + } + const toolBars: BarItem[] = [...providers.entries()] + .filter(([, v]) => v > 0) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => ({ name: k, value: v, display: usd(v) })) + const modelBars: BarItem[] = [...models.entries()] + .filter(([, v]) => v > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([k, v]) => ({ name: k, value: v, display: usd(v) })) + const taskBars: BarItem[] = [...activities.entries()] + .filter(([, v]) => v > 0) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => ({ name: k, value: v, display: usd(v) })) + + return ( + <> + +
+
+
{`${reachable} device${reachable === 1 ? '' : 's'} · ${fmtNum(total.calls)} calls`}
+
+ {unit === 'tokens' ? fmtTokens(total.tokens) : usd(total.cost)} +
+
+
+
+ +
+
+ +
+ + + + + + + +
+ + + ({ + device: r.name + (r.local ? ' · this Mac' : ''), + cost: r.error ? unreachable : usd(r.cost), + tokens: r.error ? '—' : fmtTokens(r.tokens), + calls: r.error ? '—' : fmtNum(r.calls), + sessions: r.error ? '—' : fmtNum(r.sessions), + }))} + /> + + +
+ + + + + + +
+ +
+ + + +
+ + ) +} + +export function App() { + const [page, setPage] = useState<'usage' | 'context'>('usage') + const [period, setPeriod] = useState('today') + const [provider, setProvider] = useState('all') + const [view, setView] = useState('all') + const [unit, setUnit] = useState('cost') + const [searchOpen, setSearchOpen] = useState(false) + // Mobile only: the sidebar collapses to an off-canvas drawer below md. + // On desktop this flag is inert (the max-md: transform classes don't apply). + const [sidebarOpen, setSidebarOpen] = useState(false) + const [responded, setResponded] = useState>(new Set()) + + const qc = useQueryClient() + + const { data, isError, error, refetch } = useQuery({ + queryKey: ['devices', period, provider], + queryFn: () => fetchDevices(period, provider), + initialData: () => (period === 'today' && provider === 'all' ? window.__CODEBURN_BOOTSTRAP__ : undefined), + // Bootstrap paints instantly but is stale by definition, so refetch at once + // (the default 30s staleTime would otherwise hide a live peer until then). + initialDataUpdatedAt: 0, + // When devices are paired, re-pull periodically so a device that briefly + // dropped (asleep/network blip) reappears on its own instead of staying + // gone until you switch tabs. + refetchInterval: (q) => ((q.state.data?.devices?.some((d) => !d.local) ?? false) ? 20000 : false), + }) + + const { data: shareInfo } = useQuery({ + queryKey: ['share'], + queryFn: shareStatus, + refetchInterval: (q) => (q.state.data?.sharing ? 2500 : 8000), + }) + + const refreshShare = () => qc.invalidateQueries({ queryKey: ['share'] }) + const toggleShare = async () => { + if (shareInfo?.sharing) await stopShare() + else await startShare(shareInfo?.always ?? false) + refreshShare() + } + const toggleAlways = async () => { + await startShare(!(shareInfo?.always ?? false)) + refreshShare() + } + const respondPairing = async (id: string, approve: boolean) => { + setResponded((s) => new Set(s).add(id)) // drop it from the prompt at once so it can't be double-clicked + await approvePairing(id, approve) + refreshShare() + void refetch() + } + const pending = (shareInfo?.pending ?? []).filter((p) => !responded.has(p.id)) + + // Only show devices we could actually reach; an unreachable paired device is + // hidden entirely rather than shown as an error row. + const devices = (data?.devices ?? []).filter((d) => d.payload) + const local = devices.find((d) => d.local) + const multi = devices.some((d) => !d.local) + const viewing = view === 'all' ? undefined : devices.find((d) => d.id === view) + const primary = viewing ?? local + const c0 = primary?.payload?.current + + const providerOptions = useMemo( + () => + c0 + ? Object.entries(c0.providers) + .filter(([, v]) => v > 0) + .sort((a, b) => b[1] - a[1]) + .map(([k]) => k) + : [], + [c0], + ) + + // If the device you're viewing drops off (slept/unreachable), fall back to + // All devices instead of showing an empty panel with nothing selected. + useEffect(() => { + if (view !== 'all' && data && !devices.some((d) => d.id === view)) setView('all') + }, [view, devices, data]) + + // If the selected provider isn't present on the current view, reset to all + // (otherwise a healthy device shows empty under a filter it has no data for). + useEffect(() => { + if (provider !== 'all' && c0 && !providerOptions.includes(provider)) setProvider('all') + }, [provider, providerOptions, c0]) + + const showCombined = multi && view === 'all' + const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…') + const label = local?.payload?.current?.label ?? '' + + return ( +
+
+
+ +
+ CodeBurn + + CodeBurn + + usage +
+ +
+ {(['usage', 'context'] as const).map((pg) => ( + + ))} +
+ +
+ {page === 'usage' && ( + <> +
+ {PERIODS.map((p) => ( + + ))} +
+
+ {(['cost', 'tokens'] as Unit[]).map((u) => ( + + ))} +
+ + + )} +
+
+ +
+ {sidebarOpen && ( + + {page === 'usage' && ( + <> +
+

Devices

+ {multi && ( + { setView('all'); setSidebarOpen(false) }}> + All devices + + )} + {devices.map((d) => ( + { setView(d.id); setSidebarOpen(false) }} + > + {d.name} + + ))} + {devices.length === 0 &&

Loading…

} +
+ + + + )} + +
+

Share

+ + {shareInfo?.sharing && ( +
+

+ Discoverable as “{shareInfo.name}” · {shareInfo.peers} paired +

+ +
+ )} +
+ +
+

+ Local only. Nothing leaves your machine; only totals are shared between your devices. +

+
+ + +
+
+

{page === 'context' ? 'Context' : viewTitle}

+ {page === 'usage' ? label : ''} +
+ + {page === 'context' ? ( + + ) : showCombined ? ( + + ) : ( + + )} + + {page === 'usage' && isError && ( +
Failed to load: {String((error as Error)?.message)}
+ )} +
+
+
+ + {searchOpen && setSearchOpen(false)} onPaired={() => void refetch()} />} + + {pending.length > 0 && ( +
+
+
+

Incoming pairing request

+
+
+ {pending.map((p) => ( +
+

+ “{p.name}” wants to pair with this device. +

+

+ Confirm this code matches on that device: {p.code} +

+
+ + +
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/dash/src/components/BarList.tsx b/dash/src/components/BarList.tsx new file mode 100644 index 0000000..93f9385 --- /dev/null +++ b/dash/src/components/BarList.tsx @@ -0,0 +1,28 @@ +export type BarItem = { name: string; value: number; display: string } + +export function BarList({ items, total }: { items: BarItem[]; total?: number }) { + if (!items.length) return
No data.
+ const max = Math.max(...items.map((i) => i.value), 1) + return ( +
+ {items.map((it) => { + const pct = Math.max(2, Math.round((it.value / max) * 100)) + const share = total ? Math.round((it.value / total) * 100) + '%' : '' + return ( +
+
{it.name}
+
+
+
+
+ {it.display} {share} +
+
+ ) + })} +
+ ) +} diff --git a/dash/src/components/ContextExplorer.tsx b/dash/src/components/ContextExplorer.tsx new file mode 100644 index 0000000..4bb4ab3 --- /dev/null +++ b/dash/src/components/ContextExplorer.tsx @@ -0,0 +1,222 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' + +import { + fetchContextSessions, + fetchContextTree, + type ContextProvider, + type ContextRow, + type ContextSessionInfo, +} from '@/lib/api' +import { cn, fmtNum, fmtTokens, label } from '@/lib/utils' +import { Card } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' + +const PROVIDERS: Array<{ key: ContextProvider; label: string }> = [ + { key: 'claude', label: 'Claude Code' }, + { key: 'codex', label: 'Codex' }, +] + +function ago(mtimeMs: number): string { + const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000)) + if (mins < 60) return `${mins}m ago` + if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago` + return `${Math.round(mins / (60 * 24))}d ago` +} + +function TreeTable({ rows }: { rows: ContextRow[] }) { + const max = Math.max(1, ...rows.filter((r) => !r.bold).map((r) => r.tokens)) + return ( +
+ {rows.map((r, i) => ( +
0 && 'mt-2')}> + {!r.bold && ( + + )} + + {r.label} + + {fmtNum(r.count)}x + + {fmtTokens(r.tokens)} + +
+ ))} +
+ ) +} + +function Chip({ label: lbl, value }: { label: string; value: string }) { + return ( +
+
{lbl}
+
{value}
+
+ ) +} + +function SessionDetails({ provider, id }: { provider: ContextProvider; id: string }) { + const [scope, setScope] = useState<'effective' | 'full'>('effective') + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['context-tree', provider, id], + queryFn: () => fetchContextTree(provider, id), + staleTime: 60_000, + }) + + if (isLoading) { + return ( +
+ + +

Reading the whole transcript, large sessions take a few seconds…

+
+ ) + } + if (isError || !data) { + return

Failed to load: {String((error as Error)?.message ?? 'unknown')}

+ } + + const view = scope === 'full' ? data.full : data.effective + const rows = scope === 'full' ? data.fullRows : data.effectiveRows + const window = data.reported?.window ?? null + const pct = data.reported && window ? Math.min(100, Math.round((data.reported.context / window) * 100)) : null + + return ( +
+
+ + + + +
+ + {pct !== null && ( +
+
+ {label(data.model)} · live context window + {pct}% +
+
+
= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} /> +
+
+ )} + +
+
+ {(['effective', 'full'] as const).map((s) => ( + + ))} +
+ token counts are estimates; “Context (exact)” comes from API usage +
+ + +
+ ) +} + +function SessionRow({ s, open, onToggle }: { s: ContextSessionInfo; open: boolean; onToggle: () => void }) { + return ( +
+ + {open && ( +
+ +
+ )} +
+ ) +} + +export function ContextExplorer() { + const [provider, setProvider] = useState('claude') + const [openId, setOpenId] = useState(null) + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['context-sessions', provider], + queryFn: () => fetchContextSessions(provider), + staleTime: 30_000, + }) + + return ( + <> +
+ {PROVIDERS.map((p) => ( + + ))} + what fills each session’s context window, block by block +
+ + + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + {isError &&

Failed to load sessions: {String((error as Error)?.message)}

} + {data && data.length === 0 &&

No sessions found for this provider.

} + {data?.map((s) => ( + setOpenId(openId === s.sessionId ? null : s.sessionId)} /> + ))} +
+ + ) +} diff --git a/dash/src/components/DataTable.tsx b/dash/src/components/DataTable.tsx new file mode 100644 index 0000000..65e0f34 --- /dev/null +++ b/dash/src/components/DataTable.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export type Column = { key: string; label: string; num?: boolean } + +export function DataTable({ columns, rows }: { columns: Column[]; rows: Array> }) { + if (!rows.length) return
No data.
+ return ( + + + + {columns.map((c) => ( + + ))} + + + + {rows.map((r, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
+ {c.label} +
+ {r[c.key]} +
+ ) +} diff --git a/dash/src/components/DeviceSearchModal.tsx b/dash/src/components/DeviceSearchModal.tsx new file mode 100644 index 0000000..8181c37 --- /dev/null +++ b/dash/src/components/DeviceSearchModal.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react' + +import { scanDevices, pairDevice, type DiscoveredDevice } from '@/lib/api' + +export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void; onPaired: () => void }) { + const [scanning, setScanning] = useState(true) + const [found, setFound] = useState([]) + const [error, setError] = useState(null) + const [pairing, setPairing] = useState(null) + const [status, setStatus] = useState(null) + + const scan = async () => { + setScanning(true) + setError(null) + setStatus(null) + try { + setFound(await scanDevices()) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setScanning(false) + } + } + + useEffect(() => { + void scan() + }, []) + + const connect = async (d: DiscoveredDevice) => { + setPairing(d.fingerprint) + setError(null) + setStatus(`Confirm the code ${d.code} on "${d.name}", then approve there. Waiting...`) + try { + const r = await pairDevice(d) + if (r.ok) { + setStatus(`Connected to "${r.name ?? d.name}".`) + onPaired() + setTimeout(onClose, 700) + } else { + setError(r.error ?? 'Pairing failed') + setStatus(null) + setPairing(null) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setStatus(null) + setPairing(null) + } + } + + return ( +
+
e.stopPropagation()} + > +
+

Search local devices

+
+ + +
+
+ +
+ {scanning ? ( +
+ + Looking for devices on your network... +
+ ) : found.length === 0 ? ( +

+ No devices found. On your other Mac run codeburn share on the same Wi-Fi. +

+ ) : ( +
+ {found.map((d) => ( +
+
+ + + + +
+
+
{d.name}
+
+ {d.host}:{d.port} +
+
+ {d.paired ? ( + Connected + ) : pairing === d.fingerprint ? ( + code {d.code} + ) : ( + + )} +
+ ))} +
+ )} + + {status &&

{status}

} + {error &&

{error}

} +
+
+
+ ) +} diff --git a/dash/src/components/MetricCard.tsx b/dash/src/components/MetricCard.tsx new file mode 100644 index 0000000..b40637d --- /dev/null +++ b/dash/src/components/MetricCard.tsx @@ -0,0 +1,24 @@ +import { Card } from './ui/card' +import { cn } from '@/lib/utils' + +export function MetricCard({ + label, + value, + sub, + accent, +}: { + label: string + value: string + sub?: string + accent?: boolean +}) { + return ( + +
{label}
+
+ {value} +
+ {sub ?
{sub}
: null} +
+ ) +} diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx new file mode 100644 index 0000000..bda3c3b --- /dev/null +++ b/dash/src/components/UsageChart.tsx @@ -0,0 +1,385 @@ +import { useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +import type { DailyEntry, DeviceUsage, GranularHistory } from '@/lib/api' +import { CHART_COLORS, cn, compactUsd, fmtTokens, label, usd } from '@/lib/utils' + +export type Unit = 'cost' | 'tokens' + +const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] +function fmtDay(d: string): string { + const [, m, day] = String(d).split('-') + return m && day ? `${Number(day)} ${MONTHS[Number(m)]}` : d +} + +const TOP_N = 6 + +type Series = { key: string; label: string; color: string } + +function makeTooltip(labels: Record, fmt: (n: number) => string, formatPeriod = fmtDay) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function ChartTooltip({ active, payload, label: lbl }: any) { + if (!active || !payload?.length) return null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items = payload.filter((p: any) => p.value > 0).sort((a: any, b: any) => b.value - a.value) + if (!items.length) return null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const total = items.reduce((s: number, p: any) => s + p.value, 0) + return ( +
+
{formatPeriod(String(lbl))}
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {items.slice(0, 6).map((p: any) => ( +
+ + {labels[String(p.dataKey)] ?? String(p.dataKey)} + {fmt(p.value)} +
+ ))} +
+ Total + {fmt(total)} +
+
+
+ ) + } +} + +type Breakdown = 'sessions' | 'models' + +function pad2(value: number): string { + return String(value).padStart(2, '0') +} + +function fmtTimelineTick(value: string, bucketMinutes: number): string { + const d = new Date(value) + if (!Number.isFinite(d.getTime())) return value + if (bucketMinutes >= 1440) return `${d.getDate()} ${MONTHS[d.getMonth() + 1]}` + if (bucketMinutes >= 60) return `${d.getDate()} ${MONTHS[d.getMonth() + 1]} ${pad2(d.getHours())}:00` + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +function fmtTimelineTooltip(value: string, bucketMinutes: number): string { + const d = new Date(value) + if (!Number.isFinite(d.getTime())) return value + const day = `${d.getDate()} ${MONTHS[d.getMonth() + 1]} ${d.getFullYear()}` + if (bucketMinutes >= 1440) return day + return `${day}, ${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +function bucketLabel(bucketMinutes: number): string { + if (bucketMinutes >= 1440) return 'Daily buckets' + if (bucketMinutes >= 60) return 'Hourly buckets' + return `${bucketMinutes}-minute buckets` +} + +function fmtTimelineUsd(value: number | string): string { + const number = Number(value) + if (!Number.isFinite(number)) return '$0' + const sign = number < 0 ? '-' : '' + const amount = Math.abs(number) + if (amount >= 100) return compactUsd(number) + if (amount >= 10) return `${sign}$${amount.toFixed(0)}` + if (amount >= 1) return `${sign}$${amount.toFixed(1)}` + if (amount >= 0.01) return `${sign}$${amount.toFixed(2)}` + if (amount > 0) return `${sign}$${amount.toFixed(3)}` + return '$0' +} + +function GranularLines({ + timeline, + breakdown, + unit, +}: { + timeline: GranularHistory + breakdown: Breakdown + unit: Unit +}) { + const { rows, series, labels } = useMemo(() => { + const metadata = breakdown === 'sessions' ? timeline.sessionSeries : timeline.modelSeries + const totals = new Map() + for (const point of timeline.points) { + const values = breakdown === 'sessions' ? point.sessions : point.models + for (const value of values) { + const amount = unit === 'tokens' ? value.tokens : value.cost + totals.set(value.seriesId, (totals.get(value.seriesId) ?? 0) + amount) + } + } + + // The backend already folds its beyond-cap remainder into a "*_other" + // series; never give it a top slot or it renders as a second "Other" + // line next to our own display_other fold. + const isBackendOther = (id: string) => id === 'session_other' || id === 'model_other' + const top = [...totals.entries()] + .filter(([id, total]) => total > 0 && !isBackendOther(id)) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_N) + .map(([id]) => id) + const topSet = new Set(top) + const hasOther = [...totals.entries()].some(([id, total]) => total > 0 && !topSet.has(id)) + const keys = hasOther ? [...top, 'display_other'] : top + const metadataById = new Map(metadata.map(item => [item.id, item.label])) + const rowData = timeline.points.map((point) => { + const row: Record = { period: point.timestamp } + for (const key of keys) row[key] = 0 + const values = breakdown === 'sessions' ? point.sessions : point.models + for (const value of values) { + const key = topSet.has(value.seriesId) ? value.seriesId : 'display_other' + if (!(key in row)) continue + row[key] = (row[key] as number) + (unit === 'tokens' ? value.tokens : value.cost) + } + return row + }) + const chartSeries: Series[] = keys.map((key, index) => ({ + key, + label: key === 'display_other' + ? 'Other' + : breakdown === 'models' + ? label(metadataById.get(key) ?? key) + : metadataById.get(key) ?? key, + color: CHART_COLORS[index % CHART_COLORS.length]!, + })) + // Trim LEADING zero-only buckets: the server zero-fills the whole range, so + // a flat zero line before the first real value asserts spend that was never + // recorded. Trimming by value needs no date comparison, so producer/viewer + // timezone skew cannot drop a real first-day bucket, and an all-zero series + // trims to nothing, landing in the established empty state. Idle buckets + // after the first real value stay: those zeros are true. + const firstValueIdx = rowData.findIndex(row => + chartSeries.some(item => Number(row[item.key] ?? 0) > 0), + ) + const rows = firstValueIdx > 0 ? rowData.slice(firstValueIdx) : firstValueIdx === 0 ? rowData : [] + return { + rows, + series: chartSeries, + labels: Object.fromEntries(chartSeries.map(item => [item.key, item.label])), + } + }, [timeline, breakdown, unit]) + + if (series.length === 0) { + return
No timestamped usage in this period.
+ } + + const fmt = unit === 'tokens' ? fmtTokens : usd + const axisFmt = (value: number | string) => (unit === 'tokens' ? fmtTokens(Number(value)) : fmtTimelineUsd(value)) + const Tip = makeTooltip(labels, fmt, value => fmtTimelineTooltip(value, timeline.bucketMinutes)) + + return ( +
+
+ {series.map(item => ( + + + {item.label} + + ))} +
+
+ + + + fmtTimelineTick(String(value), timeline.bucketMinutes)} + /> + + } /> + {series.map(item => ( + + ))} + + +
+
+ ) +} + +function StackedBars({ + rows, + series, + labels, + unit, +}: { + rows: Array> + series: Series[] + labels: Record + unit: Unit +}) { + const fmt = unit === 'tokens' ? fmtTokens : usd + const axisFmt = (v: number | string) => (unit === 'tokens' ? fmtTokens(Number(v)) : compactUsd(Number(v))) + const Tip = makeTooltip(labels, fmt) + return ( +
+ + + + + + } /> + {series.map((s, i) => ( + + ))} + + +
+ ) +} + +// Spend (or tokens) per day, stacked by model (single device). +export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) { + return +} + +function LegacyUsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) { + const { rows, series, labels } = useMemo(() => { + const measure = (m: { cost: number; inputTokens: number; outputTokens: number }) => + unit === 'tokens' ? m.inputTokens + m.outputTokens : m.cost + const totals = new Map() + for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + measure(m)) + const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k) + const topSet = new Set(top) + const hasOther = [...totals.keys()].some((k) => !topSet.has(k)) + const keys = hasOther ? [...top, 'Other'] : top + const rowData = daily.map((d) => { + const row: Record = { period: d.date } + for (const k of keys) row[k] = 0 + for (const m of d.topModels) { + const key = topSet.has(m.name) ? m.name : 'Other' + row[key] = (row[key] as number) + measure(m) + } + return row + }) + const series: Series[] = keys.map((k, i) => ({ key: k, label: label(k), color: CHART_COLORS[i % CHART_COLORS.length]! })) + const labels = Object.fromEntries(series.map((s) => [s.key, s.label])) + return { rows: rowData, series, labels } + }, [daily, unit]) + + return +} + +export function GranularUsageChart({ + daily, + timeline, + unit = 'cost', +}: { + daily: DailyEntry[] + timeline?: GranularHistory + unit?: Unit +}) { + const [selectedBreakdown, setSelectedBreakdown] = useState('sessions') + if (!timeline) return + + const hasSessions = timeline.sessionSeries.length > 0 + const hasModels = timeline.modelSeries.length > 0 + const breakdown = selectedBreakdown === 'sessions' && !hasSessions && hasModels ? 'models' : selectedBreakdown + + return ( +
+
+ + {bucketLabel(timeline.bucketMinutes)} + +
+ {(['sessions', 'models'] as Breakdown[]).map(option => { + const available = option === 'sessions' ? hasSessions : hasModels + return ( + + ) + })} +
+
+ +
+ ) +} + +// Spend (or tokens) per day, stacked by device (one color per device) for the All view. +export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) { + const { rows, series, labels } = useMemo(() => { + const named = devices.filter((d) => d.payload) + const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? [] + // Stable key + color per device (by unique id) so a device keeps its color + // and its bars don't remount when another device drops/returns between + // polls, and two devices sharing a hostname never collide. + const keyOf = (d: DeviceUsage) => 'dev_' + d.id.replace(/[^a-zA-Z0-9]/g, '_') + const colorOf = (id: string) => { + let h = 0 + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0 + return CHART_COLORS[Math.abs(h) % CHART_COLORS.length]! + } + const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b)) + const series: Series[] = named.map((d) => ({ + key: keyOf(d), + label: d.name + (d.local ? ' (this Mac)' : ''), + color: colorOf(d.id), + })) + const rowData = dates.map((date) => { + const row: Record = { period: date } + named.forEach((d) => { + const e = dailyOf(d).find((x) => x.date === date) + row[keyOf(d)] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0 + }) + return row + }) + const labels = Object.fromEntries(series.map((s) => [s.key, s.label])) + return { rows: rowData, series, labels } + }, [devices, unit]) + + return +} diff --git a/dash/src/components/ui/card.tsx b/dash/src/components/ui/card.tsx new file mode 100644 index 0000000..698a7f4 --- /dev/null +++ b/dash/src/components/ui/card.tsx @@ -0,0 +1,6 @@ +import type { HTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +export function Card({ className, ...props }: HTMLAttributes) { + return
+} diff --git a/dash/src/components/ui/skeleton.tsx b/dash/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..bc727f8 --- /dev/null +++ b/dash/src/components/ui/skeleton.tsx @@ -0,0 +1,5 @@ +import { cn } from '@/lib/utils' + +export function Skeleton({ className }: { className?: string }) { + return
+} diff --git a/dash/src/index.css b/dash/src/index.css new file mode 100644 index 0000000..d44ea94 --- /dev/null +++ b/dash/src/index.css @@ -0,0 +1,140 @@ +@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@300..600&display=swap"); +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +/* + * Archival warmth + forensic precision. Warm paper surfaces, ink text, a + * forest-green accent, and a green -> gold -> terracotta ramp for stacked + * charts. No pure white background, no pure black, no cold neutrals. + */ +:root { + font-family: "Geist", "Geist Fallback", system-ui, sans-serif; + --radius: 0.375rem; + + --background: #f6f4ef; + --outer-background: #e9e6de; + --foreground: #16181d; + --card: #ffffff; + --card-foreground: #16181d; + --popover: #ffffff; + --popover-foreground: #16181d; + --muted: #efece4; + --muted-foreground: #5d626b; + --tertiary-foreground: #8a857c; + --heading: #2c5242; + --border: rgba(23, 27, 32, 0.08); + --input: rgba(23, 27, 32, 0.14); + --interactive-secondary: rgba(23, 27, 32, 0.04); + --interactive-secondary-hover: rgba(23, 27, 32, 0.08); + --active-primary: #ffffff; + --accent: #efece4; + --accent-foreground: #16181d; + --subtle: #8a857c; + --primary: #1f8a5b; + --primary-foreground: #ffffff; + --ring: #1f8a5b; + --positive: #1f8a5b; + + --chart-1: #1f8a5b; + --chart-2: #4fd394; + --chart-3: #2c5242; + --chart-4: #d99a3c; + --chart-5: #c8541f; + --chart-6: #2f5fd0; + --chart-7: #7aa86f; + --chart-8: #b5403a; + --chart-9: #3f8f6b; + --chart-10: #a98b4f; + --chart-grid-stroke: rgba(23, 27, 32, 0.07); + + color-scheme: light; +} + +@theme inline { + --color-background: var(--background); + --color-outer-background: var(--outer-background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-tertiary-foreground: var(--tertiary-foreground); + --color-heading: var(--heading); + --color-border: var(--border); + --color-input: var(--input); + --color-interactive-secondary: var(--interactive-secondary); + --color-interactive-secondary-hover: var(--interactive-secondary-hover); + --color-active-primary: var(--active-primary); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-subtle: var(--subtle); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-ring: var(--ring); + --color-positive: var(--positive); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-chart-6: var(--chart-6); + --color-chart-7: var(--chart-7); + --color-chart-8: var(--chart-8); + --color-chart-9: var(--chart-9); + --color-chart-10: var(--chart-10); + --color-chart-grid-stroke: var(--chart-grid-stroke); + + --font-display: "Alga", Georgia, "Times New Roman", serif; + --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + + --radius-sm: calc(var(--radius) - 2px); + --radius-md: var(--radius); + --radius-lg: calc(var(--radius) + 2px); + + --text-xs: 12px; + --text-sm: 13px; + --text-md: 15px; + --text-lg: 17px; + --text-xl: 20px; +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-outer-background text-foreground; + letter-spacing: -0.006em; + font-weight: 400; + margin: 0; + } + ::selection { + background: var(--primary); + color: var(--primary-foreground); + } + * { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--foreground) 16%, transparent) transparent; + } +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.skeleton-shimmer { + background-color: var(--muted); + background-image: linear-gradient( + 90deg, + transparent 0%, + color-mix(in oklch, var(--foreground) 7%, transparent) 50%, + transparent 100% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + animation: shimmer 1.6s ease-in-out infinite; +} diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts new file mode 100644 index 0000000..dbeffa1 --- /dev/null +++ b/dash/src/lib/api.ts @@ -0,0 +1,299 @@ +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime' + +export type ModelDay = { + name: string + cost: number + calls: number + inputTokens: number + outputTokens: number +} + +export type DailyEntry = { + date: string + cost: number + calls: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + topModels: ModelDay[] +} + +export type GranularSeries = { id: string; label: string } +export type GranularValue = { seriesId: string; cost: number; tokens: number } +export type GranularPoint = { + timestamp: string + cost: number + tokens: number + models: GranularValue[] + sessions: GranularValue[] +} +export type GranularHistory = { + bucketMinutes: number + modelSeries: GranularSeries[] + sessionSeries: GranularSeries[] + points: GranularPoint[] +} + +export type Current = { + label: string + cost: number + calls: number + sessions: number + oneShotRate: number | null + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + cacheHitPercent: number + codexCredits: number + topActivities: Array<{ name: string; cost: number; turns: number; oneShotRate: number | null }> + topModels: Array<{ name: string; cost: number; calls: number; savingsUSD: number }> + providers: Record + topProjects: Array<{ name: string; cost: number; sessions: number; avgCostPerSession: number }> + tools: Array<{ name: string; calls: number }> + subagents: Array<{ name: string; calls: number; cost: number }> + skills: Array<{ name: string; turns: number; cost: number }> + mcpServers: Array<{ name: string; calls: number }> + modelEfficiency: Array<{ name: string; costPerEdit: number; oneShotRate: number }> + localModelSavings: { totalUSD: number } + retryTax: { totalUSD: number; retries: number } + routingWaste: { totalSavingsUSD: number } +} + +export type Payload = { + generated: string + current: Current + history: { daily: DailyEntry[]; timeline?: GranularHistory } +} + +export async function fetchUsage(period: Period, provider: string): Promise { + const res = await fetch(`/api/usage?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`) + if (!res.ok) throw new Error(`Request failed (${res.status})`) + return res.json() as Promise +} + +export type DeviceUsage = { + id: string + name: string + local: boolean + payload?: Payload + error?: string +} + +declare global { + interface Window { + __CODEBURN_BOOTSTRAP__?: { devices: DeviceUsage[] } + } +} + +// A device may run a different CodeBurn version and send a payload missing +// fields we treat as required. Fill safe defaults at the boundary so the UI +// can iterate them without crashing (the alternative is a white screen for an +// innocent local user because a peer sent an old shape). +function normalizePayload(p?: Payload): Payload | undefined { + if (!p) return p + const c = (p.current ?? {}) as Partial + const rawTimeline = p.history?.timeline + const timeline = rawTimeline ? { + bucketMinutes: rawTimeline.bucketMinutes ?? 1440, + modelSeries: rawTimeline.modelSeries ?? [], + sessionSeries: rawTimeline.sessionSeries ?? [], + points: (rawTimeline.points ?? []).map((point) => ({ + timestamp: point.timestamp, + cost: point.cost ?? 0, + tokens: point.tokens ?? 0, + models: (point.models ?? []).map((value) => ({ + seriesId: value.seriesId, + cost: value.cost ?? 0, + tokens: value.tokens ?? 0, + })), + sessions: (point.sessions ?? []).map((value) => ({ + seriesId: value.seriesId, + cost: value.cost ?? 0, + tokens: value.tokens ?? 0, + })), + })), + } : undefined + return { + generated: p.generated, + current: { + label: c.label ?? '', + cost: c.cost ?? 0, + calls: c.calls ?? 0, + sessions: c.sessions ?? 0, + oneShotRate: c.oneShotRate ?? null, + inputTokens: c.inputTokens ?? 0, + outputTokens: c.outputTokens ?? 0, + cacheReadTokens: c.cacheReadTokens ?? 0, + cacheWriteTokens: c.cacheWriteTokens ?? 0, + cacheHitPercent: c.cacheHitPercent ?? 0, + codexCredits: c.codexCredits ?? 0, + topActivities: c.topActivities ?? [], + topModels: c.topModels ?? [], + providers: c.providers ?? {}, + topProjects: c.topProjects ?? [], + tools: c.tools ?? [], + subagents: c.subagents ?? [], + skills: c.skills ?? [], + mcpServers: c.mcpServers ?? [], + modelEfficiency: c.modelEfficiency ?? [], + localModelSavings: c.localModelSavings ?? { totalUSD: 0 }, + retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 }, + routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 }, + }, + history: { + daily: (p.history?.daily ?? []).map((d) => ({ + date: d.date, + cost: d.cost ?? 0, + calls: d.calls ?? 0, + inputTokens: d.inputTokens ?? 0, + outputTokens: d.outputTokens ?? 0, + cacheReadTokens: d.cacheReadTokens ?? 0, + cacheWriteTokens: d.cacheWriteTokens ?? 0, + topModels: (d.topModels ?? []).map((m) => ({ + name: m.name, + cost: m.cost ?? 0, + calls: m.calls ?? 0, + inputTokens: m.inputTokens ?? 0, + outputTokens: m.outputTokens ?? 0, + })), + })), + ...(timeline ? { timeline } : {}), + }, + } +} + +export async function fetchDevices(period: Period, provider: string): Promise<{ devices: DeviceUsage[] }> { + const res = await fetch(`/api/devices?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`) + if (!res.ok) throw new Error(`Request failed (${res.status})`) + const data = (await res.json()) as { devices: DeviceUsage[] } + return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.payload) })) } +} + +// Keys map 1:1 to the CLI's --period values (src/cli-date.ts). Period windows +// are computed server-side by the CLI; the dashboard only forwards the key, so +// these can never drift from the CLI's totals. +export const PERIODS: Array<{ key: Period; label: string }> = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: '7 days' }, + { key: '30days', label: '30 days' }, + { key: 'month', label: 'Month' }, + { key: 'all', label: '6 months' }, + { key: 'lifetime', label: 'Lifetime' }, +] + +export type DiscoveredDevice = { + name: string + host: string + port: number + fingerprint: string + code: string + paired: boolean +} + +export async function scanDevices(): Promise { + const res = await fetch('/api/devices/scan') + if (!res.ok) throw new Error(`Scan failed (${res.status})`) + const json = (await res.json()) as { found: DiscoveredDevice[] } + return json.found +} + +export async function pairDevice(d: DiscoveredDevice): Promise<{ ok: boolean; name?: string; error?: string }> { + const res = await fetch('/api/devices/pair', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint }), + }) + return res.json() as Promise<{ ok: boolean; name?: string; error?: string }> +} + +export type ContextProvider = 'claude' | 'codex' + +export type ContextSessionInfo = { + provider: ContextProvider + sessionId: string + project: string + title: string + mtimeMs: number + sizeBytes: number +} + +export type BlockStat = { count: number; tokens: number } + +export type ContextSnapshot = { + messages: number + tokens: number + assistant: { + count: number + tokens: number + text: BlockStat + reasoning: BlockStat + toolCall: BlockStat + byTool: Array<{ tool: string; count: number; tokens: number }> + } + user: { + count: number + tokens: number + text: BlockStat + image: BlockStat + compactSummary: BlockStat + meta: BlockStat + } + toolResult: BlockStat + system: BlockStat +} + +export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean } + +export type ContextTree = { + session: { sessionId: string; project: string; mtimeMs: number; sizeBytes: number } + model: string + compactions: number + reported: { context: number; window: number | null } | null + effective: ContextSnapshot + full: ContextSnapshot + effectiveRows: ContextRow[] + fullRows: ContextRow[] +} + +export async function fetchContextSessions(provider: ContextProvider): Promise { + const res = await fetch(`/api/context/sessions?provider=${encodeURIComponent(provider)}`) + if (!res.ok) throw new Error(`Request failed (${res.status})`) + const json = (await res.json()) as { sessions: ContextSessionInfo[] } + return json.sessions ?? [] +} + +export async function fetchContextTree(provider: ContextProvider, id: string): Promise { + const res = await fetch(`/api/context/tree?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(id)}`) + if (!res.ok) throw new Error(`Request failed (${res.status})`) + return res.json() as Promise +} + +export type PendingPairing = { id: string; name: string; code: string } +export type ShareStatus = { + sharing: boolean + name: string + port: number + always: boolean + peers: number + pending: PendingPairing[] +} + +const postJson = (path: string, body: unknown) => + fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + +export async function shareStatus(): Promise { + const res = await fetch('/api/share/status') + if (!res.ok) throw new Error(`share status failed (${res.status})`) + return res.json() as Promise +} +export async function startShare(always: boolean): Promise { + return (await postJson('/api/share/start', { always })).json() as Promise +} +export async function stopShare(): Promise { + return (await postJson('/api/share/stop', {})).json() as Promise +} +export async function approvePairing(id: string, approve: boolean): Promise<{ ok: boolean }> { + return (await postJson('/api/share/approve', { id, approve })).json() as Promise<{ ok: boolean }> +} diff --git a/dash/src/lib/utils.ts b/dash/src/lib/utils.ts new file mode 100644 index 0000000..95f28c1 --- /dev/null +++ b/dash/src/lib/utils.ts @@ -0,0 +1,69 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)) +} + +export function usd(n: number | undefined | null): string { + const v = n == null || !isFinite(n) ? 0 : n + const sign = v < 0 ? '-' : '' + const a = Math.abs(v) + const s = a >= 1 || a === 0 ? a.toFixed(2) : a >= 0.01 ? a.toFixed(3) : a.toFixed(2) + const [int, dec] = s.split('.') + return sign + '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '') +} + +export function fmtTokens(n: number | undefined | null): string { + const v = n == null || !isFinite(n) ? 0 : n + if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B' + if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M' + if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K' + return String(Math.round(v)) +} + +export function fmtNum(n: number | undefined | null): string { + const v = n == null || !isFinite(n) ? 0 : n + return v.toLocaleString() +} + +export function compactUsd(n: number): string { + if (!isFinite(n)) return '$0' + const sign = n < 0 ? '-' : '' + const a = Math.abs(n) + if (a >= 1e6) return sign + '$' + (a / 1e6).toFixed(1) + 'M' + if (a >= 1e3) return sign + '$' + (a / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k' + return sign + '$' + Math.round(a) +} + +// Forest green -> gold -> terracotta ramp for stacked series (mirrors the +// --chart-* tokens). Warm and on-brand, distinct enough to read when stacked. +export const CHART_COLORS = [ + '#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f', + '#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f', +] + +const MODEL_LABELS: Record = { + 'claude-opus-4-8': 'Opus 4.8', + 'claude-opus-4-6': 'Opus 4.6', + 'claude-opus-4-7': 'Opus 4.7', + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-haiku-4-5-20251001': 'Haiku 4.5', + 'grok-build-0.1': 'Grok Build', + 'cursor-auto': 'Cursor', + 'composer-2.5': 'Composer 2.5', +} + +// Prettify a model id for chart legends. Display-name fields (current.topModels) +// already arrive clean; history rows carry raw ids, so we map the common ones +// and lightly clean the rest. +export function label(key: string): string { + if (MODEL_LABELS[key]) return MODEL_LABELS[key] + if (key === 'Other' || key === 'unknown') return key + return key + .replace(/^gpt-/i, 'GPT-') + .replace(/-(\d{8,})$/, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) +} diff --git a/dash/src/main.tsx b/dash/src/main.tsx new file mode 100644 index 0000000..68fcb0f --- /dev/null +++ b/dash/src/main.tsx @@ -0,0 +1,47 @@ +import { Component, StrictMode, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import { App } from './App' +import './index.css' + +// Last-resort guard: a render error (e.g. an unexpected payload from a peer on +// a different version) shows a recoverable message instead of a blank page. +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + render() { + if (this.state.error) { + return ( +
+

Something went wrong rendering the dashboard.

+

{String(this.state.error.message)}

+ +
+ ) + } + return this.props.children + } +} + +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } }, +}) + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/dash/tsconfig.json b/dash/tsconfig.json new file mode 100644 index 0000000..09a9a6c --- /dev/null +++ b/dash/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/dash/vite.config.ts b/dash/vite.config.ts new file mode 100644 index 0000000..ca07a91 --- /dev/null +++ b/dash/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { fileURLToPath, URL } from 'node:url' + +// base './' so the built assets load with relative URLs when the CLI serves +// dist/dash from the local server root. Built output goes straight into the +// package's dist/dash so `codeburn web` can serve it after `npm run build`. +export default defineConfig({ + plugins: [react(), tailwindcss()], + base: './', + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + build: { + outDir: '../dist/dash', + emptyOutDir: true, + }, + server: { + port: 5173, + // During frontend dev, run `codeburn web` (CLI api on 4747) and `npm run dev` + // here; this proxies the data calls to the CLI. + proxy: { '/api': 'http://127.0.0.1:4747' }, + }, +}) diff --git a/docs/providers/devin.md b/docs/providers/devin.md new file mode 100644 index 0000000..5ee74a8 --- /dev/null +++ b/docs/providers/devin.md @@ -0,0 +1,191 @@ +# Devin + +Cognition Devin CLI local usage tracking. + +- **Source:** `src/providers/devin.ts` +- **Loading:** lazy (`src/providers/index.ts` `LAZY_PROVIDERS`) — it opens the `sessions.db` SQLite file, so it is registered alongside the other native-sqlite providers. +- **Test:** `tests/providers/devin.test.ts` + +## Where it reads from + +Devin CLI data lives under: + +```text +~/.local/share/devin/cli/ +``` + +The MVP usage source is transcript JSON: + +```text +~/.local/share/devin/cli/transcripts/*.json +``` + +The provider also reads: + +```text +~/.local/share/devin/cli/sessions.db +``` + +`sessions.db` is enrichment only. It supplies project path/name, model fallback, +timestamp fallback, and hidden-session filtering. It is not the source of usage +or billing. + +## Configuration + +Devin reports spend in ACUs. CodeBurn reports provider cost through `costUSD`, +so Devin stays disabled until a positive finite ACU-to-USD rate is configured: + +```json +{ + "devin": { + "acuUsdRate": 2.25 + } +} +``` + +The config file is: + +```text +~/.config/codeburn/config.json +``` + +The macOS Settings window writes this value from the Devin tab. There is no +environment-variable override and no default rate. Do not hardcode a universal +ACU price; Devin ACU pricing is account/contract dependent. + +When the rate is missing or invalid, `discoverSessions()` returns `[]` and the +parser yields no calls. Devin remains registered as a provider, but it does not +appear in CLI/UI results until configured. + +## Storage format + +Transcript root is a JSON object following the [ATIF-v1.7 trajectory schema][atif], +with Devin-specific additions such as per-step `metadata` and `extra`. The +parser does not validate `schema_version`; it only requires a parseable object +with `steps[]`. + +Core fields include `session_id`, `agent.model_name`, `agent.extra` (Devin +backend/permission info), `final_metrics`, and `steps[]`. + +Steps now support two metric sources. The parser checks `step.metrics` first +(the standard ATIF location) and falls back to `step.metadata.metrics` (the +legacy Devin location). Similarly, ACU cost is read from +`step.metadata.committed_acu_cost` first, falling back to +`step.extra.committed_acu_cost`. + +Messages can be a plain string or an array of `ContentPart` objects (text or +image), following the ATIF v1.6+ multimodal content model. The parser +normalises both forms when extracting user messages. + +Each counted step can provide: + +- `step_id` +- `metadata.committed_acu_cost` (or `extra.committed_acu_cost`) +- `metrics.prompt_tokens` (or `metadata.metrics.input_tokens`) +- `metrics.completion_tokens` (or `metadata.metrics.output_tokens`) +- `metrics.extra.cache_creation_input_tokens` (or `metadata.metrics.cache_creation_tokens`) +- `metrics.cached_tokens` (or `metadata.metrics.cache_read_tokens`) +- `metadata.created_at` +- `metadata.generation_model` (or `extra.generation_model`) +- `metadata.request_id` +- `tool_calls[].function_name` +- `observation.results[]` (tool output; not parsed for usage) + +User-input steps (`metadata.is_user_input === true`) are skipped. Non-user +steps are included only if they have positive ACU usage or positive token usage. + +## Pricing + +ACU cost is per step, not cumulative. The provider reads +`metadata.committed_acu_cost` first, falling back to +`extra.committed_acu_cost`, then converts with: + +```text +costUSD = committed_acu_cost * devin.acuUsdRate +``` + +Token-only steps are still included when they have positive token metrics, but +their `costUSD` is `0` if `committed_acu_cost` is absent from both locations. + +`src/parser.ts` preserves Devin's provider-supplied `costUSD` instead of +re-pricing it through LiteLLM. + +## sessions.db enrichment + +The provider currently reads these columns from `sessions`: + +| Column | Use | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | +| `id` | join key with transcript `session_id` during parsing; discovery uses the transcript filename before `.json` | +| `working_directory` | `projectPath` and derived project name | +| `model` | model fallback | +| `title` | project name fallback | +| `created_at` | timestamp fallback | +| `last_activity_at` | preferred session timestamp fallback | +| `hidden` | skip hidden sessions | + +`message_nodes`, `prompt_history`, and `tool_call_state` are not parsed yet. + +## Timestamps + +Step timestamps come from `metadata.created_at`, falling back to +`sessions.last_activity_at`, then `sessions.created_at`. + +Transcript step timestamps are passed through as ATIF string timestamps. +Numeric normalization is only applied to `sessions.db` timestamps: + +- less than `10_000_000_000`: seconds +- otherwise: milliseconds + +## Model Resolution + +Model names resolve in this order: + +1. `step.metadata.generation_model` +2. `step.model_name` +3. `transcript.agent.model_name` +4. `sessions.model` +5. `devin` + +## Caching + +No provider-level cache. + +The normal session cache stores parsed provider calls, but Devin is always +reparsed by `src/parser.ts` because `sessions.db` can change without the +transcript JSON fingerprint changing. + +## Deduplication + +`devin::` + +The provider name is part of the key via the `devin:` prefix. + +## Quirks + +- The transcript directory has usage; `sessions.db` is enrichment only. +- `committed_acu_cost` is per-generation/per-step ACU usage. Never treat it as cumulative. It can appear in `metadata` (legacy) or `extra` (ATIF v1.7); the provider checks both. +- Token metrics can live in `step.metrics` (standard ATIF) or `step.metadata.metrics` (legacy Devin). The provider checks `step.metrics` first, falling back to `metadata`. +- Step messages can be a plain string or an array of `ContentPart` objects (text/image). The parser normalises both when extracting user messages. +- There is no default ACU-to-USD rate. Missing config intentionally hides Devin. +- Hidden sessions from `sessions.db` are skipped in discovery and parsing. +- Tool names come directly from `tool_calls[].function_name`; the provider assumes valid ATIF tool-call records. +- If SQLite is unavailable or `sessions.db` cannot be opened, the provider still parses transcripts without enrichment. + +## When fixing a bug here + +1. First check whether `~/.config/codeburn/config.json` contains a valid + `devin.acuUsdRate`. Without it, no Devin sessions should appear. +2. For usage total bugs, compare against (ACU cost can live in `metadata` or `extra`): + + ```bash + jq '[.steps[] | select(.metadata.is_user_input != true) | (.metadata.committed_acu_cost // .extra.committed_acu_cost // 0)] | add' ~/.local/share/devin/cli/transcripts/.json + ``` + +3. If project/model/timestamp metadata is wrong, inspect `sessions.db`, not the transcript. +4. If a hidden session appears, check the `hidden` column. Discovery can only + hide sessions whose transcript filename matches `sessions.id`; parsing uses + the transcript `session_id` when present. +5. Run `tests/providers/devin.test.ts` after parser changes. It covers ACU conversion, disabled-until-configured behavior, timestamp parsing, deduplication, hidden sessions, `sessions.db` enrichment, ATIF v1.7 multimodal messages, `step.metrics` vs `metadata.metrics` priority, and `extra.committed_acu_cost` fallback. + +[atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md diff --git a/docs/providers/grok.md b/docs/providers/grok.md new file mode 100644 index 0000000..4bed0ee --- /dev/null +++ b/docs/providers/grok.md @@ -0,0 +1,45 @@ +# Grok Build + +Grok Build, xAI's coding CLI. Sessions use the `grok-build` model by default. + +- **Source:** `src/providers/grok.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/grok.test.ts` + +## Where it reads from + +`$GROK_HOME/sessions/` (or `~/.grok/sessions/`), one directory per session: +`sessions///`. The parser reads `summary.json`, `signals.json`, and `updates.jsonl` from each session directory. + +## Storage format + +JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current_model_id`. `signals.json` holds `modelsUsed`, `toolsUsed`, and `contextTokensUsed`. `updates.jsonl` is the ACP log: each streamed chunk carries `params._meta.totalTokens` (running context size) and `params._meta.promptId` (one per turn). + +## Token model + +**Estimated.** Grok does not log billable input/output tokens. It only records the running context fill (`totalTokens` per chunk, and `contextTokensUsed` in signals). The parser reconstructs a rough estimate from the per-turn `totalTokens` curve: input is the context entering each turn, output is the context growth during it. The result is flagged `costIsEstimated` and re-priced with `calculateCost`. + +## Pricing + +`grok-build` is aliased to `grok-build-0.1` in `src/models.ts`, so it prices off the bundled LiteLLM fallback. Note that xAI's published API rate and the LiteLLM fallback figure differ, so treat the cost as an estimate and verify against your xAI usage console. + +## Caching + +None. + +## Deduplication + +Per `grok:::`. + +## Quirks + +- **No cache or output/tool-token split.** Only context fill is available, so cache fields are `0` and the cost is an estimate (likely an upper bound, since re-sent context is cached server-side and not exposed in the session files). +- **No bash-command capture.** Tool names come from `signals.toolsUsed`; per-command bash text is not extracted, so `bashCommands` is empty. +- **Whole-session timestamp.** Spend is attributed to `updated_at`, since the context curve is cumulative. +- **Subscription vs API.** Grok Build runs via either a metered xAI API account (tiered) or a SuperGrok subscription; the session files do not record which. + +## When fixing a bug here + +1. Discovery: check the `sessions///` walk and the `GROK_HOME` resolution. +2. Token estimate: see `estimateTokens` (groups `updates.jsonl` by `promptId`). +3. Add a fixture-format session under `tests/providers/grok.test.ts`; do not mock the filesystem. diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md new file mode 100644 index 0000000..3f63147 --- /dev/null +++ b/docs/providers/hermes.md @@ -0,0 +1,67 @@ +# Hermes Agent + +Hermes Agent CLI profiles. + +- **Source:** `src/providers/hermes.ts` +- **Loading:** lazy (`src/providers/index.ts` `LAZY_PROVIDERS`) — opens SQLite, so it is loaded on demand like the other SQLite-backed providers in this fork. +- **Test:** `tests/providers/hermes.test.ts` + +## Where it reads from + +| Source | Path | +|---|---| +| Default Hermes profile | `$HERMES_HOME/state.db` if set, otherwise `~/.hermes/state.db` | +| Named Hermes profiles | `$HERMES_HOME/profiles//state.db` | + +## Storage format + +SQLite. The provider reads Hermes' aggregate `sessions` token/cost counters and the matching `messages` rows for user prompt and tool-call context. + +## Parser + +Hermes stores durable token accounting at the session level, so CodeBurn emits one parsed call per Hermes session instead of one call per LLM API request. The call contains the aggregate session totals: + +- input tokens +- output tokens +- cache-read tokens +- cache-write tokens +- reasoning tokens +- actual or estimated cost when Hermes recorded one + +If Hermes recorded no positive cost, CodeBurn falls back to its normal model pricing table. + +## Project grouping + +Discovery groups sessions by Hermes profile (`default`, `coder`, `analytics`, etc.). Parsing prefers the session's own `sessions.cwd` column; when absent it scrapes a clean `Current working directory: /path` line from the transcript so CodeBurn can canonicalize worktrees. The parser deliberately ignores quoted or escaped prompt text that merely contains the phrase `Current working directory:`. + +## Tool mapping + +Hermes `tool_calls` are normalized to CodeBurn display names where possible: + +- `terminal` -> `Bash` +- `read_file` -> `Read` +- `write_file` -> `Write` +- `patch` -> `Edit` +- `search_files` -> `Grep` +- browser tools -> `Browser` +- web tools -> `WebSearch` / `WebFetch` +- skill tools -> `Skill` + +Terminal command arguments are exposed as `bashCommands` for CodeBurn's command breakdowns. The optional `toolSequence` is emitted as `string[][]` (flat tool names per assistant turn) to match this fork's `ParsedProviderCall` shape. + +## Caching + +The shared session cache fingerprints Hermes state DB files by path and mtime. + +## Quirks + +- The provider is aggregate-first because Hermes' stable accounting lives in `sessions`. Do not infer per-turn usage from message text. +- Source paths are encoded as `#hermes-session=` so SQLite paths containing `:` remain safe. +- SQLite schema checks are intentionally light: if the expected `sessions` or `messages` columns are absent, the DB is skipped. + +## When fixing a bug here + +1. Reproduce against a real Hermes `state.db` or a minimal SQLite fixture. +2. Run `npm test -- tests/providers/hermes.test.ts --run`. +3. For local smoke testing, use an isolated cache directory, for example: + `CODEBURN_CACHE_DIR=/tmp/codeburn-hermes-cache node --import tsx -e "import { parseAllSessions } from './src/parser.ts'; console.log(await parseAllSessions(undefined, 'hermes'))"`. diff --git a/docs/providers/lingtai-tui.md b/docs/providers/lingtai-tui.md new file mode 100644 index 0000000..bea3a0a --- /dev/null +++ b/docs/providers/lingtai-tui.md @@ -0,0 +1,88 @@ +# LingTai TUI + +LingTai TUI per-agent token ledger integration. + +- **Source:** `src/providers/lingtai-tui.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/lingtai-tui.test.ts` + +## Where it reads from + +| Source | Path | +|---|---| +| Explicit LingTai homes | `$LINGTAI_HOME` or `$LINGTAI_TUI_HOME` if set; path-list values are supported | +| Default LingTai home | `~/.lingtai` | +| Project LingTai homes | `/.lingtai` for projects registered in `~/.lingtai-tui/registry.jsonl` and `~/.lingtai-tui/brief/projects/*/meta.json` | +| Current worktree home | `.lingtai` in the current directory or any parent directory | +| Agent ledgers | `//logs/token_ledger.jsonl` | + +Daemon ledgers nested under `/daemons/...` are deliberately not discovered during normal scanning. LingTai mirrors daemon usage into the parent agent ledger with `source`, `em_id`, and `run_id` tags, so reading nested ledgers too would double count spend. + +## Storage format + +Append-only JSONL. Each valid ledger line may include: + +- `source` +- `em_id` +- `run_id` +- `ts` +- `input` +- `output` +- `thinking` +- `cached` +- `model` +- `endpoint` + +Malformed lines and zero-token entries are skipped. Missing `model` falls back to the agent `.agent.json` `llm.model`, then `unknown`. + +## Parser + +CodeBurn emits one parsed call per ledger entry. LingTai records provider-normalized total input plus a separate `cached` counter, so the provider maps: + +- `input - cached` -> fresh input tokens +- `cached` -> cache-read tokens +- `output` -> output tokens +- `thinking` -> reasoning tokens + +Costs are calculated from CodeBurn's normal model pricing table. + +## Activity mapping + +LingTai's token ledger is an accounting source, not full chat history, so it does not include the original user prompt or per-tool transcript. CodeBurn maps the ledger `source` field conservatively via the synthesized `userMessage` and `tools` on each call: + +| LingTai `source` | Synthesized userMessage | tools | CodeBurn activity | +|---|---|---|---| +| `main` and unknown sources | `LingTai … conversation` | — | Conversation | +| `tc_wake` and other task-coordinator wake sources | `LingTai task coordinator wake` | `Agent` | Delegation | +| `daemon` | `LingTai daemon task` | `Agent` | Delegation | +| `summarize_apriori` | `LingTai planning summary` | `EnterPlanMode` | Planning | + +This keeps the menubar and dashboard **By Activity** view from collapsing all LingTai usage into Conversation while avoiding invented feature/debug/refactor semantics that the ledger cannot prove. + +## Project grouping + +Discovery reads `/.agent.json` and groups by `nickname`, `agent_name`, `address`, then the directory name. Project-local homes are prefixed with the project directory name, for example `sample-project-Project Agent`, so same-named agents from different LingTai projects do not collapse together. The parsed call also carries the agent directory as `projectPath`. + +## Caching + +The shared session cache fingerprints each `token_ledger.jsonl`. `LINGTAI_HOME`, `LINGTAI_TUI_HOME`, and `LINGTAI_TUI_GLOBAL_DIR` are part of the provider environment fingerprint so changing homes invalidates stale cached results. + +## Deduplication + +Dedup keys include provider name, ledger path, line number, timestamp, model, endpoint, LingTai source tags, and token counts: + +`lingtai-tui:::::...` + +The ledger is append-only, so line number is stable for normal operation. + +## Quirks + +- Tool calls are not reconstructed from chat history. The token ledger is the stable accounting source and does not include tool metadata. +- Older ledger entries may not include `source`; those are labeled `main`. +- `cached` is treated as cache read. LingTai does not expose a separate cache creation counter in the ledger. + +## When fixing a bug here + +1. Prefer a minimal redacted `token_ledger.jsonl` fixture over full `chat_history.jsonl`. +2. Check whether a daemon entry is already mirrored into the parent ledger before adding new discovery paths. +3. Run `npm test -- tests/providers/lingtai-tui.test.ts --run`. diff --git a/docs/providers/open-design.md b/docs/providers/open-design.md new file mode 100644 index 0000000..c3ae101 --- /dev/null +++ b/docs/providers/open-design.md @@ -0,0 +1,54 @@ +# Open Design + +Open Design — a desktop agentic-coding app that makes its own LLM API calls and records per-run token usage natively as a JSONL event stream, so codeburn reads it directly. + +- **Source:** `src/providers/open-design.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/open-design.test.ts` + +## Where it reads from + +| Order | Path | +|---|---| +| 1 | `$CODEBURN_OPEN_DESIGN_DIR` (codeburn-only override) | +| 2 | `~/Library/Application Support/Open Design` (macOS) | +| 2 | `%APPDATA%\Open Design` (Windows) | +| 2 | `~/.config/Open Design` (Linux) | + +Session files: `/namespaces//data/runs//events.jsonl`. The override may point at any level of that tree — a `namespaces/` dir, a single namespace's `data/` dir, or a bare `runs/` dir — and discovery normalizes accordingly (see Quirks). The project label is the `` directory name; when discovery is rooted below the namespace level the label falls back to the nearest meaningful directory name (or `open-design`). + +## Storage format + +JSONL, one event per line. Each line is `{ id, event, timestamp, data }`. The parser tracks the active model across the run and emits one call per usage event: + +- `event: "start"` with `data.model` seeds the initial model. +- `event: "agent"` with `data.type === "status"` and `data.model` switches the active model mid-run. +- `event: "agent"` with `data.type === "usage"` carries `data.usage` (`{ input_tokens, output_tokens, cached_read_tokens, thought_tokens }`) and is attributed to the currently active model. + +Timestamps are either ISO strings or numeric epoch milliseconds; numeric values are normalized to ISO via `new Date(ms).toISOString()`. + +## Pricing + +Cost is recomputed locally via `calculateCost` from the LiteLLM snapshot. `input_tokens` is treated as **inclusive** of cache reads, so the uncached input passed to pricing is `input_tokens − cached_read_tokens`. Reasoning (`thought_tokens`) bills at the output rate, so it is folded into the output arg (`output_tokens + thought_tokens`). + +On-disk model strings can be `provider:modelId` (e.g. `openai-codex:gpt-5.5`). codeburn's canonicalizer (`getCanonicalName`) only strips slash-style prefixes, so a colon-prefixed id would price at $0 unless it is registered as a `BUILTIN_ALIASES` entry mapping to the bare LiteLLM id (`openai-codex:gpt-5.5` → `gpt-5.5`). + +## Caching + +None at the provider level. + +## Deduplication + +Per `open-design::`. `` is the run directory name. For id-less lines the key falls back to a per-run event counter. The `seenKeys` set is shared across parser runs, so re-parsing the same run emits nothing the second time. + +## Quirks + +- **Flexible discovery root.** `$CODEBURN_OPEN_DESIGN_DIR` may point at a `namespaces/` dir, a namespace's `data/` dir, or a `runs/` dir directly; discovery detects the level from the basename and walks down to per-run `events.jsonl` files, deduping paths. +- **Active-model tracking.** Usage events do not carry the model; the model comes from the preceding `start` or `status` event, so a mixed-model run yields separate per-model calls. +- **No tools / user prompt / web search.** The event stream captured here records only usage, so `tools`, `bashCommands`, `userMessage` are empty and `webSearchRequests` is always `0`. + +## When fixing a bug here + +1. Confirm whether the bug is in **discovery** (runs not picked up — `discoverOpenDesignSessions`/`discoverRunsDir`/`discoverNamespacesDir`) or **parsing** (`createParser`). +2. `data.usage` field names (`input_tokens`, `output_tokens`, `cached_read_tokens`, `thought_tokens`) come straight from the on-disk stream; validate against a real `events.jsonl` sample. +3. Add a fixture-driven case to `tests/providers/open-design.test.ts` under `tests/fixtures/open-design/...`. Do not mock the filesystem; write a temp namespaces/runs layout like the existing fixtures and point `CODEBURN_OPEN_DESIGN_DIR` at it. diff --git a/docs/providers/zcode.md b/docs/providers/zcode.md new file mode 100644 index 0000000..c1fcad6 --- /dev/null +++ b/docs/providers/zcode.md @@ -0,0 +1,89 @@ +# ZCode + +ZCode CLI coding agent (z.ai), running GLM-5.2 over the z.ai start-plan. + +- **Source:** `src/providers/zcode.ts` +- **Loading:** lazy (`src/providers/index.ts`, `LAZY_PROVIDERS`). Lazy because we read ZCode's SQLite database with `node:sqlite`. +- **Test:** `tests/providers/zcode.test.ts` (3 tests, fixture-based) + +## Where it reads from + +ZCode keeps a single global SQLite database for the CLI. + +| Source | Path | +|---|---| +| ZCode CLI db | `~/.zcode/cli/db/db.sqlite` | + +The desktop app dir (`~/Library/Application Support/ZCode`) only holds Electron runtime state, and the JSONL activity log (`~/.zcode/cli/log/*.jsonl`) redacts token counts, so neither is used. + +## Storage format + +SQLite. Schema verified against CLI db v0.14.8. Three tables matter: + +```sql +CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT NOT NULL, + ... +); + +CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + turn_id TEXT, + model_id TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + completed_at INTEGER, + ... +); + +CREATE TABLE tool_usage ( + session_id TEXT NOT NULL, + turn_id TEXT, + tool_name TEXT NOT NULL, + started_at INTEGER NOT NULL, + ... +); +``` + +## Caching + +None at the provider level. + +## Deduplication + +Per `zcode:` (`zcode.ts`). `model_usage.id` is the row primary key, unique per request. + +## What we extract + +| codeburn field | ZCode source | +|---|---| +| `inputTokens` | `model_usage.input_tokens` minus cached + created (see quirks) | +| `outputTokens` | `model_usage.output_tokens` | +| `reasoningTokens` | `model_usage.reasoning_tokens` | +| `cacheCreationInputTokens` | `model_usage.cache_creation_input_tokens` | +| `cacheReadInputTokens` | `model_usage.cache_read_input_tokens` | +| `costUSD` | computed by `calculateCost` (ZCode stores no cost) | +| `model` | `model_usage.model_id` (e.g. `GLM-5.2`) | +| `timestamp` | `model_usage.completed_at` if set, otherwise `started_at` (epoch ms) | +| `tools` | `tool_usage.tool_name` for the turn, attached to one request per turn | + +## Quirks worth knowing + +- **Cached tokens are folded into `input_tokens` (OpenAI-style).** The row's `input_tokens` is the full prompt size including cache reads/writes, and `provider_total_tokens = input_tokens + output_tokens`. The parser subtracts `cache_read_input_tokens` and `cache_creation_input_tokens` from `input_tokens` so fresh input bills at the input rate and cached at the cache-read rate. Confirmed against the nested Anthropic usage in `provider_metadata_json` (e.g. 100 input = 36 fresh + 64 cached). +- **No cost is stored anywhere.** GLM-5.2 runs on z.ai's `start-plan` subscription, so ZCode logs tokens only. CodeBurn computes a notional cost from the pricing table. +- **GLM-5.2 is priced via an alias.** LiteLLM does not list GLM-5.2 yet, so `GLM-5.2` maps to `glm-5` (GLM-5) in `BUILTIN_ALIASES` (`src/models.ts`) — the nearest priced sibling in this fork's bundled snapshot. (Upstream aliases to `glm-5p1`, which this fork's snapshot does not ship.) Reports therefore show the model priced as `glm-5`, the same way any aliased model displays as its priced-as target. Update the alias once LiteLLM adds GLM-5.2. +- **Timestamps are milliseconds.** Unlike Crush (seconds), ZCode stores epoch ms; the parser passes them straight to `Date`. +- **Tools are attached per turn, not per request.** `tool_usage` links to a turn, not a specific `model_usage` row, so each turn's tools are attached to its first request to avoid double-counting. Bash command text is not stored, so `bashCommands` is always empty. + +## When fixing a bug here + +1. Confirm the schema against a real ZCode install; copy `~/.zcode/cli/db/db.sqlite` to a temp file before querying so you do not lock the live db. +2. If costs are $0, check that `GLM-5.2` (or the current model id) still resolves through `BUILTIN_ALIASES` to a priced model in `src/data/litellm-snapshot.json`. +3. If tokens look ~8x too high, someone likely removed the cache-subtraction in the input normalization; the row's `input_tokens` already includes cached tokens. +4. New fixtures go under the inline schema in `tests/providers/zcode.test.ts`. diff --git a/docs/providers/zed.md b/docs/providers/zed.md new file mode 100644 index 0000000..8436e79 --- /dev/null +++ b/docs/providers/zed.md @@ -0,0 +1,84 @@ +# Zed + +Zed's built-in agent (the Assistant / Agent panel in the Zed editor). + +- **Source:** `src/providers/zed.ts` +- **Loading:** lazy (`src/providers/index.ts`). Lazy because Zed stores threads in a single SQLite database that we read with `node:sqlite`, and the blobs are zstd-compressed (needs Node's `zlib.zstdDecompressSync`, added in Node 22.15 / 23.8). +- **Test:** `tests/providers/zed.test.ts` (fixture-based; skipped on Node builds without `node:sqlite` or without `zlib` zstd support). + +## Where it reads from + +A single threads database, one row per thread. + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Zed/threads/threads.db` | +| Windows | `~/AppData/Local/Zed/threads/threads.db` | +| Linux | `~/.local/share/zed/threads/threads.db` | + +## Storage format + +SQLite. The `threads` table has a `data` BLOB column whose contents are decided by `data_type`: + +- `zstd` — the current save path; the blob is zstd-compressed JSON. +- `json` — legacy uncompressed rows; the blob is plain UTF-8 JSON. + +Any other `data_type` is treated as unknown and skipped. + +The decompressed JSON carries the thread's model and its token accounting: + +```jsonc +{ + "model": { "provider": "anthropic", "model": "claude-opus-4-8" }, + "request_token_usage": { + "": { + "input_tokens": 1200, + "output_tokens": 300, + "cache_creation_input_tokens": 5000, + "cache_read_input_tokens": 90000 + } + }, + "cumulative_token_usage": { "input_tokens": 2000, "output_tokens": 450 } +} +``` + +Token fields are already Anthropic-shaped, so they map straight onto codeburn's cache/reasoning-exclusive convention with no decomposition. + +## Caching + +None at the provider level. + +## Deduplication + +Per `zed::` (`zed.ts`). `requestKey` is either a key from `request_token_usage` or the synthetic `cumulative-remainder`. The `seenKeys` set is shared across parses so repeat scans do not double-count. + +## What we extract + +| codeburn field | Zed source | +|---|---| +| `inputTokens` | `request_token_usage[key].input_tokens` | +| `outputTokens` | `request_token_usage[key].output_tokens` | +| `cacheCreationInputTokens` | `cache_creation_input_tokens` | +| `cacheReadInputTokens` / `cachedInputTokens` | `cache_read_input_tokens` | +| `model` | `model.model` (falls back to `unknown`) | +| `timestamp` | `threads.updated_at` | +| `userMessage` | `threads.summary` | +| `sessionId` | `threads.id` | + +`reasoningTokens`, `webSearchRequests`, `tools`, and `bashCommands` are all left as zero / empty. Zed does not record per-tool calls in a form we read. + +## Quirks worth knowing + +- **The per-request map undercounts.** `request_token_usage` is keyed by user message and does not cover every request (verified on a real thread: `cumulative_token_usage` was ~3x the map sum). The parser emits one call per non-empty map entry, then adds a synthetic `cumulative-remainder` call so the thread's totals match the exact `cumulative_token_usage` counter without double-counting. +- **Empty maps degrade to one cumulative call.** A thread whose `request_token_usage` is empty but whose `cumulative_token_usage` is non-empty produces a single `cumulative-remainder` call. +- **Zero-usage threads are skipped.** Entries whose token counts are all zero never produce a call. +- **zstd support is required.** `zlib.zstdDecompressSync` landed in Node 22.15 / 23.8. On older Node the provider writes a notice to stderr and yields nothing rather than crashing (the package floor is 22.13). +- **Unreadable rows are skipped, not fatal.** Rows with an unknown `data_type` or a blob that fails to decompress/parse are counted and reported (`skipped N unreadable Zed threads`) but do not drop healthy threads. + +## When fixing a bug here + +1. Confirm the issue against a real Zed install; check the `threads` table shape (`data_type`, `data`) before assuming the schema changed. +2. If totals look ~3x too low, the `cumulative-remainder` top-up is likely broken; verify the sum of per-request entries against `cumulative_token_usage`. +3. If Zed sessions show timestamps from 1970, check that `updated_at` is still an ISO-8601 string (the parser feeds it straight into `new Date`). +4. If the model shows `unknown`, the thread JSON has no `model.model`; confirm the field path is still `model.model`. +5. New fixtures go under the inline `CREATE TABLE threads` schema in `tests/providers/zed.test.ts`; keep it synchronized with Zed's actual columns. diff --git a/docs/providers/zerostack.md b/docs/providers/zerostack.md new file mode 100644 index 0000000..ba83e73 --- /dev/null +++ b/docs/providers/zerostack.md @@ -0,0 +1,48 @@ +# Zerostack + +Zerostack (gi-dellav/zerostack) — a minimal Rust coding agent. Wraps OpenRouter (default), OpenAI, Anthropic, Gemini, and Ollama. + +- **Source:** `src/providers/zerostack.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/zerostack.test.ts` + +## Where it reads from + +The platform data dir + `zerostack/sessions/`, mirroring Rust's `dirs::data_dir` (`src/session/storage.rs` in zerostack): + +| OS | Path | +|---|---| +| macOS | `~/Library/Application Support/zerostack/sessions/` | +| Linux | `$XDG_DATA_HOME/zerostack/sessions/` or `~/.local/share/zerostack/sessions/` | + +`ZS_DATA_DIR` overrides the whole data dir (sessions live directly under it). A directory argument to `createZerostackProvider(dir)` overrides the sessions dir outright (used by tests). + +## Storage format + +JSON — one `.json` file per session. Each file is a single `Session` object: a `messages[]` array (`role`, `content`, `estimated_tokens`) plus session-level metadata. + +## Token model + +**Cumulative, not per-call.** Real billable tokens exist only as session totals — `total_input_tokens`, `total_output_tokens` — alongside `model`, `provider`, and `working_dir`. Individual messages carry only a rough `estimated_tokens`. So the parser emits **one `ParsedProviderCall` per session** from the totals; cost is recomputed with `calculateCost` (LiteLLM), not taken from the session's own `total_cost`. + +## Caching + +None. + +## Deduplication + +Per `zerostack:::`. + +## Quirks + +- **No cache breakdown.** zerostack's `Usage` only carries `input_tokens` and `output_tokens` (`src/agent/runner.rs`); it folds any cached prompt tokens into the input count and discards the split before writing the session. So cache fields are always `0` and the cache % reads 0. Cost is re-priced at LiteLLM's standard input rate, which can slightly overestimate when caching was active — consistent with zerostack's own flat `input_token_cost`. +- **No tool data.** zerostack persists only final assistant text, not tool-call or bash records, so `tools` and `bashCommands` are always empty. +- **OpenRouter model ids are prefixed** (e.g. `deepseek/deepseek-v4-pro`). `modelDisplayName` strips the route prefix before resolving; `calculateCost` resolves the prefixed id via LiteLLM's canonical-name handling. +- **Whole-session timestamp.** All spend is attributed to `updated_at`, since cumulative totals can't be split across days. +- Unknown/local models (Ollama, custom) price at `$0`, which is expected. + +## When fixing a bug here + +1. Confirm whether the bug is in **discovery** (session files not picked up — check the data-dir resolution against `src/session/storage.rs`) or **parsing** (totals mapped wrong). +2. The session struct is `Session` in zerostack's `src/session/mod.rs`. If a field is renamed upstream, update `ZerostackSession` to match. +3. Add a fixture-format session to `tests/providers/zerostack.test.ts`; do not mock the filesystem. diff --git a/gnome/indicator.js b/gnome/indicator.js index 0658831..e4beb9f 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -62,6 +62,7 @@ const CURRENCIES = [ { code: 'MXN', symbol: 'MX$' }, { code: 'ZAR', symbol: 'R ' }, { code: 'DKK', symbol: 'kr ' }, + { code: 'RON', symbol: 'lei ' }, { code: 'CNY', symbol: '¥' }, ]; diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index c59cdf2..f68dbfe 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -111,6 +111,25 @@ codesign --force --sign - \ --deep "${BUNDLE}" 2>/dev/null || true codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)" +echo "▸ Verifying deployment target and libswift_errno absence..." +BUILT_EXE="${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}" +MINOS_LINES=$(vtool -show-build "${BUILT_EXE}" 2>/dev/null | awk '/minos/{print $2}') +if [[ -z "${MINOS_LINES}" ]]; then + echo "Could not read a minos deployment target from ${BUILT_EXE} (vtool returned nothing)." >&2 + exit 1 +fi +BAD_MINOS=$(printf '%s\n' "${MINOS_LINES}" | grep -v '^14\.0$' || true) +if [[ -n "${BAD_MINOS}" ]]; then + echo "✗ Expected minos 14.0 for every arch slice, found: ${BAD_MINOS}" >&2 + echo " Did Package.swift's platforms: [.macOS(...)] regress past .v14?" >&2 + exit 1 +fi +if otool -L "${BUILT_EXE}" | grep libswift_errno | grep -qv 'weak'; then + echo "✗ ${BUILT_EXE} links libswift_errno.dylib (macOS 15+ only), would fail on Sonoma with -10825." >&2 + exit 1 +fi +echo " minos 14.0 confirmed, no libswift_errno dependency." + ZIP_NAME="CodeBurnMenubar-${VERSION}.zip" ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" echo "▸ Packaging ${ZIP_NAME}..." diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 40ba058..33ab201 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -725,6 +725,9 @@ final class AppStore { if let pct = usage.sevenDaySonnetPercent { details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt)) } + for scoped in usage.scopedWeekly { + details.append(.init(label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt)) + } } let plan = subscription?.tier.displayName return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: []) @@ -852,7 +855,7 @@ final class AppStore { } enum SupportedCurrency: String, CaseIterable, Identifiable { - case USD, GBP, EUR, AUD, CAD, NZD, JPY, CNY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK + case USD, GBP, EUR, AUD, CAD, NZD, JPY, CNY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK, RON var id: String { rawValue } var displayName: String { switch self { @@ -874,6 +877,7 @@ enum SupportedCurrency: String, CaseIterable, Identifiable { case .MXN: "Mexican Peso" case .ZAR: "South African Rand" case .DKK: "Danish Krone" + case .RON: "Romanian Leu" } } } @@ -973,6 +977,10 @@ enum SubscriptionLoadState: Sendable, Equatable { enum DisplayMetric: String { case cost case tokens + /// Codex credits. Only appears in the hero's tap-cycle when the current + /// period actually has Codex credit usage; otherwise the hero cycles + /// cost → tokens → cost and a stale `.credits` renders as cost. (#568) + case credits } enum InsightMode: String, CaseIterable, Identifiable { diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index ecadfd8..8b8dc9c 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -47,6 +47,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let updateChecker = UpdateChecker() /// Held for the lifetime of the app to opt out of App Nap and Automatic Termination. private var backgroundActivity: NSObjectProtocol? + /// True while the displays are asleep. The refresh loop skips spawning the + /// (expensive, full-Node-process) CLI fetch entirely then: nobody can see + /// the menubar, so a fetch is pure battery burn (#647). + private var displayAsleep = false private var pendingRefreshWork: DispatchWorkItem? private var refreshLoopTask: Task? private var forceRefreshTask: Task? @@ -120,6 +124,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { queue: .main ) { [weak self] _ in Task { @MainActor in + self?.displayAsleep = false self?.store.resetLoadingState() self?.forceRefresh() if self?.refreshLoopTask == nil { self?.startRefreshLoop() } @@ -131,7 +136,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.forceRefresh() } + Task { @MainActor in + self?.displayAsleep = false + self?.forceRefresh() + } + } + + // Display sleep without system sleep (clamshell displays off, screen + // saver / energy settings) previously kept the full 30s spawn cadence + // running for hours. Skip refresh spawns until the screens wake. (#647) + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + // If a sleep/wake pair ever reorders (two Task hops), a stuck + // displayAsleep=true self-heals on the next popover open (which + // clears it) — the background loop resumes then. (#648) + Task { @MainActor in self?.displayAsleep = true } } } @@ -141,7 +163,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.forceRefresh() } + Task { @MainActor in + // The LaunchAgent posts this every 30s; skip while the displays + // sleep so this external tick doesn't defeat the idle-energy + // backoff (nobody's looking). Real wakes/opens refresh via their + // own paths. (#648) + guard self?.displayAsleep == false else { return } + self?.forceRefresh() + } } } @@ -311,12 +340,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // skip below. self.refreshStatusButton() } - // Skip the loop's tick if a wake / manual / distributed- - // notification refresh just ran. Without this gate, every - // wake produced two refreshes (forceRefresh from the wake - // observer plus the loop's natural tick). + // Cadence gate (#647): the timer keeps ticking every 30s + // (cheap), but the expensive part — each usage refresh is a full + // Node process at 100%+ CPU for seconds — backs off. With the + // popover closed, on battery / Low Power the spawn cadence + // stretches (150s / 300s); while the displays sleep it stops + // entirely; in Manual mode it never auto-spawns. An open popover + // always gets the active 30s cadence, and popover-open + // forceRefresh catches any staleness the backoff introduced. + // `interval >= 30` also subsumes the old 5s wake-dedup gate. let sinceLast = Date().timeIntervalSince(self.lastRefreshTime) - if sinceLast >= 5 { + let usageInterval = RefreshCadence.interval( + mode: UsageRefreshCadence.current, + popoverOpen: self.popover?.isShown ?? false, + onBattery: PowerSource.isOnBattery(), + lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled + ) + let maySpawnUsage = !self.displayAsleep + && (usageInterval.map { sinceLast >= $0 } ?? false) + if maySpawnUsage { if self.store.selectedPeriod != .today || self.store.selectedProvider != .all { async let quiet: Void = self.store.refreshQuietly(period: .today) // force:false so a key refreshed <30s ago by a switch/wake @@ -619,6 +661,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // restart the loop if it died, and pull a fresh fetch if the visible // data has gone stale — so opening always shows current usage. store.clearWedgedLoading() + // An open popover proves the display is on — self-heal any stuck + // displayAsleep so the spawn gate can't stay latched off. (#648 fix) + displayAsleep = false if refreshLoopTask == nil { startRefreshLoop() } if store.isCurrentDataStale { forceRefresh() } // Warm the other common periods (all-provider) so the first click on diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift index 3d219de..e90c238 100644 --- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift +++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift @@ -60,7 +60,8 @@ final class CurrencyState: Sendable { "CHF": "CHF", "SEK": "kr", "DKK": "kr", - "ZAR": "R" + "ZAR": "R", + "RON": "lei" ] } diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift index f97641d..59a2034 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -140,9 +140,8 @@ enum ClaudeSubscriptionService { case 200: clearUsageBlock() do { - let decoded = try JSONDecoder().decode(UsageResponse.self, from: data) let tier = try ClaudeCredentialStore.subscriptionTier() - return mapResponse(decoded, rawTier: tier) + return try parseUsage(data, rawTier: tier) } catch { throw FetchError.usageDecodeFailed } @@ -191,17 +190,50 @@ enum ClaudeSubscriptionService { // MARK: - Response mapping + /// Decodes a usage endpoint response body. Internal so tests can feed the + /// captured JSON shape without a network round trip. + static func parseUsage(_ data: Data, rawTier: String?) throws -> SubscriptionUsage { + let decoded = try JSONDecoder().decode(UsageResponse.self, from: data) + return mapResponse(decoded, rawTier: rawTier) + } + private struct UsageResponse: Decodable { let fiveHour: Window? let sevenDay: Window? let sevenDayOpus: Window? let sevenDaySonnet: Window? + let limits: [Limit]? enum CodingKeys: String, CodingKey { case fiveHour = "five_hour" case sevenDay = "seven_day" case sevenDayOpus = "seven_day_opus" case sevenDaySonnet = "seven_day_sonnet" + case limits + } + } + + /// Entry in the `limits` array. Model-scoped weekly buckets (like Fable) + /// only appear here, not as named top-level windows. + private struct Limit: Decodable { + let kind: String? + let percent: Double? + let resetsAt: String? + let scope: Scope? + + enum CodingKeys: String, CodingKey { + case kind, percent, scope + case resetsAt = "resets_at" + } + + struct Scope: Decodable { + let model: Model? + struct Model: Decodable { + let displayName: String? + enum CodingKeys: String, CodingKey { + case displayName = "display_name" + } + } } } @@ -215,7 +247,18 @@ enum ClaudeSubscriptionService { } private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage { - SubscriptionUsage( + let scopedWeekly = (r.limits ?? []).compactMap { limit -> SubscriptionUsage.ScopedWindow? in + guard limit.kind == "weekly_scoped", + let name = limit.scope?.model?.displayName, + let percent = limit.percent + else { return nil } + return SubscriptionUsage.ScopedWindow( + label: name, + percent: percent, + resetsAt: parseDate(limit.resetsAt) + ) + } + return SubscriptionUsage( tier: SubscriptionUsage.tier(from: rawTier), rawTier: rawTier, fiveHourPercent: r.fiveHour?.utilization, @@ -226,6 +269,7 @@ enum ClaudeSubscriptionService { sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt), sevenDaySonnetPercent: r.sevenDaySonnet?.utilization, sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt), + scopedWeekly: scopedWeekly, fetchedAt: Date() ) } diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index f12d095..6bf8f33 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -17,6 +17,26 @@ enum DataClientError: Error { case outputTooLarge } +/// Wraps a `MenubarPayload` decode failure with a bounded snippet of what the CLI +/// actually wrote to stdout (plus stderr), so a malformed-output failure — for +/// example a stray Node banner landing on stdout ahead of the JSON (see #515) — +/// is self-diagnosing in logs and the UI instead of an opaque "not valid JSON". +struct CLIDecodeFailure: Error, CustomStringConvertible { + let underlying: Error + let stdoutByteCount: Int + let stdoutSnippet: String + let stderr: String + + var description: String { + var parts = [ + "decode failed: \(underlying)", + "stdout (\(stdoutByteCount) bytes): \(stdoutSnippet.isEmpty ? "" : stdoutSnippet)", + ] + if !stderr.isEmpty { parts.append("stderr: \(stderr)") } + return parts.joined(separator: " | ") + } +} + /// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route /// commands through `/bin/zsh -c` anymore. struct DataClient { @@ -38,7 +58,13 @@ struct DataClient { do { return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout) } catch { - throw DataClientError.decode(error) + let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self) + throw DataClientError.decode(CLIDecodeFailure( + underlying: error, + stdoutByteCount: result.stdout.count, + stdoutSnippet: snippet, + stderr: result.stderr + )) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 43366df..6f894d5 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -109,6 +109,9 @@ struct CurrentBlock: Codable, Sendable { let cacheReadTokens: Int let cacheWriteTokens: Int let cacheHitPercent: Double + /// Codex credits consumed in the period; 0 when there is no Codex usage. + /// Feeds the optional third hero metric (cost → tokens → credits). (#568) + let codexCredits: Double let topActivities: [ActivityEntry] let topModels: [ModelEntry] let providers: [String: Double] @@ -123,7 +126,7 @@ extension CurrentBlock { enum CodingKeys: String, CodingKey { case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, - cacheHitPercent, topActivities, topModels, providers, topProjects, + cacheHitPercent, codexCredits, topActivities, topModels, providers, topProjects, modelEfficiency, topSessions, retryTax, routingWaste } // Custom decode so older CLI builds whose menubar payload is missing @@ -145,6 +148,9 @@ extension CurrentBlock { cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) ?? 0 cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens) ?? 0 cacheHitPercent = try c.decode(Double.self, forKey: .cacheHitPercent) + // decodeIfPresent so payloads from older CLI builds (before codex credits + // were forwarded) still decode, reporting 0 credits until the CLI updates. + codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits) ?? 0 topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities) topModels = try c.decode([ModelEntry].self, forKey: .topModels) providers = try c.decode([String: Double].self, forKey: .providers) @@ -265,6 +271,7 @@ extension MenubarPayload { cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitPercent: 0, + codexCredits: 0, topActivities: [], topModels: [], providers: [:], diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift index 350983f..211e7f1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift @@ -21,6 +21,15 @@ struct SubscriptionUsage: Sendable, Equatable { } } + /// A model-scoped weekly limit from the `limits` array (e.g. the Fable + /// bucket). The label is the API's `scope.model.display_name`, so new + /// model buckets show up without a client update. + struct ScopedWindow: Sendable, Equatable { + let label: String + let percent: Double + let resetsAt: Date? + } + let tier: Tier let rawTier: String? let fiveHourPercent: Double? @@ -31,6 +40,7 @@ struct SubscriptionUsage: Sendable, Equatable { let sevenDayOpusResetsAt: Date? let sevenDaySonnetPercent: Double? let sevenDaySonnetResetsAt: Date? + let scopedWeekly: [ScopedWindow] let fetchedAt: Date static func tier(from raw: String?) -> Tier { diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift new file mode 100644 index 0000000..c203142 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/UsageRefreshCadence.swift @@ -0,0 +1,42 @@ +import Foundation + +/// User-configurable cadence for the usage payload refresh loop (#647). +/// `auto` keeps the adaptive default: 30s while active, backed off on battery, +/// in Low Power Mode, and while the displays sleep. `manual = 0` never +/// auto-spawns; usage refreshes only on popover open, Refresh Now, and first +/// launch. Stored as raw seconds in UserDefaults (auto = -1), mirroring +/// SubscriptionRefreshCadence. +enum UsageRefreshCadence: Int, CaseIterable, Identifiable { + case auto = -1 + case manual = 0 + case oneMinute = 60 + case fiveMinutes = 300 + case fifteenMinutes = 900 + + var id: Int { rawValue } + + var label: String { + switch self { + case .auto: return "Auto (30s, less on battery)" + case .manual: return "Manual" + case .oneMinute: return "1 minute" + case .fiveMinutes: return "5 minutes" + case .fifteenMinutes: return "15 minutes" + } + } + + static let defaultsKey = "CodeBurnMenubarRefreshSeconds" + static let `default`: UsageRefreshCadence = .auto + + static var current: UsageRefreshCadence { + get { + // integer(forKey:) returns 0 for a missing key, which aliases + // `manual`; probe object(forKey:) to seed the default instead. + if UserDefaults.standard.object(forKey: defaultsKey) == nil { + return .default + } + return UsageRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) } + } +} diff --git a/mac/Sources/CodeBurnMenubar/RefreshCadence.swift b/mac/Sources/CodeBurnMenubar/RefreshCadence.swift new file mode 100644 index 0000000..be05cf3 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/RefreshCadence.swift @@ -0,0 +1,50 @@ +import Foundation +import IOKit.ps + +/// Decides how often the background refresh loop may spawn CLI fetches. The +/// 30s timer keeps firing (cheap); this throttles the expensive part - each +/// fetch is a full Node process at 100%+ CPU for seconds (#647). With the +/// popover closed nobody is looking at anything but the status figure, so on +/// battery or in Low Power Mode the spawn cadence backs off. Opening the +/// popover always refreshes immediately (forceRefresh on stale), so the +/// backoff never shows a user stale data they are actually looking at. +enum RefreshCadence { + static let activeSeconds: TimeInterval = 30 + static let batteryIdleSeconds: TimeInterval = 150 + static let lowPowerIdleSeconds: TimeInterval = 300 + + /// nil means "never auto-spawn" (manual mode): usage refreshes only on + /// popover open, Refresh Now, and first launch. + static func interval( + mode: UsageRefreshCadence, + popoverOpen: Bool, + onBattery: Bool, + lowPowerMode: Bool + ) -> TimeInterval? { + switch mode { + case .manual: + return nil + case .auto: + if popoverOpen { return activeSeconds } + if lowPowerMode { return lowPowerIdleSeconds } + if onBattery { return batteryIdleSeconds } + return activeSeconds + case .oneMinute, .fiveMinutes, .fifteenMinutes: + // A fixed user-chosen cadence, except an open popover always gets + // the active cadence: the user is looking at the numbers. + return popoverOpen + ? min(activeSeconds, TimeInterval(mode.rawValue)) + : TimeInterval(mode.rawValue) + } + } +} + +enum PowerSource { + static func isOnBattery() -> Bool { + // Copy function -> retained; Get function -> borrowed (unretained). + guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let type = IOPSGetProvidingPowerSourceType(snapshot)?.takeUnretainedValue() as String? + else { return false } + return type == kIOPMBatteryPowerKey + } +} diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index ace7b19..c102633 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -66,7 +66,7 @@ enum CodeburnCLI { // The menubar runs as an accessory app with no foreground window, and macOS // background-throttles accessory apps and their children. Without this lift the // codeburn subprocess parses 5-10x slower than the same command run from a - // user-interactive terminal, which starves the 15s refresh cadence on large corpora. + // user-interactive terminal, which starves the 30s refresh cadence on large corpora. process.qualityOfService = .userInitiated return process } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 5e69ce8..e85dc14 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -1107,13 +1107,19 @@ private struct PlanInsight: View { Group { switch store.subscriptionLoadState { case .notBootstrapped: - PlanConnectView { Task { await store.bootstrapSubscription() } } + PlanConnectView( + title: "Connect Claude subscription", + message: "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." + ) { Task { await store.bootstrapSubscription() } } case .dormant: // Previously bootstrapped, but we deferred the keychain // prompt until the user clicked Connect. Show the same // PlanConnectView, but the action now activates via the // existing credential rather than re-bootstrapping. - PlanConnectView { Task { await store.activateClaudeFromDormant() } } + PlanConnectView( + title: "Connect Claude subscription", + message: "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically." + ) { Task { await store.activateClaudeFromDormant() } } case .bootstrapping: PlanLoadingView() case .loading: @@ -1123,7 +1129,10 @@ private struct PlanInsight: View { PlanLoadingView() } case .noCredentials: - PlanNoCredentialsView() + PlanNoCredentialsView( + title: "No Claude credentials found", + message: "Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again." + ) { Task { await store.bootstrapSubscription() } } case .failed: PlanFailedView(error: store.subscriptionError) case .transientFailure: @@ -1133,7 +1142,11 @@ private struct PlanInsight: View { PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.") } case let .terminalFailure(reason): - PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } } + PlanReconnectView( + title: "Reconnect Claude", + reason: reason, + fallback: "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect." + ) { Task { await store.bootstrapSubscription() } } case .loaded: if let usage { loadedBody(usage: usage) @@ -1172,6 +1185,9 @@ private struct PlanInsight: View { if let p = usage.sevenDaySonnetPercent { UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"]) } + ForEach(usage.scopedWeekly, id: \.label) { scoped in + UtilizationRow(label: "7-day \(scoped.label)", percent: scoped.percent, resetsAt: scoped.resetsAt, projection: projections["scoped_\(scoped.label)"]) + } } OptimizeSavingsBadge(payload: store.currentPayload) @@ -1183,12 +1199,15 @@ private struct PlanInsight: View { private func recomputeProjections(usage: SubscriptionUsage) async { var result: [String: WindowProjection] = [:] - let inputs: [(String, Double?, Date?, TimeInterval)] = [ + var inputs: [(String, Double?, Date?, TimeInterval)] = [ ("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, Self.fiveHourSeconds), ("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, Self.sevenDaySeconds), ("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, Self.sevenDaySeconds), ("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, Self.sevenDaySeconds), ] + for scoped in usage.scopedWeekly { + inputs.append(("scoped_\(scoped.label)", scoped.percent, scoped.resetsAt, Self.sevenDaySeconds)) + } for (key, percent, resetsAt, windowSeconds) in inputs { if let projection = await project(key: key, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) { result[key] = projection @@ -1253,24 +1272,24 @@ private struct PlanLoadingView: View { } private struct PlanNoCredentialsView: View { - @Environment(AppStore.self) private var store + let title: String + let message: String + let onRetry: () -> Void var body: some View { VStack(spacing: 10) { Image(systemName: "key.slash") .font(.system(size: 24)) .foregroundStyle(.tertiary) - Text("No Claude credentials found") + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.") + Text(.init(message)) .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 280) - Button("Try Again") { - Task { await store.bootstrapSubscription() } - } + Button("Try Again", action: onRetry) .controlSize(.small) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) @@ -1313,9 +1332,12 @@ private struct PlanFailedView: View { } /// Shown the very first time a user opens the Plan tab. Clicking Connect is the -/// only path to triggering the macOS keychain prompt for Claude Code credentials — -/// the menubar app does not touch the keychain at startup. +/// only path to triggering the provider's credential read (for Claude, the +/// macOS keychain prompt) — the menubar app does not touch credentials at +/// startup. private struct PlanConnectView: View { + let title: String + let message: String let onConnect: () -> Void var body: some View { @@ -1323,10 +1345,10 @@ private struct PlanConnectView: View { Image(systemName: "link.circle") .font(.system(size: 26)) .foregroundStyle(Theme.brandAccent) - Text("Connect Claude subscription") + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.") + Text(.init(message)) .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -1343,10 +1365,12 @@ private struct PlanConnectView: View { /// Shown when the refresh token has been invalidated (typically because the user /// re-authenticated on another device). Clicking the button re-runs bootstrap, -/// which reads Claude's credentials source again and writes a fresh copy to our -/// own keychain item. +/// which reads the provider's credentials source again and writes a fresh copy +/// to our own keychain item. private struct PlanReconnectView: View { + let title: String let reason: String? + let fallback: String let onReconnect: () -> Void var body: some View { @@ -1354,10 +1378,10 @@ private struct PlanReconnectView: View { Image(systemName: "arrow.triangle.2.circlepath.circle") .font(.system(size: 24)) .foregroundStyle(.red) - Text("Reconnect Claude") + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.") + Text(reason ?? fallback) .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -1387,9 +1411,15 @@ private struct CodexPlanInsight: View { Group { switch store.codexLoadState { case .notBootstrapped: - PlanConnectView { Task { await store.bootstrapCodex() } } + PlanConnectView( + title: "Connect ChatGPT subscription", + message: "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically." + ) { Task { await store.bootstrapCodex() } } case .dormant: - PlanConnectView { Task { await store.activateCodexFromDormant() } } + PlanConnectView( + title: "Connect ChatGPT subscription", + message: "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically." + ) { Task { await store.activateCodexFromDormant() } } case .bootstrapping: PlanLoadingView() case .loading: @@ -1399,7 +1429,10 @@ private struct CodexPlanInsight: View { PlanLoadingView() } case .noCredentials: - PlanNoCredentialsView() + PlanNoCredentialsView( + title: "No Codex credentials found", + message: "Sign in with Codex first: run `codex login` in your terminal. Then click Try Again." + ) { Task { await store.bootstrapCodex() } } case .failed: PlanFailedView(error: store.codexError) case .transientFailure: @@ -1409,7 +1442,11 @@ private struct CodexPlanInsight: View { PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.") } case let .terminalFailure(reason): - PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } } + PlanReconnectView( + title: "Reconnect Codex", + reason: reason, + fallback: "Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect." + ) { Task { await store.bootstrapCodex() } } case .loaded: if let usage = store.codexUsage { loadedBody(usage: usage) diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 2ab3836..6fa1ad7 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -11,14 +11,30 @@ struct HeroSection: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var heroPressed: Bool = false - /// Cycle the hero metric on tap: cost → tokens (↑↓) → cost. - /// Two-mode UX replaces the previous Settings picker so users discover - /// the alternate view without digging through preferences. Persists via - /// UserDefaults. + /// Whether the current period has Codex credit usage. Credits only join + /// the hero's tap-cycle when there's something to show — a non-Codex user + /// never sees a "0 cr" state. + private var creditsAvailable: Bool { + store.currentPayload.current.codexCredits > 0 + } + + /// The effective metric to render. Normalizes a stale persisted `.credits` + /// down to `.cost` when the current period has no credits, so switching to + /// a non-Codex period never leaves the hero stuck on an empty credits view. + private var metric: DisplayMetric { + if store.displayMetric == .credits && !creditsAvailable { return .cost } + return store.displayMetric + } + + /// Cycle the hero metric on tap: cost → tokens (↑↓) → credits → cost. + /// Credits is skipped when the period has none. Replaces the old Settings + /// picker so users discover the alternate views without digging through + /// preferences. Persists via UserDefaults. private func cycleMetric() { - switch store.displayMetric { - case .cost: store.displayMetric = .tokens - case .tokens: store.displayMetric = .cost + switch metric { + case .cost: store.displayMetric = .tokens + case .tokens: store.displayMetric = creditsAvailable ? .credits : .cost + case .credits: store.displayMetric = .cost } } @@ -64,7 +80,7 @@ struct HeroSection: View { .onEnded { _ in cycleMetric(); heroPressed = false } ) .help(metricTooltip) - .accessibilityLabel(store.displayMetric == .tokens ? "Total tokens" : "Total cost") + .accessibilityLabel(accessibilityMetricLabel) .accessibilityValue(heroText) .accessibilityHint(metricTooltip) .accessibilityAddTraits(.isButton) @@ -73,7 +89,7 @@ struct HeroSection: View { Spacer() VStack(alignment: .trailing, spacing: 2) { - if store.displayMetric == .tokens { + if metric == .tokens { // ↑ in — everything fed to the model (fresh input + cache // read + cache write). ↓ out — tokens the model generated. // Arrows follow the upload=in / download=out convention. @@ -112,7 +128,7 @@ struct HeroSection: View { SparklineView(points: sparkPoints) .frame(height: 22) .padding(.top, 2) - .accessibilityLabel("14-day \(store.displayMetric == .tokens ? "token" : "spend") trend") + .accessibilityLabel("14-day \(metric == .tokens ? "token" : "spend") trend") } // Daily budget warning banner — uses todayPayload (always-warm @@ -140,18 +156,30 @@ struct HeroSection: View { .padding(.bottom, 12) } - /// Last 14 days of the hero metric, feeding the inline sparkline. + /// Last 14 days of the hero metric, feeding the inline sparkline. Credits + /// have no per-day series in the daily history, so that mode reuses the + /// spend sparkline as a secondary at-a-glance trend. private var sparkPoints: [Double] { let days = store.currentPayload.history.daily.suffix(14) - return store.displayMetric == .tokens ? days.map(\.effectiveTokens) : days.map(\.cost) + return metric == .tokens ? days.map(\.effectiveTokens) : days.map(\.cost) } /// One-line tooltip surfaced on hover. Tells the user the next view /// they'd see if they tapped — the affordance is otherwise invisible. private var metricTooltip: String { - switch store.displayMetric { - case .cost: return "Tap to show tokens" - case .tokens: return "Tap to show cost" + switch metric { + case .cost: return "Tap to show tokens" + case .tokens: return creditsAvailable ? "Tap to show Codex credits" : "Tap to show cost" + case .credits: return "Tap to show cost" + } + } + + /// Accessibility label for the hero figure, tracking the active metric. + private var accessibilityMetricLabel: String { + switch metric { + case .cost: return "Total cost" + case .tokens: return "Total tokens" + case .credits: return "Codex credits" } } @@ -173,17 +201,27 @@ struct HeroSection: View { return c.inputTokens + c.cacheReadTokens + c.cacheWriteTokens } - /// Hero figure text. Falls back to currency for `.cost`; renders the total - /// token throughput for `.tokens` (the side caption splits it into in↑ / out↓). + /// Hero figure text. Currency for `.cost`; total token throughput for + /// `.tokens` (the side caption splits it into in↑ / out↓); Codex credit + /// total for `.credits`. private var heroText: String { - if store.displayMetric == .tokens { + switch metric { + case .tokens: let total = Double(totalTokens) if total >= 1_000_000_000 { return String(format: "%.2fB tok", total / 1_000_000_000) } if total >= 1_000_000 { return String(format: "%.1fM tok", total / 1_000_000) } if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) } return String(format: "%.0f tok", total) + case .credits: + let cr = store.currentPayload.current.codexCredits + if cr >= 1_000_000 { return String(format: "%.1fM cr", cr / 1_000_000) } + if cr >= 1_000 { return String(format: "%.1fK cr", cr / 1_000) } + // Sub-1K credits keep one decimal only when fractional, so a whole + // number reads "42 cr" not "42.0 cr". + return cr == cr.rounded() ? String(format: "%.0f cr", cr) : String(format: "%.1f cr", cr) + case .cost: + return store.currentPayload.current.cost.asCurrency() } - return store.currentPayload.current.cost.asCurrency() } private func formatTokens(_ n: Double) -> String { diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 6a6797e..fe73375 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -29,6 +29,12 @@ struct SettingsView: View { private struct GeneralSettingsTab: View { @Environment(AppStore.self) private var store + // AppStorage (not a computed Binding over UsageRefreshCadence.current): + // a plain UserDefaults write does not invalidate the view, so the picker + // label would never reflect the selection even though the value landed. + @AppStorage(UsageRefreshCadence.defaultsKey) + private var usageRefreshSeconds: Int = UsageRefreshCadence.default.rawValue + var body: some View { Form { Section("Display") { @@ -57,6 +63,21 @@ private struct GeneralSettingsTab: View { } } + Section("Usage Refresh") { + Picker("Update every", selection: Binding( + get: { UsageRefreshCadence(rawValue: usageRefreshSeconds) ?? .default }, + set: { usageRefreshSeconds = $0.rawValue } + )) { + ForEach(UsageRefreshCadence.allCases) { cadence in + Text(cadence.label).tag(cadence) + } + } + .pickerStyle(.menu) + Text("How often the menubar figure re-reads your local session data. Auto refreshes every 30 seconds while you're plugged in and backs off on battery; Manual only refreshes when you open the popover or click Refresh Now.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Section("Alerts") { Picker("Daily budget", selection: Binding( get: { store.dailyBudget }, diff --git a/mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift new file mode 100644 index 0000000..4ad729d --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ClaudeSubscriptionParsingTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Claude usage response parsing") +struct ClaudeSubscriptionParsingTests { + // Shape captured from the live oauth/usage endpoint (2026-07): named + // windows plus a `limits` array carrying model-scoped weekly buckets. + private let liveShape = """ + { + "five_hour": { "utilization": 13.0, "resets_at": "2026-07-02T22:09:59.599633+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null }, + "seven_day": { "utilization": 63.0, "resets_at": "2026-07-03T06:59:59.599658+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null }, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "extra_usage": { "is_enabled": false }, + "limits": [ + { "kind": "session", "group": "session", "percent": 13, "severity": "normal", "resets_at": "2026-07-02T22:09:59.456907+00:00", "scope": null, "is_active": false }, + { "kind": "weekly_all", "group": "weekly", "percent": 63, "severity": "normal", "resets_at": "2026-07-03T06:59:59.456926+00:00", "scope": null, "is_active": false }, + { "kind": "weekly_scoped", "group": "weekly", "percent": 94, "severity": "critical", "resets_at": "2026-07-03T06:59:59.457220+00:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": true } + ] + } + """ + + @Test("model-scoped weekly bucket surfaces with its display name") + func scopedWeeklyParsed() throws { + let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x") + #expect(usage.scopedWeekly.count == 1) + let fable = try #require(usage.scopedWeekly.first) + #expect(fable.label == "Fable") + #expect(fable.percent == 94) + #expect(fable.resetsAt != nil) + } + + @Test("named windows still map alongside limits") + func namedWindowsUnaffected() throws { + let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x") + #expect(usage.fiveHourPercent == 13.0) + #expect(usage.sevenDayPercent == 63.0) + #expect(usage.sevenDayOpusPercent == nil) + #expect(usage.sevenDaySonnetPercent == nil) + #expect(usage.tier == .max20x) + } + + @Test("session and weekly_all limits are not duplicated as scoped rows") + func unscopedKindsSkipped() throws { + let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: nil) + #expect(!usage.scopedWeekly.contains { $0.percent == 13 || $0.percent == 63 }) + } + + @Test("response without a limits array parses with no scoped windows") + func missingLimitsIsBackCompat() throws { + let old = """ + { + "five_hour": { "utilization": 40.0, "resets_at": "2026-07-02T22:00:00+00:00" }, + "seven_day": { "utilization": 12.5, "resets_at": "2026-07-03T07:00:00+00:00" } + } + """ + let usage = try ClaudeSubscriptionService.parseUsage(Data(old.utf8), rawTier: "pro") + #expect(usage.scopedWeekly.isEmpty) + #expect(usage.fiveHourPercent == 40.0) + #expect(usage.tier == .pro) + } + + @Test("weekly_scoped without a model display name is skipped") + func scopedWithoutNameSkipped() throws { + let body = """ + { + "limits": [ + { "kind": "weekly_scoped", "percent": 50, "resets_at": "2026-07-03T07:00:00+00:00", "scope": { "model": null } } + ] + } + """ + let usage = try ClaudeSubscriptionService.parseUsage(Data(body.utf8), rawTier: nil) + #expect(usage.scopedWeekly.isEmpty) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift index 80dde63..ea75fe8 100644 --- a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift @@ -82,3 +82,33 @@ struct AsyncSemaphoreTests { #expect(done == count) } } + +// A MenubarPayload decode failure must surface the CLI's actual stdout/stderr so +// a stray banner on stdout (see #515) is self-diagnosing, not an opaque +// "not valid JSON". (upstream #547) +@Suite("CLIDecodeFailure") +struct CLIDecodeFailureTests { + private struct Boom: Error {} + + @Test("surfaces the stdout snippet, byte count, and stderr") + func surfacesOutput() { + let failure = CLIDecodeFailure( + underlying: Boom(), + stdoutByteCount: 13, + stdoutSnippet: "(node) banner", + stderr: "warn: x" + ) + let text = failure.description + #expect(text.contains("(node) banner")) + #expect(text.contains("13 bytes")) + #expect(text.contains("warn: x")) + } + + @Test("reports empty stdout distinctly") + func emptyStdout() { + let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "") + let text = failure.description + #expect(text.contains("0 bytes")) + #expect(text.contains("")) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift b/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift new file mode 100644 index 0000000..e1eff8d --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +// The idle-energy fix (#647/#648): the 30s timer keeps ticking, but the +// expensive Node-spawn cadence backs off on battery / Low Power / display-off +// with the popover closed, and never auto-spawns in Manual mode. RefreshCadence +// is a pure function of those inputs, so its decisions are fully unit-testable. +@Suite("RefreshCadence") +struct RefreshCadenceTests { + @Test("Auto with the popover open always uses the active cadence") + func autoPopoverOpenActive() { + #expect(RefreshCadence.interval(mode: .auto, popoverOpen: true, onBattery: true, lowPowerMode: true) + == RefreshCadence.activeSeconds) + } + + @Test("Auto idle on AC stays active") + func autoIdleOnAC() { + #expect(RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: false) + == RefreshCadence.activeSeconds) + } + + @Test("Auto idle on battery backs off") + func autoIdleOnBattery() { + #expect(RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: false) + == RefreshCadence.batteryIdleSeconds) + } + + @Test("Auto in Low Power Mode backs off furthest") + func autoLowPower() { + #expect(RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: true) + == RefreshCadence.lowPowerIdleSeconds) + #expect(RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: true) + == RefreshCadence.lowPowerIdleSeconds) + } + + @Test("Manual never auto-spawns") + func manualNeverSpawns() { + #expect(RefreshCadence.interval(mode: .manual, popoverOpen: false, onBattery: false, lowPowerMode: false) == nil) + #expect(RefreshCadence.interval(mode: .manual, popoverOpen: true, onBattery: false, lowPowerMode: false) == nil) + } + + @Test("A fixed cadence ignores power state") + func fixedIgnoresPower() { + #expect(RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: false, onBattery: true, lowPowerMode: true) + == TimeInterval(UsageRefreshCadence.fiveMinutes.rawValue)) + #expect(RefreshCadence.interval(mode: .fifteenMinutes, popoverOpen: false, onBattery: false, lowPowerMode: false) + == TimeInterval(UsageRefreshCadence.fifteenMinutes.rawValue)) + } + + @Test("A fixed cadence goes active while the popover is open") + func fixedGoesActiveWhenOpen() { + #expect(RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: true, onBattery: true, lowPowerMode: false) + == RefreshCadence.activeSeconds) + } + + @Test("Backoff intervals are ordered active < battery < lowPower") + func backoffOrdering() { + #expect(RefreshCadence.activeSeconds < RefreshCadence.batteryIdleSeconds) + #expect(RefreshCadence.batteryIdleSeconds < RefreshCadence.lowPowerIdleSeconds) + } + + @Test("UsageRefreshCadence defaults to auto when unset and round-trips") + func cadenceDefaultsAndRoundTrips() { + UserDefaults.standard.removeObject(forKey: UsageRefreshCadence.defaultsKey) + #expect(UsageRefreshCadence.current == .auto) + UsageRefreshCadence.current = .manual + #expect(UsageRefreshCadence.current == .manual) + UsageRefreshCadence.current = .fiveMinutes + #expect(UsageRefreshCadence.current == .fiveMinutes) + UserDefaults.standard.removeObject(forKey: UsageRefreshCadence.defaultsKey) + } +} diff --git a/package-lock.json b/package-lock.json index 7d7b8d8..5296772 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "@soumyadebroy3/codeburn", - "version": "2.4.9", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@soumyadebroy3/codeburn", - "version": "2.4.9", + "version": "2.5.0", "license": "MIT", "dependencies": { + "bonjour-service": "^1.4.3", "chalk": "^5.6.2", "commander": "^15.0.0", "ink": "^7.0.4", "react": "^19.2.6", + "selfsigned": "^5.5.0", "strip-ansi": "^7.2.0" }, "bin": { @@ -21,6 +23,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/react": "^19.2.15", + "@types/selfsigned": "^2.0.4", "@vitest/coverage-v8": "^4.1.7", "tsup": "^8.5.1", "tsx": "^4.22.3", @@ -619,6 +622,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -638,6 +647,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.132.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", @@ -648,6 +669,163 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -1343,6 +1521,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/selfsigned": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/selfsigned/-/selfsigned-2.0.4.tgz", + "integrity": "sha512-vNmPhMatNNp9iZ/Ri2w1fciqNcPA2edA58qhzi5F/qDO49Ap4GtEGytuiTX1pSO1HJr81VopC8/INylO9s0keQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", @@ -1546,6 +1731,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1580,6 +1779,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bonjour-service": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.3.tgz", + "integrity": "sha512-2Kd5UYlFUVgAKMTyuBLl6w49wqfOnbxHqmuH0oCl/n7TfAikR0zoowNOP5BU4dfXmm+Vr9JyEN370auSMx+CNg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1596,6 +1805,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1776,6 +1994,18 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -1876,6 +2106,12 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2467,6 +2703,19 @@ "dev": true, "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2592,6 +2841,23 @@ "pathe": "^2.0.1" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2664,6 +2930,24 @@ } } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -2702,6 +2986,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2813,6 +3103,19 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3025,6 +3328,12 @@ "node": ">=0.8" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3087,9 +3396,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsup": { "version": "8.5.1", @@ -3647,6 +3954,24 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", diff --git a/package.json b/package.json index 16e870b..bfbc2e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@soumyadebroy3/codeburn", - "version": "2.4.9", + "version": "2.5.0", "description": "See where your AI coding tokens go - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js", @@ -12,7 +12,8 @@ ], "scripts": { "bundle-litellm": "node scripts/bundle-litellm.mjs", - "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').copyFileSync('src/cli.ts','dist/cli.js'); require('fs').chmodSync('dist/cli.js', 0o755)\"", + "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').copyFileSync('src/cli.ts','dist/cli.js'); require('fs').chmodSync('dist/cli.js', 0o755)\" && npm run build:dash", + "build:dash": "cd dash && npm install --no-audit --no-fund --silent && npm run build", "dev": "tsx src/cli.ts", "test": "vitest", "prepublishOnly": "npm run build", @@ -50,15 +51,18 @@ "access": "public" }, "dependencies": { + "bonjour-service": "^1.4.3", "chalk": "^5.6.2", "commander": "^15.0.0", "ink": "^7.0.4", "react": "^19.2.6", + "selfsigned": "^5.5.0", "strip-ansi": "^7.2.0" }, "devDependencies": { "@types/node": "^25.9.1", "@types/react": "^19.2.15", + "@types/selfsigned": "^2.0.4", "@vitest/coverage-v8": "^4.1.7", "tsup": "^8.5.1", "tsx": "^4.22.3", diff --git a/src/act/apply.ts b/src/act/apply.ts new file mode 100644 index 0000000..7daba0c --- /dev/null +++ b/src/act/apply.ts @@ -0,0 +1,100 @@ +import { lstat, mkdir, rename, rm, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { randomUUID } from 'crypto' +import type { ActionPlan, ActionRecord, FileChange } from './types.js' +import { appendRecord, defaultActionsDir, withLock } from './journal.js' +import { backupDirFor, relBackupPath, revertChange, sha256File, snapshotFile } from './backup.js' + +// The only mutation path. Order: back up every file the plan touches, apply +// the mutations, hash the results, then journal. If a mutation or the journal +// append throws, the steps already applied are rolled back (newest first) and +// nothing is journaled. +export async function runAction(plan: ActionPlan, actionsDir: string = defaultActionsDir()): Promise { + return withLock(actionsDir, async () => { + const id = randomUUID() + const at = new Date().toISOString() + const backupDir = backupDirFor(actionsDir, id) + await mkdir(backupDir, { recursive: true }) + + // One snapshot per unique path (first occurrence wins), so a path touched + // twice still reverts to its true pre-action bytes. + const snapshots = new Map() + let n = 0 + const snapshot = async (p: string): Promise => { + if (!snapshots.has(p)) { + const existed = await snapshotFile(p, join(backupDir, `${n}.bak`)) + snapshots.set(p, existed ? relBackupPath(id, n++) : null) + } + return snapshots.get(p)! + } + + const changes: FileChange[] = [] + for (const pc of plan.changes) { + changes.push({ + path: pc.path, + backup: await snapshot(pc.path), + op: pc.op, + ...(pc.op === 'move' ? { movedTo: pc.movedTo, destBackup: await snapshot(pc.movedTo) } : {}), + afterHash: '', + }) + } + + const done: number[] = [] + try { + // Stale-plan guard: plans carry full post-edit content computed at + // build time, so refuse if a target changed between preview and + // confirm. Runs before any mutation; failure needs no rollback (the + // catch below only removes the backup dir). + for (const pc of plan.changes) { + if (pc.op === 'move' || pc.expectedHash === undefined) continue + if ((await sha256File(pc.path)) !== pc.expectedHash) { + throw new Error(`${pc.path} changed since the plan was built; re-run codeburn optimize --apply`) + } + } + for (let i = 0; i < plan.changes.length; i++) { + const pc = plan.changes[i]! + if (pc.op === 'move') { + await mkdir(dirname(pc.movedTo), { recursive: true }) + try { + await rename(pc.path, pc.movedTo) + } catch (err) { + // rename cannot replace a directory destination. It is already + // snapshotted (destBackup), so clear it and retry. Other codes + // (e.g. a missing source) rethrow before any destination damage. + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOTEMPTY' && code !== 'EEXIST' && code !== 'EISDIR' && code !== 'ENOTDIR') throw err + await rm(pc.movedTo, { recursive: true, force: true }) + await rename(pc.path, pc.movedTo) + } + } else { + await mkdir(dirname(pc.path), { recursive: true }) + await writeFile(pc.path, pc.content) + } + done.push(i) + } + // Hash after ALL mutations so overlapping changes carry the final state. + // Directories get '' (no content hash); drift detection skips them. + for (const change of changes) { + const p = change.op === 'move' ? change.movedTo! : change.path + const st = await lstat(p).catch(() => null) + change.afterHash = st && !st.isDirectory() ? (await sha256File(p)) ?? '' : '' + } + const record: ActionRecord = { + id, + at, + kind: plan.kind, + findingId: plan.findingId ?? null, + description: plan.description, + changes, + status: 'applied', + ...(plan.baseline ? { baseline: plan.baseline } : {}), + } + await appendRecord(actionsDir, record) + return record + } catch (err) { + for (const i of done.reverse()) await revertChange(actionsDir, changes[i]!) + await rm(backupDir, { recursive: true, force: true }) + throw err + } + }) +} diff --git a/src/act/backup.ts b/src/act/backup.ts new file mode 100644 index 0000000..740ad99 --- /dev/null +++ b/src/act/backup.ts @@ -0,0 +1,78 @@ +import { copyFile, cp, lstat, mkdir, readFile, rename, rm } from 'fs/promises' +import { createHash } from 'crypto' +import { dirname, join } from 'path' +import type { FileChange } from './types.js' + +export function backupDirFor(actionsDir: string, id: string): string { + return join(actionsDir, 'backups', id) +} + +export function relBackupPath(id: string, index: number): string { + return `backups/${id}/${index}.bak` +} + +// Snapshot src (file or directory tree) to dest if it exists; return whether +// it existed so the caller can record backup: null for a create. +export async function snapshotFile(src: string, dest: string): Promise { + try { + const st = await lstat(src) + if (st.isDirectory()) await cp(src, dest, { recursive: true }) + else await copyFile(src, dest) + return true + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false + throw err + } +} + +export function sha256(buf: Buffer): string { + return createHash('sha256').update(buf).digest('hex') +} + +export async function sha256File(path: string): Promise { + try { + return sha256(await readFile(path)) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } +} + +export async function pathExists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +} + +// Reverse a single applied change. Shared by mid-apply rollback and undo. +// Non-move reverts key on backup presence, not the op label, so a create that +// overwrote an existing file and an edit of a missing file restore correctly. +export async function revertChange(actionsDir: string, change: FileChange): Promise { + const restore = async (backup: string, to: string): Promise => { + const src = join(actionsDir, backup) + await mkdir(dirname(to), { recursive: true }) + if ((await lstat(src)).isDirectory()) { + await rm(to, { recursive: true, force: true }) + await cp(src, to, { recursive: true }) + } else { + await copyFile(src, to) + } + } + if (change.op === 'move') { + if (await pathExists(change.movedTo!)) { + await rm(change.path, { recursive: true, force: true }) + await mkdir(dirname(change.path), { recursive: true }) + await rename(change.movedTo!, change.path) + if (change.destBackup) await restore(change.destBackup, change.movedTo!) + } else if (change.backup) { + // The moved file is gone; fall back to the source snapshot. + await restore(change.backup, change.path) + } + return + } + if (change.backup) await restore(change.backup, change.path) + else await rm(change.path, { recursive: true, force: true }) +} diff --git a/src/act/cli.ts b/src/act/cli.ts new file mode 100644 index 0000000..15bf2f7 --- /dev/null +++ b/src/act/cli.ts @@ -0,0 +1,85 @@ +import type { Command } from 'commander' +import { renderTable } from '../text-table.js' +import { defaultActionsDir, readRecords, shortId } from './journal.js' +import { DriftError, undoAction } from './undo.js' +import { buildActReportJson, computeActReport, renderActReport } from './report.js' + +function formatWhen(at: string): string { + return at.replace('T', ' ').slice(0, 16) +} + +export function registerActCommands(program: Command): void { + const act = program + .command('act') + .description('Review and undo changes codeburn has applied') + + act + .command('list') + .description('List applied actions, newest first') + .option('--json', 'Output the full records as JSON') + .action(async (opts: { json?: boolean }) => { + try { + const records = (await readRecords(defaultActionsDir())).reverse() + if (opts.json) { + console.log(JSON.stringify(records, null, 2)) + return + } + if (records.length === 0) { + console.log('No actions recorded yet.') + return + } + const rows = records.map(r => [shortId(r.id), formatWhen(r.at), r.description, r.status]) + console.log(renderTable( + [{ header: 'ID' }, { header: 'When' }, { header: 'Description' }, { header: 'Status' }], + rows, + )) + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + }) + + act + .command('undo [id]') + .description('Undo an action by id (8-char prefix accepted), or the most recent with --last') + .option('--last', 'Undo the most recent action') + .option('--force', 'Undo even if the target files changed since they were applied') + .action(async (id: string | undefined, opts: { last?: boolean; force?: boolean }) => { + if (!id && !opts.last) { + console.error('Specify an action id or --last.') + process.exitCode = 1 + return + } + try { + const record = await undoAction(opts.last ? { last: true } : { id: id! }, { force: opts.force }) + console.log(`Undid ${shortId(record.id)}: ${record.description}`) + } catch (err) { + if (err instanceof DriftError) { + console.error(err.message + ':') + for (const f of err.drifted) console.error(` ${f}`) + console.error('Re-run with --force to undo anyway.') + } else { + console.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + } + }) + + act + .command('report') + .description('Realized vs estimated savings for applied actions older than 3 days') + .option('--json', 'Output the realized report as JSON') + .action(async (opts: { json?: boolean }) => { + try { + const report = await computeActReport() + if (opts.json) { + console.log(JSON.stringify(buildActReportJson(report), null, 2)) + return + } + console.log(renderActReport(report)) + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + }) +} diff --git a/src/act/journal.ts b/src/act/journal.ts new file mode 100644 index 0000000..1d3710b --- /dev/null +++ b/src/act/journal.ts @@ -0,0 +1,93 @@ +import { appendFile, mkdir, readFile, rm, stat, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { getConfigFilePath } from '../config.js' +import type { ActionRecord } from './types.js' + +// Actions live beside config.json under the same CodeBurn home dir; reuse the +// config resolver rather than inventing a second location. +export function defaultActionsDir(): string { + return join(dirname(getConfigFilePath()), 'actions') +} + +export function journalPath(actionsDir: string): string { + return join(actionsDir, 'journal.jsonl') +} + +export function shortId(id: string): string { + return id.slice(0, 8) +} + +export async function appendRecord(actionsDir: string, record: ActionRecord): Promise { + await mkdir(actionsDir, { recursive: true }) + await appendFile(journalPath(actionsDir), JSON.stringify(record) + '\n', 'utf-8') +} + +// Append-only JSONL: a status flip is a full replacement line for the same id, +// so the last line for an id wins. Returns records in creation (first-seen) +// order. Unparseable lines are skipped so a corrupt journal never crashes. +export async function readRecords(actionsDir: string): Promise { + let raw: string + try { + raw = await readFile(journalPath(actionsDir), 'utf-8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } + const order: string[] = [] + const byId = new Map() + for (const line of raw.split('\n')) { + if (!line.trim()) continue + let rec: ActionRecord + try { + rec = JSON.parse(line) as ActionRecord + } catch { + continue + } + if (!rec || typeof rec.id !== 'string') continue + if (!byId.has(rec.id)) order.push(rec.id) + byId.set(rec.id, rec) + } + return order.map(id => byId.get(id)!) +} + +const LOCK_STALE_MS = 60_000 + +function lockPath(actionsDir: string): string { + return join(actionsDir, '.lock') +} + +async function acquireLock(lock: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + // A single wx write: the lock is never observable in an empty state, so + // a freshly taken lock cannot be stolen as stale. + await writeFile(lock, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' }) + return + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + let mtimeMs: number + try { + mtimeMs = (await stat(lock)).mtimeMs + } catch (statErr) { + if ((statErr as NodeJS.ErrnoException).code !== 'ENOENT') throw statErr + continue // holder released between write and stat; retry + } + if (Date.now() - mtimeMs <= LOCK_STALE_MS) { + throw new Error('another codeburn action is in progress (lock held); retry shortly') + } + await rm(lock, { force: true }) + } + } + throw new Error('could not acquire the codeburn action lock') +} + +export async function withLock(actionsDir: string, fn: () => Promise): Promise { + await mkdir(actionsDir, { recursive: true }) + const lock = lockPath(actionsDir) + await acquireLock(lock) + try { + return await fn() + } finally { + await rm(lock, { force: true }) + } +} diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts new file mode 100644 index 0000000..5b90685 --- /dev/null +++ b/src/act/optimize-apply.ts @@ -0,0 +1,186 @@ +import { createInterface } from 'node:readline/promises' +import { homedir } from 'os' +import chalk from 'chalk' +import type { DateRange, ProjectSummary } from '../types.js' +import { scanAndDetect, type WasteFinding } from '../optimize.js' +import { formatCost } from '../currency.js' +import { formatTokens } from '../format.js' +import { runAction } from './apply.js' +import { shortId } from './journal.js' +import { planFindings, type FindingPlan, type PlanContext } from './plans.js' + +export type ApplyOptions = { + yes?: boolean + dryRun?: boolean + only?: string + actionsDir?: string + ctx?: PlanContext + // Test seams: crafted findings skip the session scan; streams default to + // the real stdio. + findings?: WasteFinding[] + costRate?: number + input?: NodeJS.ReadableStream + output?: NodeJS.WritableStream + errorOutput?: NodeJS.WritableStream +} + +function short(p: string): string { + const home = homedir() + return p.startsWith(home) ? '~' + p.slice(home.length) : p +} + +function changeLines(fp: FindingPlan): string[] { + return fp.plan!.changes.map(c => { + const base = c.op === 'move' ? `${short(c.path)} -> ${short(c.movedTo)}` : short(c.path) + const note = fp.pathNotes?.[c.path] + return note ? `${base} (${note})` : base + }) +} + +export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], costRate: number): string { + const lines: string[] = [''] + lines.push(chalk.bold(' Appliable config-class fixes:')) + appliable.forEach((fp, i) => { + const f = fp.finding + const savings = `~${formatTokens(f.tokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(f.tokensSaved * costRate)}` : ''}` + lines.push('') + lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`) + for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`)) + for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) + }) + if (manual.length > 0) { + lines.push('') + lines.push(chalk.dim(' Not auto-appliable (apply by hand):')) + for (const fp of manual) { + lines.push(chalk.dim(` - ${fp.finding.title} [${fp.finding.id}] manual`)) + for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) + } + } + lines.push('') + return lines.join('\n') +} + +function selectPlans(answer: string, appliable: FindingPlan[]): FindingPlan[] { + const a = answer.trim().toLowerCase() + if (a === 'a' || a === 'all' || a === 'y' || a === 'yes') return appliable + if (a === '' || a === 'q' || a === 'quit' || a === 'n' || a === 'no') return [] + const picked: FindingPlan[] = [] + for (const token of a.split(/[\s,]+/)) { + const n = Number.parseInt(token, 10) + if (Number.isInteger(n) && n >= 1 && n <= appliable.length && !picked.includes(appliable[n - 1]!)) { + picked.push(appliable[n - 1]!) + } + } + return picked +} + +async function ask(question: string, input: NodeJS.ReadableStream, output: NodeJS.WritableStream): Promise { + const rl = createInterface({ input, output }) + try { + // EOF (piped stdin) closes the interface with the question pending; + // treat it as "quit" instead of hanging or dying silently. The close + // fallback is deferred one tick so an answer that arrived together with + // EOF still wins the race. + return await new Promise(resolve => { + rl.question(question).then(resolve, () => resolve('')) + rl.once('close', () => setImmediate(() => resolve(''))) + }) + } finally { + rl.close() + } +} + +export async function runOptimizeApply( + projects: ProjectSummary[], + dateRange: DateRange | undefined, + opts: ApplyOptions = {}, +): Promise { + const output = opts.output ?? process.stdout + const errout = opts.errorOutput ?? process.stderr + const print = (line = ''): void => { output.write(line + '\n') } + + let findings = opts.findings + let costRate = opts.costRate ?? 0 + if (!findings) { + errout.write(chalk.dim(' Analyzing your sessions...\n')) + const scanned = await scanAndDetect(projects, dateRange) + findings = scanned.findings + costRate = scanned.costRate + } + const plans = planFindings(findings, opts.ctx) + + let appliable = plans.filter(p => p.plan !== null) + const manual = plans.filter(p => p.plan === null) + + const onlyIds = opts.only ? opts.only.split(',').map(s => s.trim()).filter(Boolean) : [] + if (onlyIds.length > 0) { + const valid = new Set(appliable.map(p => p.finding.id)) + const bad = onlyIds.filter(id => !valid.has(id)) + if (bad.length > 0) { + const validList = valid.size > 0 ? [...valid].join(', ') : '(none)' + errout.write(`codeburn optimize --apply: unknown or not-appliable finding id${bad.length === 1 ? '' : 's'}: ${bad.join(', ')}. Appliable ids for this run: ${validList}\n`) + process.exitCode = 2 + return + } + appliable = appliable.filter(p => onlyIds.includes(p.finding.id)) + } + + if (appliable.length === 0) { + print(chalk.dim('\n No appliable config-class fixes for this period.')) + for (const fp of manual) { + for (const note of fp.notes) print(chalk.yellow(` ! ${fp.finding.id}: ${note}`)) + } + print() + return + } + + print(renderApplyList(appliable, manual, costRate)) + + if (opts.dryRun) { + print(chalk.dim(' Dry run: nothing was changed.\n')) + return + } + + let selected: FindingPlan[] + if (opts.yes) { + // CLAUDE.md rules land in the cwd's file; blanket --yes from an unrelated + // directory would write advice into the wrong project. They need the + // interactive picker or an explicit --only selection. + const explicit = new Set(onlyIds) + const skipped = appliable.filter(fp => fp.plan!.kind === 'claude-md-rule' && !explicit.has(fp.finding.id)) + selected = appliable.filter(fp => !skipped.includes(fp)) + for (const fp of skipped) { + print(chalk.yellow(` Skipped ${fp.finding.id}: CLAUDE.md edits are not applied with --yes; use the interactive picker or --only ${fp.finding.id}.`)) + } + } else { + const answer = await ask(' Apply all / pick numbers / quit [a / 1 2 3 / q]: ', opts.input ?? process.stdin, output) + selected = selectPlans(answer, appliable) + } + + if (selected.length === 0) { + print(chalk.dim(' Nothing applied.\n')) + return + } + + // Stamp a trailing-14-day before-baseline onto each plan so runAction + // persists it and `act report` can measure realized savings later. Best + // effort: a scan failure leaves the baseline absent (reported "not + // measurable"), never blocking the apply. + try { + const { captureBaselinesForPlans } = await import('./report.js') + await captureBaselinesForPlans(selected) + } catch { /* baseline is optional; apply proceeds without it */ } + + print() + for (const fp of selected) { + try { + const record = await runAction(fp.plan!, opts.actionsDir) + print(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`) + print(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } catch (e) { + errout.write(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`) + '\n') + process.exitCode = 1 + } + } + print() +} diff --git a/src/act/plans.ts b/src/act/plans.ts new file mode 100644 index 0000000..861d4c8 --- /dev/null +++ b/src/act/plans.ts @@ -0,0 +1,446 @@ +import { existsSync, readFileSync } from 'fs' +import { isAbsolute, join } from 'path' +import { homedir } from 'os' +import type { ActionKind, ActionPlan, PlannedChange } from './types.js' +import { sha256 } from './backup.js' +import type { WasteFinding } from '../optimize.js' + +// Turns an optimize finding into a concrete, journaled file-mutation plan. +// Only config-class findings are appliable; everything else yields plan: null +// (shown as "manual" by the CLI). Every path is derived from an injectable +// context so tests can point the whole thing at a fixture home. + +export type PlanContext = { + homeDir?: string + cwd?: string + shell?: string +} + +export type BuiltPlan = { + plan: ActionPlan | null + // Human-facing skip reasons and parse errors, surfaced in the apply summary. + notes: string[] + // Per-file preview annotations (path -> text), e.g. which ~/.claude.json + // project entries lose a server. + pathNotes?: Record +} + +export type FindingPlan = BuiltPlan & { finding: WasteFinding } + +type ResolvedPaths = { + homeDir: string + cwd: string + projectMcpJson: string + projectSettings: string + projectSettingsLocal: string + userClaudeJson: string + skillsDir: string + agentsDir: string + commandsDir: string + projectClaudeMd: string + shellRc: string +} + +function resolvePaths(ctx: PlanContext): ResolvedPaths { + const homeDir = ctx.homeDir ?? homedir() + const cwd = ctx.cwd ?? process.cwd() + const shell = ctx.shell ?? process.env['SHELL'] ?? '' + return { + homeDir, + cwd, + projectMcpJson: join(cwd, '.mcp.json'), + projectSettings: join(cwd, '.claude', 'settings.json'), + projectSettingsLocal: join(cwd, '.claude', 'settings.local.json'), + userClaudeJson: join(homeDir, '.claude.json'), + skillsDir: join(homeDir, '.claude', 'skills'), + agentsDir: join(homeDir, '.claude', 'agents'), + commandsDir: join(homeDir, '.claude', 'commands'), + projectClaudeMd: join(cwd, 'CLAUDE.md'), + shellRc: join(homeDir, /zsh/.test(shell) ? '.zshrc' : '.bashrc'), + } +} + +export function planFor(finding: WasteFinding, ctx: PlanContext = {}): ActionPlan | null { + return buildPlan(finding, resolvePaths(ctx)).plan +} + +export function planFindings(findings: WasteFinding[], ctx: PlanContext = {}): FindingPlan[] { + const r = resolvePaths(ctx) + return findings.map(finding => ({ finding, ...buildPlan(finding, r) })) +} + +function buildPlan(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + switch (finding.id) { + case 'mcp-low-coverage': return buildMcpRemove(finding, r) + case 'unused-mcp': return buildMcpRemove(finding, r) + case 'mcp-project-scope': return buildMcpProjectScope(finding, r) + case 'unused-skills': return buildArchive(finding, r, 'skill') + case 'unused-agents': return buildArchive(finding, r, 'agent') + case 'unused-commands': return buildArchive(finding, r, 'command') + case 'bash-output-cap': return buildShellConfig(finding, r) + default: + if (finding.fix.type === 'paste' && finding.fix.destination === 'claude-md') { + return buildClaudeMdRule(finding, r) + } + return { plan: null, notes: [] } + } +} + +// --------------------------------------------------------------------------- +// MCP config editing (remove + project-scope) +// --------------------------------------------------------------------------- + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +function shortPath(p: string, homeDir: string): string { + return p.startsWith(homeDir) ? '~' + p.slice(homeDir.length) : p +} + +// Config keys are the server's original name; coverage findings carry the +// runtime-normalized form (":" -> "_"). Match either. +function findServerKey(container: Record | undefined, server: string): string | null { + if (!container) return null + for (const k of Object.keys(container)) { + if (k === server || k.replace(/:/g, '_') === server) return k + } + return null +} + +type DocState = { + path: string + doc: Record + existed: boolean + dirty: boolean + // sha256 of the raw bytes the doc was parsed from (before the BOM strip); + // null when the file did not exist. Becomes the change's expectedHash so + // runAction refuses to apply over a file edited after the plan was built. + rawHash: string | null +} + +// Reads each config file at most once, tracks parse errors, and emits one +// PlannedChange per file it actually mutated. +class ConfigDocs { + private docs = new Map() + private errors = new Map() + constructor(private homeDir: string) {} + + load(path: string): DocState | null { + if (this.docs.has(path)) return this.docs.get(path)! + if (!existsSync(path)) { + const state: DocState = { path, doc: {}, existed: false, dirty: false, rawHash: null } + this.docs.set(path, state) + return state + } + let buf: Buffer + try { + buf = readFileSync(path) + } catch (e) { + this.errors.set(path, `could not read ${shortPath(path, this.homeDir)}: ${errMessage(e)}`) + this.docs.set(path, null) + return null + } + const rawHash = sha256(buf) + let raw = buf.toString('utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + try { + const doc = JSON.parse(raw) as Record + const state: DocState = { path, doc, existed: true, dirty: false, rawHash } + this.docs.set(path, state) + return state + } catch (e) { + this.errors.set(path, `could not parse ${shortPath(path, this.homeDir)}: ${errMessage(e)}`) + this.docs.set(path, null) + return null + } + } + + changes(): PlannedChange[] { + const out: PlannedChange[] = [] + for (const state of this.docs.values()) { + if (state && state.dirty) { + out.push({ + op: state.existed ? 'edit' : 'create', + path: state.path, + content: JSON.stringify(state.doc, null, 2) + '\n', + expectedHash: state.rawHash, + }) + } + } + return out + } + + errorNotes(): string[] { + return [...this.errors.values()] + } +} + +type ContainerRef = { container: Record; projectPath: string | null } + +function serverContainers(state: DocState, isUserClaudeJson: boolean): ContainerRef[] { + const containers: ContainerRef[] = [] + const top = state.doc.mcpServers + if (top && typeof top === 'object') containers.push({ container: top as Record, projectPath: null }) + if (isUserClaudeJson) { + const projects = state.doc.projects + if (projects && typeof projects === 'object') { + for (const [projectPath, entry] of Object.entries(projects as Record)) { + const pm = (entry as Record | null)?.['mcpServers'] + if (pm && typeof pm === 'object') containers.push({ container: pm as Record, projectPath }) + } + } + } + return containers +} + +// Deletes the server from the file's containers. With projectScope set, only +// the top-level container and the listed (cold) project entries are touched; +// entries under any other project path keep their copy. +function deleteServer( + state: DocState, + server: string, + isUserClaudeJson: boolean, + projectScope?: ReadonlySet, +): { removed: boolean; projectEntries: string[] } { + let removed = false + const projectEntries: string[] = [] + for (const { container, projectPath } of serverContainers(state, isUserClaudeJson)) { + if (projectScope && projectPath !== null && !projectScope.has(projectPath)) continue + const key = findServerKey(container, server) + if (!key) continue + delete container[key] + state.dirty = true + removed = true + if (projectPath !== null) projectEntries.push(projectPath) + } + return { removed, projectEntries } +} + +function readServerValue(state: DocState, server: string, isUserClaudeJson: boolean): unknown { + for (const { container } of serverContainers(state, isUserClaudeJson)) { + const key = findServerKey(container, server) + if (key) return container[key] + } + return undefined +} + +function projectRemovalNote(server: string, entries: string[], homeDir: string): string { + const noun = entries.length === 1 ? 'entry' : 'entries' + return `removes ${server} from ${entries.length} project ${noun}: ${entries.map(e => shortPath(e, homeDir)).join(', ')}` +} + +function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : [] + const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson] + const docs = new ConfigDocs(r.homeDir) + const skips: string[] = [] + const pathNotes: Record = {} + const addPathNote = (path: string, note: string): void => { + pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note + } + + for (const server of servers) { + let removed = false + for (const path of searchPaths) { + const state = docs.load(path) + if (!state) continue + const res = deleteServer(state, server, path === r.userClaudeJson) + if (res.removed) removed = true + if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir)) + } + if (!removed) skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`) + } + + const changes = docs.changes() + const notes = [...docs.errorNotes(), ...skips] + if (changes.length === 0) return { plan: null, notes } + return { + plan: mcpPlan('mcp-remove', finding.id, `Remove ${changes.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes), + notes, + ...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}), + } +} + +function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + const entries = finding.apply?.kind === 'mcp-project-scope' ? finding.apply.servers : [] + const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson] + const docs = new ConfigDocs(r.homeDir) + const skips: string[] = [] + const pathNotes: Record = {} + const addPathNote = (path: string, note: string): void => { + pathNotes[path] = pathNotes[path] ? `${pathNotes[path]}; ${note}` : note + } + + for (const { server, keepProjects, removeProjects } of entries) { + const keepers = keepProjects.filter(p => isAbsolute(p)) + if (keepers.length === 0) { + skips.push(`skipped ${server}: no absolute keeper project path to scope into`) + continue + } + + let value: unknown + for (const path of searchPaths) { + const state = docs.load(path) + if (!state) continue + const found = readServerValue(state, server, path === r.userClaudeJson) + if (found !== undefined) { value = found; break } + } + if (value === undefined) { + skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`) + continue + } + + // Scoped removal: only the global entry and the finding's cold projects + // lose the server. The cwd's own config files count as cold only when + // the cwd is in the cold list; a keeper or unrelated cwd keeps its copy. + const coldSet = new Set(removeProjects) + const keeperMcpPaths = new Set(keepers.map(k => join(k, '.mcp.json'))) + for (const path of searchPaths) { + if (keeperMcpPaths.has(path)) continue + const isUser = path === r.userClaudeJson + if (!isUser && !coldSet.has(r.cwd)) continue + const state = docs.load(path) + if (!state) continue + const res = deleteServer(state, server, isUser, isUser ? coldSet : undefined) + if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir)) + } + + for (const keeper of keepers) { + const state = docs.load(join(keeper, '.mcp.json')) + if (!state) { + skips.push(`skipped ${server} for ${keeper}: its .mcp.json could not be parsed`) + continue + } + const existing = state.doc.mcpServers + const mcpServers = existing && typeof existing === 'object' + ? existing as Record + : (state.doc.mcpServers = {}) + mcpServers[server] = value + state.dirty = true + } + } + + const changes = docs.changes() + const notes = [...docs.errorNotes(), ...skips] + if (changes.length === 0) return { plan: null, notes } + return { + plan: mcpPlan('mcp-project-scope', finding.id, `Project-scope ${entries.length === 1 ? 'an MCP server' : 'MCP servers'}`, changes), + notes, + ...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}), + } +} + +function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[]): ActionPlan { + return { kind, findingId, description, changes } +} + +// --------------------------------------------------------------------------- +// Archive unused skills / agents / commands +// --------------------------------------------------------------------------- + +const ARCHIVE_KIND: Record<'skill' | 'agent' | 'command', ActionKind> = { + skill: 'archive-skill', + agent: 'archive-agent', + command: 'archive-command', +} + +function withSuffix(base: string, n: number): string { + const dot = base.lastIndexOf('.') + return dot === -1 ? `${base}-${n}` : `${base.slice(0, dot)}-${n}${base.slice(dot)}` +} + +function buildArchive(finding: WasteFinding, r: ResolvedPaths, capability: 'skill' | 'agent' | 'command'): BuiltPlan { + const names = finding.apply?.kind === 'archive' ? finding.apply.names : [] + const baseDir = capability === 'skill' ? r.skillsDir : capability === 'agent' ? r.agentsDir : r.commandsDir + const isDir = capability === 'skill' + const archivedDir = join(baseDir, '.archived') + const changes: PlannedChange[] = [] + const notes: string[] = [] + const claimed = new Set() + + for (const name of names) { + const source = isDir ? join(baseDir, name) : join(baseDir, `${name}.md`) + if (!existsSync(source)) { + notes.push(`skipped ${name}: ${shortPath(source, r.homeDir)} no longer exists`) + continue + } + const destBase = isDir ? name : `${name}.md` + let dest = join(archivedDir, destBase) + let n = 2 + while (existsSync(dest) || claimed.has(dest)) { + dest = join(archivedDir, withSuffix(destBase, n)) + n++ + } + claimed.add(dest) + changes.push({ op: 'move', path: source, movedTo: dest }) + } + + if (changes.length === 0) return { plan: null, notes } + return { + plan: { + kind: ARCHIVE_KIND[capability], + findingId: finding.id, + description: `Archive ${changes.length} unused ${capability}${changes.length === 1 ? '' : 's'}`, + changes, + }, + notes, + } +} + +// --------------------------------------------------------------------------- +// Marker-block edits (CLAUDE.md rule, shell rc) +// --------------------------------------------------------------------------- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function upsertMarkerBlock(existing: string | null, id: string, text: string, style: 'html' | 'hash'): string { + const begin = style === 'html' ? `` : `# codeburn:begin ${id}` + const end = style === 'html' ? `` : `# codeburn:end ${id}` + const block = `${begin}\n${text}\n${end}\n` + if (!existing) return block + const region = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`) + if (region.test(existing)) return existing.replace(region, block) + return existing.endsWith('\n') ? existing + block : existing + '\n' + block +} + +function markerChange(target: string, id: string, text: string, style: 'html' | 'hash'): PlannedChange { + const buf = existsSync(target) ? readFileSync(target) : null + const existing = buf === null ? null : buf.toString('utf-8') + return { + op: buf === null ? 'create' : 'edit', + path: target, + content: upsertMarkerBlock(existing, id, text, style), + expectedHash: buf === null ? null : sha256(buf), + } +} + +function buildClaudeMdRule(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + if (finding.fix.type !== 'paste') return { plan: null, notes: [] } + const target = r.projectClaudeMd + return { + plan: { + kind: 'claude-md-rule', + findingId: finding.id, + description: `Add the ${finding.id} rule block to ${shortPath(target, r.homeDir)}`, + changes: [markerChange(target, finding.id, finding.fix.text, 'html')], + }, + notes: [], + } +} + +function buildShellConfig(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { + if (finding.fix.type !== 'paste') return { plan: null, notes: [] } + const target = r.shellRc + return { + plan: { + kind: 'shell-config', + findingId: finding.id, + description: `Set the bash output cap in ${shortPath(target, r.homeDir)}`, + changes: [markerChange(target, finding.id, finding.fix.text, 'hash')], + }, + notes: [], + } +} diff --git a/src/act/report.ts b/src/act/report.ts new file mode 100644 index 0000000..af173d8 --- /dev/null +++ b/src/act/report.ts @@ -0,0 +1,590 @@ +import { existsSync } from 'fs' +import type { DateRange, ProjectSummary, SessionSummary } from '../types.js' +import type { ActionBaseline, ActionKind, ActionRecord } from './types.js' +import type { FindingPlan } from './plans.js' +import { + AVG_TOKENS_PER_READ, + HEALTHY_READ_EDIT_RATIO, + TOKENS_PER_MCP_TOOL, + TOOLS_PER_MCP_SERVER, + TOKENS_PER_SKILL_DEF, + TOKENS_PER_AGENT_DEF, + TOKENS_PER_COMMAND_DEF, + READ_TOOL_NAMES, + EDIT_TOOL_NAMES, + aggregateMcpCoverage, + computeInputCostRate, + type McpServerCoverage, + type WasteFinding, +} from '../optimize.js' +import { parseAllSessions } from '../parser.js' +import { computeYield, type YieldSummary } from '../yield.js' +import { defaultActionsDir, readRecords } from './journal.js' +import { renderTable } from '../text-table.js' +import { formatTokens } from '../format.js' +import { formatCost } from '../currency.js' + +const DAY_MS = 24 * 60 * 60 * 1000 +const WINDOW_CAP_DAYS = 30 +const BASELINE_WINDOW_DAYS = 14 +const REPORT_MIN_AGE_DAYS = 3 +const MIN_POST_WINDOW_SESSIONS = 20 +const VOLUME_SHIFT_FACTOR = 2 + +// Encode the epic's honest-accounting rules where they are seen: estimates are +// window-scaled so both columns share a scale, each kind measures only its own +// metric, guard is correlation, and realized figures are rounded down. +const HONEST_FOOTER = + 'Estimates are scaled to the measured window for comparability; the at-apply estimate is kept in --json. ' + + 'MCP and archive realized figures are derived from per-session baselines times session counts, not independently measured. ' + + 'Each fix measures only its own metric; effects are never attributed across signals. ' + + 'Guard rows are correlation, not attribution. Realized numbers are rounded down.' + +const MCP_KINDS = new Set(['mcp-remove', 'mcp-project-scope']) +const ARCHIVE_DEF_TOKENS: Partial> = { + 'archive-skill': TOKENS_PER_SKILL_DEF, + 'archive-agent': TOKENS_PER_AGENT_DEF, + 'archive-command': TOKENS_PER_COMMAND_DEF, +} + +export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable' + +export type ActReportRow = { + id: string + appliedAt: string + date: string + kind: ActionKind + description: string + // The detector's estimate persisted at apply time, unmodified. + estimatedAtApply: number + // The estimate re-expressed over the same post-apply window as realized so + // the two table columns are comparable; falls back to estimatedAtApply for + // kinds with no window scaling. + estimatedForWindow: number + realizedTokens: number + status: RealizedStatus + confidence: 'low' | 'normal' + note: string + // guard-install only: yield split then vs now, labeled correlation. + correlation?: { + abandonedPctThen: number + abandonedPctNow: number + avgSessionCostThenUSD: number + avgSessionCostNowUSD: number + } +} + +export type ActReport = { + generatedAt: string + windowCapDays: number + costRate: number + rows: ActReportRow[] + totalRealizedTokens: number + totalRealizedCostUSD: number + measuredCount: number + activeCount: number + observedDays: number + // Journal lines that parsed as JSON but are not usable records (missing or + // unparseable `at`, missing status); skipped and surfaced, never a throw. + malformedRecords: number + // findingId -> earliest apply date of an active applied action; drives the + // optimize "(previously applied ..., re-flagged)" title suffix. + appliedByFinding: Record +} + +export type ActReportOptions = { + actionsDir?: string + now?: Date + cwd?: string + loadProjects?: (range: DateRange) => Promise + computeYield?: (range: DateRange) => Promise +} + +// --------------------------------------------------------------------------- +// Shared measurement helpers +// --------------------------------------------------------------------------- + +function ageDays(iso: string, now: Date): number { + return (now.getTime() - new Date(iso).getTime()) / DAY_MS +} + +function allSessions(projects: ProjectSummary[]): SessionSummary[] { + return projects.flatMap(p => p.sessions) +} + +function sessionsInWindow(projects: ProjectSummary[], start: Date, end: Date): SessionSummary[] { + const out: SessionSummary[] = [] + for (const p of projects) { + for (const s of p.sessions) { + if (!s.firstTimestamp) continue + const t = new Date(s.firstTimestamp).getTime() + if (t >= start.getTime() && t <= end.getTime()) out.push(s) + } + } + return out +} + +function countToolCalls(sessions: SessionSummary[], names: ReadonlySet): number { + let n = 0 + for (const s of sessions) { + for (const [tool, data] of Object.entries(s.toolBreakdown)) { + if (names.has(tool)) n += data.calls + } + } + return n +} + +function countBashCalls(sessions: SessionSummary[]): number { + let n = 0 + for (const s of sessions) { + for (const data of Object.values(s.bashBreakdown)) n += data.calls + } + return n +} + +function sessionLoadsAny(s: SessionSummary, servers: string[]): boolean { + for (const fqn of s.mcpInventory ?? []) { + const seg = fqn.split('__')[1] + if (seg && servers.includes(seg)) return true + } + for (const server of Object.keys(s.mcpBreakdown)) { + if (servers.includes(server)) return true + } + return false +} + +function countSessionsLoading(projects: ProjectSummary[], servers: string[]): number { + return allSessions(projects).filter(s => sessionLoadsAny(s, servers)).length +} + +// A kind whose realized effect is a token saving (everything except guard, +// which is a dollars/yield correlation, and out-of-scope kinds). +function isTokenKind(kind: ActionKind): boolean { + return kind !== 'guard-install' && kind !== 'guard-uninstall' && kind !== 'model-default' +} + +function confidenceFor(afterSessions: number, baseline: ActionBaseline, afterStart: Date, now: Date): 'low' | 'normal' { + if (afterSessions < MIN_POST_WINDOW_SESSIONS) return 'low' + if (baseline.sessions > 0 && baseline.windowDays > 0) { + const afterDays = Math.max((now.getTime() - afterStart.getTime()) / DAY_MS, 1) + const shift = (afterSessions / afterDays) / (baseline.sessions / baseline.windowDays) + if (shift > VOLUME_SHIFT_FACTOR || shift < 1 / VOLUME_SHIFT_FACTOR) return 'low' + } + return 'normal' +} + +// --------------------------------------------------------------------------- +// Per-kind realized deltas +// --------------------------------------------------------------------------- + +function mcpRow( + base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[], + baseline: ActionBaseline, afterStart: Date, now: Date, +): ActReportRow { + const servers = Object.keys(baseline.metrics) + const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0) + if (servers.length === 0 || perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' } + if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' } + // Window-scaled estimate: what the fix would save if every window session + // benefited. Realized differs from it only through still-loading sessions + // (and the revert check), so the pair is derived from session counts, not + // independently measured. + const estimatedForWindow = Math.floor(perSessionTokens * sessions.length) + const stillLoading = sessions.filter(s => sessionLoadsAny(s, servers)).length + const confidence = confidenceFor(sessions.length, baseline, afterStart, now) + if (rec.kind === 'mcp-remove' && stillLoading > 0) { + return { + ...base, + estimatedForWindow, + status: 'reverted', + confidence, + note: `reverted by user: ${servers.join(', ')} loaded again in ${stillLoading} post-apply session${stillLoading === 1 ? '' : 's'}`, + } + } + const savedSessions = Math.max(0, sessions.length - stillLoading) + return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * savedSessions), confidence } +} + +function archiveRow( + base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[], + baseline: ActionBaseline, afterStart: Date, now: Date, +): ActReportRow { + const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0) + if (perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' } + if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' } + const estimatedForWindow = Math.floor(perSessionTokens * sessions.length) + const confidence = confidenceFor(sessions.length, baseline, afterStart, now) + const restored = rec.changes.some(c => c.op === 'move' && existsSync(c.path)) + if (restored) { + return { ...base, estimatedForWindow, status: 'reverted', confidence, note: 'reverted by user: an archived item was moved back into place' } + } + // Estimate and realized are the same product by construction; the measured + // signal here is the session count and the revert check, not the multiply. + return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * sessions.length), confidence } +} + +function readEditRow( + base: ActReportRow, sessions: SessionSummary[], + baseline: ActionBaseline, afterStart: Date, now: Date, +): ActReportRow { + const editsNow = countToolCalls(sessions, EDIT_TOOL_NAMES) + const readsNow = countToolCalls(sessions, READ_TOOL_NAMES) + const editsThen = baseline.metrics['edits'] ?? 0 + const readsThen = baseline.metrics['reads'] ?? 0 + if (editsThen <= 0 || editsNow <= 0) return { ...base, note: 'not measurable: not enough edit activity to compare' } + const ratioThen = readsThen / editsThen + const ratioNow = readsNow / editsNow + // Detector estimate math: reads short of HEALTHY_READ_EDIT_RATIO per edit are + // the retry-prone deficit. Credit only the reduction in that deficit, scaled + // by current edits; a worsened ratio claims nothing. + const deficitThen = Math.max(HEALTHY_READ_EDIT_RATIO - ratioThen, 0) + const deficitNow = Math.max(HEALTHY_READ_EDIT_RATIO - ratioNow, 0) + const realized = Math.floor(Math.max(0, deficitThen - deficitNow) * editsNow * AVG_TOKENS_PER_READ) + // Same edits denominator as realized, so realized never exceeds it. + const estimatedForWindow = Math.floor(deficitThen * editsNow * AVG_TOKENS_PER_READ) + return { + ...base, + estimatedForWindow, + status: 'measured', + realizedTokens: realized, + confidence: confidenceFor(sessions.length, baseline, afterStart, now), + note: `read:edit ${ratioThen.toFixed(1)}:1 -> ${ratioNow.toFixed(1)}:1`, + } +} + +async function guardRow( + base: ActReportRow, afterStart: Date, now: Date, + baseline: ActionBaseline, opts: ActReportOptions, +): Promise { + const abandonedThen = baseline.metrics['abandonedPct'] + const avgThen = baseline.metrics['avgSessionCostUSD'] + if (abandonedThen === undefined || avgThen === undefined) { + return { ...base, note: 'not measurable: no yield baseline captured at install time' } + } + const yieldFn = opts.computeYield ?? ((range: DateRange) => computeYield(range, opts.cwd ?? process.cwd())) + let summary: YieldSummary + try { + summary = await yieldFn({ start: afterStart, end: now }) + } catch { + return { ...base, note: 'not measurable: yield could not be computed for the post-apply window' } + } + const abandonedNow = summary.total.cost > 0 ? Math.round((summary.abandoned.cost / summary.total.cost) * 100) : 0 + const avgNow = summary.total.sessions > 0 ? summary.total.cost / summary.total.sessions : 0 + return { + ...base, + status: 'measured', + confidence: summary.total.sessions < MIN_POST_WINDOW_SESSIONS ? 'low' : 'normal', + note: 'correlation, not attribution', + correlation: { + abandonedPctThen: abandonedThen, + abandonedPctNow: abandonedNow, + avgSessionCostThenUSD: avgThen, + avgSessionCostNowUSD: avgNow, + }, + } +} + +async function computeRow(rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date, opts: ActReportOptions): Promise { + const estimatedAtApply = rec.baseline?.estimatedTokens ?? 0 + const base: ActReportRow = { + id: rec.id, + appliedAt: rec.at, + date: rec.at.slice(0, 10), + kind: rec.kind, + description: rec.description, + estimatedAtApply, + estimatedForWindow: estimatedAtApply, + realizedTokens: 0, + status: 'not-measurable', + confidence: 'normal', + note: '', + } + const baseline = rec.baseline + if (!baseline) return { ...base, note: 'not measurable: no baseline captured at apply time' } + + if (MCP_KINDS.has(rec.kind)) return mcpRow(base, rec, sessions, baseline, afterStart, now) + if (rec.kind in ARCHIVE_DEF_TOKENS) return archiveRow(base, rec, sessions, baseline, afterStart, now) + if (rec.kind === 'claude-md-rule') return readEditRow(base, sessions, baseline, afterStart, now) + if (rec.kind === 'shell-config') return { ...base, note: 'not measurable: bash result token sizes are not retained in the summary' } + if (rec.kind === 'guard-install') return guardRow(base, afterStart, now, baseline, opts) + return { ...base, note: 'not measurable: kind is not tracked by act report' } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +// A journal line can be any JSON; only records with a parseable `at` date and +// a string status can be dated and filtered. Anything else is skipped and +// counted, so a corrupt journal can never crash `act report` or optimize. +function isSaneRecord(r: ActionRecord): boolean { + return typeof r.at === 'string' && typeof r.status === 'string' && !Number.isNaN(new Date(r.at).getTime()) +} + +export async function computeActReport(opts: ActReportOptions = {}): Promise { + const now = opts.now ?? new Date() + const rawRecords = await readRecords(opts.actionsDir ?? defaultActionsDir()) + const records = rawRecords.filter(isSaneRecord) + const malformedRecords = rawRecords.length - records.length + const active = records.filter(r => r.status === 'applied') + + const appliedByFinding: Record = {} + for (const r of active) { + if (!r.findingId) continue + const date = r.at.slice(0, 10) + const prev = appliedByFinding[r.findingId] + if (!prev || date < prev) appliedByFinding[r.findingId] = date + } + + const empty: ActReport = { + generatedAt: now.toISOString(), + windowCapDays: WINDOW_CAP_DAYS, + costRate: 0, + rows: [], + totalRealizedTokens: 0, + totalRealizedCostUSD: 0, + measuredCount: 0, + activeCount: active.length, + observedDays: 0, + malformedRecords, + appliedByFinding, + } + + const eligible = active.filter(r => ageDays(r.at, now) > REPORT_MIN_AGE_DAYS) + if (eligible.length === 0) return empty + + const windowStart = new Date(now.getTime() - WINDOW_CAP_DAYS * DAY_MS) + const loadProjects = opts.loadProjects ?? ((range: DateRange) => parseAllSessions(range, 'claude')) + const projects = await loadProjects({ start: windowStart, end: now }) + const costRate = computeInputCostRate(projects) + + const rows: ActReportRow[] = [] + for (const rec of eligible) { + const afterStart = new Date(Math.max(new Date(rec.at).getTime(), windowStart.getTime())) + rows.push(await computeRow(rec, sessionsInWindow(projects, afterStart, now), afterStart, now, opts)) + } + + const measuredRows = rows.filter(r => r.status === 'measured' && isTokenKind(r.kind)) + const totalRealizedTokens = measuredRows.reduce((s, r) => s + r.realizedTokens, 0) + const observedDays = Math.min( + WINDOW_CAP_DAYS, + measuredRows.reduce((mx, r) => Math.max(mx, Math.ceil(ageDays(r.appliedAt, now))), 0), + ) + + return { + generatedAt: now.toISOString(), + windowCapDays: WINDOW_CAP_DAYS, + costRate, + rows, + totalRealizedTokens, + totalRealizedCostUSD: totalRealizedTokens * costRate, + measuredCount: measuredRows.length, + activeCount: active.length, + observedDays, + malformedRecords, + appliedByFinding, + } +} + +export function buildOptimizeAppliedHeader(report: ActReport): string | null { + // Under-claim: only normal-confidence measured rows feed the optimize line. + // Low-confidence rows stay visible in `act report` but never in the header. + const confident = report.rows.filter(r => r.status === 'measured' && isTokenKind(r.kind) && r.confidence === 'normal') + if (confident.length === 0) return null + const tokens = confident.reduce((s, r) => s + r.realizedTokens, 0) + const generated = new Date(report.generatedAt) + const days = Math.min( + report.windowCapDays, + confident.reduce((mx, r) => Math.max(mx, Math.ceil(ageDays(r.appliedAt, generated))), 0), + ) + const cost = report.costRate > 0 ? ` (~${formatCost(tokens * report.costRate)})` : '' + return `Applied fixes: ${report.activeCount} active, realized ~${formatTokens(tokens)} tokens${cost} over ${days} day${days === 1 ? '' : 's'}. Details: codeburn act report` +} + +function realizedCell(r: ActReportRow): string { + if (r.status === 'reverted') return 'reverted' + if (r.status === 'not-measurable') return 'not measurable' + if (r.correlation) return `abandoned ${r.correlation.abandonedPctThen}% -> ${r.correlation.abandonedPctNow}% (corr.)` + return formatTokens(r.realizedTokens) +} + +function malformedNote(n: number): string { + return `${n} malformed record${n === 1 ? '' : 's'} skipped` +} + +export function renderActReport(report: ActReport): string { + if (report.rows.length === 0) { + const lines = ['', ' No applied actions to measure yet.'] + if (report.activeCount > 0) { + lines.push(` ${report.activeCount} action${report.activeCount === 1 ? '' : 's'} applied; measurement starts after ${REPORT_MIN_AGE_DAYS} days.`) + } else { + lines.push(' Apply fixes with codeburn optimize --apply, then check back after a few days.') + } + if (report.malformedRecords > 0) lines.push(` ${malformedNote(report.malformedRecords)}.`) + lines.push('') + return lines.join('\n') + } + + const rows = report.rows.map(r => [ + r.date, + r.description, + r.estimatedForWindow > 0 ? formatTokens(r.estimatedForWindow) : '-', + realizedCell(r), + r.status === 'measured' && isTokenKind(r.kind) ? r.confidence : '-', + ]) + const totalCost = report.costRate > 0 ? ` (~${formatCost(report.totalRealizedCostUSD)})` : '' + rows.push(['', 'Total realized', '', `${formatTokens(report.totalRealizedTokens)}${totalCost}`, '']) + + const table = renderTable( + [{ header: 'Applied' }, { header: 'Action' }, { header: 'Estimated', right: true }, { header: 'Realized', right: true }, { header: 'Confidence' }], + rows, + { boldRows: new Set([rows.length - 1]) }, + ) + + const details: string[] = [] + for (const r of report.rows) { + if (r.status === 'measured' && isTokenKind(r.kind)) continue + if (r.note) details.push(` ${r.date} ${r.kind}: ${r.note}`) + if (r.correlation) { + details.push(` avg session cost ${formatCost(r.correlation.avgSessionCostThenUSD)} -> ${formatCost(r.correlation.avgSessionCostNowUSD)}`) + } + } + if (report.malformedRecords > 0) details.push(` ${malformedNote(report.malformedRecords)}`) + + return ['', table, ...(details.length > 0 ? ['', ...details] : []), '', ' ' + HONEST_FOOTER, ''].join('\n') +} + +export function buildActReportJson(report: ActReport): unknown { + return { + generatedAt: report.generatedAt, + windowCapDays: report.windowCapDays, + malformedRecords: report.malformedRecords, + actions: report.rows.map(r => { + const tokenMeasured = r.status === 'measured' && isTokenKind(r.kind) + return { + id: r.id, + date: r.date, + kind: r.kind, + description: r.description, + estimatedAtApply: r.estimatedAtApply, + estimatedForWindow: r.estimatedForWindow, + realizedTokens: tokenMeasured ? r.realizedTokens : null, + status: r.status, + confidence: tokenMeasured ? r.confidence : null, + note: r.note, + ...(r.correlation ? { correlation: r.correlation } : {}), + } + }), + totals: { + realizedTokens: report.totalRealizedTokens, + realizedCostUSD: report.totalRealizedCostUSD, + measuredActions: report.measuredCount, + activeActions: report.activeCount, + observedDays: report.observedDays, + }, + footer: HONEST_FOOTER, + } +} + +// --------------------------------------------------------------------------- +// Baseline capture (apply time) +// --------------------------------------------------------------------------- + +type CaptureCtx = { + projects: ProjectSummary[] + coverage: McpServerCoverage[] + windowDays: number + now: Date +} + +function mcpServersFromApply(finding: WasteFinding): string[] { + if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers + if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server) + return [] +} + +function needsConfigBaseline(kind: ActionKind): boolean { + return MCP_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config' +} + +export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined { + const common = { + windowDays: ctx.windowDays, + capturedAt: ctx.now.toISOString(), + estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)), + } + + if (MCP_KINDS.has(kind)) { + const servers = mcpServersFromApply(finding) + if (servers.length === 0) return undefined + const covByServer = new Map(ctx.coverage.map(c => [c.server, c])) + const metrics: Record = {} + for (const server of servers) { + const cov = covByServer.get(server) + const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER + metrics[server] = tools * TOKENS_PER_MCP_TOOL + } + return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics } + } + + const defTokens = ARCHIVE_DEF_TOKENS[kind] + if (defTokens !== undefined) { + const names = finding.apply?.kind === 'archive' ? finding.apply.names : [] + if (names.length === 0) return undefined + const metrics: Record = {} + for (const name of names) metrics[name] = defTokens + return { ...common, sessions: allSessions(ctx.projects).length, metrics } + } + + const sessions = allSessions(ctx.projects) + if (kind === 'claude-md-rule') { + return { ...common, sessions: sessions.length, metrics: { reads: countToolCalls(sessions, READ_TOOL_NAMES), edits: countToolCalls(sessions, EDIT_TOOL_NAMES) } } + } + if (kind === 'shell-config') { + return { ...common, sessions: sessions.length, metrics: { calls: countBashCalls(sessions) } } + } + return undefined +} + +// Scan the trailing 14 days once and stamp a baseline onto every appliable +// plan that carries one, so runAction persists it for `act report` to diff. +export async function captureBaselinesForPlans( + plans: FindingPlan[], + opts: { now?: Date; loadProjects?: (range: DateRange) => Promise } = {}, +): Promise { + const applicable = plans.filter(fp => fp.plan && needsConfigBaseline(fp.plan.kind)) + if (applicable.length === 0) return + const now = opts.now ?? new Date() + const start = new Date(now.getTime() - BASELINE_WINDOW_DAYS * DAY_MS) + const loadProjects = opts.loadProjects ?? ((range: DateRange) => parseAllSessions(range, 'claude')) + const projects = await loadProjects({ start, end: now }) + const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now } + for (const fp of applicable) { + const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx) + if (baseline) fp.plan!.baseline = baseline + } +} + +export async function captureGuardBaseline( + opts: { now?: Date; cwd?: string; computeYield?: (range: DateRange) => Promise } = {}, +): Promise { + const now = opts.now ?? new Date() + const range = { start: new Date(now.getTime() - BASELINE_WINDOW_DAYS * DAY_MS), end: now } + const yieldFn = opts.computeYield ?? ((r: DateRange) => computeYield(r, opts.cwd ?? process.cwd())) + let summary: YieldSummary + try { + summary = await yieldFn(range) + } catch { + return undefined + } + return { + windowDays: BASELINE_WINDOW_DAYS, + capturedAt: now.toISOString(), + estimatedTokens: 0, + sessions: summary.total.sessions, + metrics: { + abandonedPct: summary.total.cost > 0 ? Math.round((summary.abandoned.cost / summary.total.cost) * 100) : 0, + avgSessionCostUSD: summary.total.sessions > 0 ? summary.total.cost / summary.total.sessions : 0, + }, + } +} diff --git a/src/act/types.ts b/src/act/types.ts new file mode 100644 index 0000000..1f0baf6 --- /dev/null +++ b/src/act/types.ts @@ -0,0 +1,66 @@ +export type ActionKind = + | 'mcp-remove' | 'mcp-project-scope' + | 'archive-skill' | 'archive-agent' | 'archive-command' + | 'claude-md-rule' | 'shell-config' + | 'guard-install' | 'guard-uninstall' + | 'model-default' + +export type FileChange = { + path: string // absolute path modified + backup: string | null // backups//.bak relative to the actions dir, null if the file did not exist before + op: 'edit' | 'create' | 'move' + movedTo?: string // for op: 'move' (archives) + destBackup?: string | null // move ops: snapshot of a file that already existed at movedTo + afterHash: string // sha256 of the post-apply bytes, checked for drift on undo +} + +// Before/after measurement captured when an action is applied, diffed against +// the post-apply window by `act report`. `metrics` holds the kind-specific +// numbers: +// mcp-remove / mcp-project-scope: server name -> schema tokens per session +// archive-skill|agent|command: item name -> definition tokens per session +// claude-md-rule (read/edit rule): { reads, edits } +// shell-config (bash cap): { calls } +// guard-install: { abandonedPct, avgSessionCostUSD } +// estimatedTokens is the finding's estimate at apply time (0 for guard, which +// is a correlation signal, not a token estimate). sessions is the affected-scope +// session count over the window, kept out of `metrics` so it can never collide +// with a server literally named "sessions"; it feeds only the volume-shift +// confidence check. +export type ActionBaseline = { + windowDays: number + capturedAt: string + estimatedTokens: number + sessions: number + metrics: Record +} + +export type ActionRecord = { + id: string // crypto.randomUUID() + at: string // ISO timestamp + kind: ActionKind + findingId: string | null + description: string // one human sentence, shown in `act list` + changes: FileChange[] + status: 'applied' | 'undone' + undoneAt?: string + baseline?: ActionBaseline +} + +// expectedHash: sha256 of the raw on-disk bytes the plan's content was +// computed from (null when the plan expects the file to be absent). runAction +// refuses to apply when the target no longer matches, so a file edited +// between preview and confirm is never silently clobbered with stale +// content. undefined skips the check. +export type PlannedChange = + | { op: 'edit'; path: string; content: string | Buffer; expectedHash?: string | null } + | { op: 'create'; path: string; content: string | Buffer; expectedHash?: string | null } + | { op: 'move'; path: string; movedTo: string } + +export type ActionPlan = { + kind: ActionKind + description: string + findingId?: string | null + changes: PlannedChange[] + baseline?: ActionBaseline +} diff --git a/src/act/undo.ts b/src/act/undo.ts new file mode 100644 index 0000000..570ca1e --- /dev/null +++ b/src/act/undo.ts @@ -0,0 +1,77 @@ +import type { ActionRecord, FileChange } from './types.js' +import { appendRecord, defaultActionsDir, readRecords, shortId, withLock } from './journal.js' +import { pathExists, revertChange, sha256File } from './backup.js' + +export class DriftError extends Error { + constructor(public record: ActionRecord, public drifted: string[]) { + super(`Refusing to undo ${shortId(record.id)}: ${drifted.length} file(s) changed since they were applied`) + this.name = 'DriftError' + } +} + +export function findRecord(records: ActionRecord[], idOrPrefix: string): ActionRecord | undefined { + const exact = records.find(r => r.id === idOrPrefix) + if (exact) return exact + const matches = records.filter(r => r.id.startsWith(idOrPrefix)) + if (matches.length > 1) { + throw new Error(`"${idOrPrefix}" matches ${matches.length} actions; use more characters.`) + } + return matches[0] +} + +// A move leaves the bytes at movedTo, so that is the path to hash for drift. +function currentPath(change: FileChange): string { + return change.op === 'move' ? change.movedTo! : change.path +} + +async function driftedFiles(record: ActionRecord): Promise { + const drifted: string[] = [] + for (const change of record.changes) { + // Undo of a move renames back onto the original path; refuse if something + // now occupies it rather than silently overwriting. + if (change.op === 'move' && await pathExists(change.path)) { + drifted.push(`${change.path} (occupied, undo would overwrite it)`) + } + if (change.afterHash === '') continue // no content hash (directories) + const p = currentPath(change) + try { + if ((await sha256File(p)) !== change.afterHash) drifted.push(p) + } catch (err) { + drifted.push(`${p} (unreadable: ${(err as NodeJS.ErrnoException).code ?? 'unknown'})`) + } + } + return drifted +} + +export type UndoSelector = { id: string } | { last: true } + +export async function undoAction( + selector: UndoSelector, + opts: { actionsDir?: string; force?: boolean } = {}, +): Promise { + const actionsDir = opts.actionsDir ?? defaultActionsDir() + return withLock(actionsDir, async () => { + const records = await readRecords(actionsDir) + let record: ActionRecord | undefined + if ('last' in selector) { + record = records.filter(r => r.status === 'applied').at(-1) + if (!record) throw new Error('Nothing to undo.') + } else { + record = findRecord(records, selector.id) + if (!record) throw new Error(`No action matches "${selector.id}".`) + } + if (record.status === 'undone') { + throw new Error(`Action ${shortId(record.id)} is already undone.`) + } + if (!opts.force) { + const drifted = await driftedFiles(record) + if (drifted.length > 0) throw new DriftError(record, drifted) + } + for (let i = record.changes.length - 1; i >= 0; i--) { + await revertChange(actionsDir, record.changes[i]!) + } + const undone: ActionRecord = { ...record, status: 'undone', undoneAt: new Date().toISOString() } + await appendRecord(actionsDir, undone) + return undone + }) +} diff --git a/src/audit-report.ts b/src/audit-report.ts new file mode 100644 index 0000000..a133dcf --- /dev/null +++ b/src/audit-report.ts @@ -0,0 +1,220 @@ +import { getModelCosts, type ModelCosts } from './models.js' +import { getProvider } from './providers/index.js' +import { formatCost, formatTokens } from './format.js' +import { renderTable, type TableColumn } from './text-table.js' +import type { ProjectSummary } from './types.js' + +// One (provider, model) bucket, exposing both the raw token fields as recorded +// by the provider/transcript and the normalized totals codeburn actually +// prices, so a mismatch between the two is visible in one place. +export type AuditRow = { + provider: string + providerDisplayName: string + model: string + modelDisplayName: string + calls: number + // Summed straight from each call's usage, untouched. + raw: { + inputTokens: number + outputTokens: number + reasoningTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number // Anthropic vocab + cachedInputTokens: number // OpenAI vocab + webSearchRequests: number + } + // What the reports display: reasoning folds into output, and the two + // cache-read vocabularies collapse to their max (providers fill one or both). + displayed: { + inputTokens: number + outputTokens: number + cacheWriteTokens: number + cacheReadTokens: number + } + // Per-token rates used for pricing; null when the model has no pricing entry. + rates: ModelCosts | null + // Cost split by component (displayed tokens x rate), plus the recomputed + // total. recomputedTotalUSD should track attributedCostUSD; a gap points at + // fast-mode multipliers or the 1-hour cache rate that calculateCost applies. + cost: { + input: number + output: number + cacheWrite: number + cacheRead: number + webSearch: number + recomputedTotalUSD: number + } + // The cost codeburn actually attributed to these calls (sum of call.costUSD). + attributedCostUSD: number +} + +export async function aggregateAudit(projects: ProjectSummary[]): Promise { + type Bucket = { + provider: string + model: string + calls: number + attributedCostUSD: number + cacheReadDisplayed: number + raw: AuditRow['raw'] + } + const buckets = new Map() + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + const provider = call.provider || 'unknown' + const model = call.model || 'unknown' + const key = `${provider} ${model}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { + provider, + model, + calls: 0, + attributedCostUSD: 0, + cacheReadDisplayed: 0, + raw: { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + webSearchRequests: 0, + }, + } + buckets.set(key, bucket) + } + const u = call.usage + bucket.raw.inputTokens += u.inputTokens + bucket.raw.outputTokens += u.outputTokens + bucket.raw.reasoningTokens += u.reasoningTokens + bucket.raw.cacheCreationInputTokens += u.cacheCreationInputTokens + bucket.raw.cacheReadInputTokens += u.cacheReadInputTokens + bucket.raw.cachedInputTokens += u.cachedInputTokens + bucket.raw.webSearchRequests += u.webSearchRequests + // Per-call max (then summed) mirrors how the reports collapse the two + // cache-read vocabularies, so the audit's displayed total matches. + bucket.cacheReadDisplayed += Math.max(u.cacheReadInputTokens, u.cachedInputTokens) + bucket.attributedCostUSD += call.costUSD + bucket.calls += 1 + } + } + } + } + + const providerCache = new Map string }>() + async function resolveProvider(name: string) { + const cached = providerCache.get(name) + if (cached) return cached + const p = await getProvider(name) + const entry = { + displayName: p?.displayName ?? name, + formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m, + } + providerCache.set(name, entry) + return entry + } + + const rows: AuditRow[] = [] + for (const bucket of buckets.values()) { + const meta = await resolveProvider(bucket.provider) + const displayed = { + inputTokens: bucket.raw.inputTokens, + outputTokens: bucket.raw.outputTokens + bucket.raw.reasoningTokens, + cacheWriteTokens: bucket.raw.cacheCreationInputTokens, + cacheReadTokens: bucket.cacheReadDisplayed, + } + const rates = getModelCosts(bucket.model) + const cost = { + input: rates ? displayed.inputTokens * rates.inputCostPerToken : 0, + output: rates ? displayed.outputTokens * rates.outputCostPerToken : 0, + cacheWrite: rates ? displayed.cacheWriteTokens * rates.cacheWriteCostPerToken : 0, + cacheRead: rates ? displayed.cacheReadTokens * rates.cacheReadCostPerToken : 0, + webSearch: rates ? bucket.raw.webSearchRequests * rates.webSearchCostPerRequest : 0, + recomputedTotalUSD: 0, + } + cost.recomputedTotalUSD = cost.input + cost.output + cost.cacheWrite + cost.cacheRead + cost.webSearch + rows.push({ + provider: bucket.provider, + providerDisplayName: meta.displayName, + model: bucket.model, + modelDisplayName: meta.formatModel(bucket.model), + calls: bucket.calls, + raw: bucket.raw, + displayed, + rates, + cost, + attributedCostUSD: bucket.attributedCostUSD, + }) + } + + rows.sort((a, b) => b.attributedCostUSD - a.attributedCostUSD) + return rows +} + +export function renderAuditTable(rows: AuditRow[]): string { + const columns: TableColumn[] = [ + { header: 'Provider' }, + { header: 'Model' }, + { header: 'Calls', right: true }, + { header: 'Input', right: true }, + { header: 'Output', right: true }, + { header: 'Reason', right: true }, + { header: 'Cache wr', right: true }, + { header: 'Cache rd', right: true }, + { header: 'Cost', right: true }, + ] + + const body = rows.map((r) => [ + r.providerDisplayName, + r.modelDisplayName, + r.calls.toLocaleString(), + formatTokens(r.raw.inputTokens), + formatTokens(r.raw.outputTokens), + formatTokens(r.raw.reasoningTokens), + formatTokens(r.raw.cacheCreationInputTokens), + formatTokens(r.displayed.cacheReadTokens), + formatCost(r.attributedCostUSD), + ]) + + const totals = rows.reduce( + (a, r) => ({ + calls: a.calls + r.calls, + input: a.input + r.raw.inputTokens, + output: a.output + r.raw.outputTokens, + reason: a.reason + r.raw.reasoningTokens, + cacheWrite: a.cacheWrite + r.raw.cacheCreationInputTokens, + cacheRead: a.cacheRead + r.displayed.cacheReadTokens, + cost: a.cost + r.attributedCostUSD, + }), + { calls: 0, input: 0, output: 0, reason: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }, + ) + body.push([ + 'Total', + '', + totals.calls.toLocaleString(), + formatTokens(totals.input), + formatTokens(totals.output), + formatTokens(totals.reason), + formatTokens(totals.cacheWrite), + formatTokens(totals.cacheRead), + formatCost(totals.cost), + ]) + + const table = renderTable(columns, body, { boldRows: new Set([body.length - 1]) }) + const legend = [ + '', + 'Columns are the raw token fields each provider records. codeburn then normalizes for pricing:', + ' - Reason folds into Output (priced output = output + reasoning)', + ' - Cache rd = max(Anthropic cacheReadInput, OpenAI cached), since providers fill one or both', + ' - Cache wr is priced at 1.25x the input rate, Cache rd at 0.1x, when a model omits explicit cache rates', + 'Use --format json for per-component cost, the rates applied, and both raw cache-read fields.', + ].join('\n') + return table + '\n' + legend +} + +export function renderAuditJson(rows: AuditRow[]): string { + return JSON.stringify(rows, null, 2) +} diff --git a/src/bash-utils.ts b/src/bash-utils.ts index 358a67b..2daad47 100644 --- a/src/bash-utils.ts +++ b/src/bash-utils.ts @@ -5,6 +5,17 @@ function stripQuotedStrings(command: string): string { return command.replaceAll(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length)) } +// Wrapper commands that delegate to a real program as their argument. When one +// leads a segment we skip it so the *wrapped* tool is attributed, not the +// wrapper (e.g. `sudo npm test` -> npm, `npx tsc` -> tsc). (upstream #658) +const COMMAND_PREFIXES = new Set([ + 'sudo', 'doas', + 'npx', 'bunx', + 'time', + 'nice', 'nohup', 'stdbuf', + 'rtk', +]) + export function extractBashCommands(rawCommand: string): string[] { if (!rawCommand?.trim()) return [] @@ -34,7 +45,17 @@ export function extractBashCommands(rawCommand: string): string[] { const tokens = segment.split(/\s+/) let i = 0 - while (i < tokens.length && /^\w+=/.test(tokens[i]!)) i++ + while (i < tokens.length) { + if (/^\w+=/.test(tokens[i]!)) { i++; continue } + const next = tokens[i + 1] + if ( + next !== undefined && + COMMAND_PREFIXES.has(basename(tokens[i]!)) && + !next.startsWith('-') && + !/["']/.test(next) + ) { i++; continue } + break + } const base = i < tokens.length ? basename(tokens[i]!) : '' if (base && base !== 'cd' && base !== 'true' && base !== 'false') { diff --git a/src/cli-date.ts b/src/cli-date.ts index 96cafc2..257df7e 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -15,9 +15,9 @@ const END_OF_DAY_MS = 999 // `--from` / `--to`. const ALL_TIME_MONTHS = 6 -export type Period = 'today' | 'week' | '30days' | 'month' | 'all' +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime' -export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] +export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime'] // Short labels suitable for the dashboard tab strip. Long-form labels for // header text come from `getDateRange().label`. @@ -27,9 +27,10 @@ export const PERIOD_LABELS: Record = { '30days': '30 Days', month: 'This Month', all: '6 Months', + lifetime: 'Lifetime', } -const VALID_PERIODS: ReadonlyArray = ['today', 'week', '30days', 'month', 'all'] +const VALID_PERIODS: ReadonlyArray = ['today', 'week', '30days', 'month', 'all', 'lifetime'] export function toPeriod(s: string): Period { if ((VALID_PERIODS as readonly string[]).includes(s)) return s as Period @@ -140,6 +141,15 @@ export function getDateRange(period: string): { range: DateRange; label: string const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1) return { range: { start, end }, label: 'Last 6 months' } } + case 'lifetime': { + // Unbounded all-time window. Unlike 'all' (deliberately capped at 6 months + // to keep the menubar's hot path fast), 'lifetime' is the web dashboard's + // explicit full-history view — a slower, user-initiated scan is fine there. + // Start far enough back to cover any real AI-coding history; the parser + // filters by actual session dates, so an early start costs nothing extra. + const start = new Date(2015, 0, 1) + return { range: { start, end }, label: 'Lifetime' } + } default: { process.stderr.write( `codeburn: unknown period "${period}". Valid values: today, week, 30days, month, all.\n` @@ -152,3 +162,49 @@ export function getDateRange(period: string): { range: DateRange; label: string export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string { return `${from ?? 'all'} to ${to ?? 'today'}` } + +/** + * A malformed usage query (bad period name or date). Thrown by + * periodInfoFromQuery so the sharing HTTPS server can answer 400 instead of + * crashing — a remote peer's query string is untrusted input. + */ +export class UsageQueryError extends Error { + constructor(message: string) { + super(message) + this.name = 'UsageQueryError' + } +} + +/** + * Resolve a `{ period?, from?, to? }` usage query (as received over the wire by + * the sharing server, or built locally for `status --scope`) into a concrete + * date range + label. + * + * Unlike getDateRange/toPeriod, this NEVER calls process.exit: the query comes + * from a remote peer, so an unknown period or malformed date must degrade to a + * 400 (UsageQueryError), never take down the host process. `from`/`to` take + * precedence over `period`, matching the CLI's --from/--to override. + */ +export function periodInfoFromQuery( + query: { period?: string; from?: string; to?: string }, + defaultPeriod: Period = 'month', +): { range: DateRange; label: string } { + const { period, from, to } = query + if (from !== undefined || to !== undefined) { + let range: DateRange | null + try { + range = parseDateRangeFlags(from, to) + } catch (err) { + throw new UsageQueryError(err instanceof Error ? err.message : String(err)) + } + if (!range) throw new UsageQueryError('empty date range') + return { range, label: formatDateRangeLabel(from, to) } + } + const resolved = period ?? defaultPeriod + // Validate against the known set BEFORE calling getDateRange, whose default + // branch would process.exit on an unknown period — unacceptable server-side. + if (!(PERIODS as readonly string[]).includes(resolved) && resolved !== 'yesterday') { + throw new UsageQueryError(`unknown period "${resolved}"`) + } + return getDateRange(resolved) +} diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 8dafd79..7c63649 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -6,7 +6,9 @@ import { homedir } from 'node:os' import type { ParsedProviderCall } from './providers/types.js' -const CODEX_CACHE_VERSION = 2 +// v3: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478). +// Recent Codex sessions cached under v2 dropped these, so force a re-parse. +const CODEX_CACHE_VERSION = 3 const CACHE_FILE = 'codex-results.json' const FINGERPRINT_BYTES = 256 diff --git a/src/codex-credits.ts b/src/codex-credits.ts new file mode 100644 index 0000000..10d5b1f --- /dev/null +++ b/src/codex-credits.ts @@ -0,0 +1,57 @@ +// Codex credit pricing. ChatGPT/Codex subscription users consume *credits*, a +// separate unit from API dollars: usage is billed as "credits per million +// tokens" at per-model rates that differ from the API USD pricing CodeBurn uses +// for cost. This module computes credit consumption from token counts so the +// app can show usage in credits (issues #408 and #495). +// +// Rates are credits per 1,000,000 tokens, from +// https://developers.openai.com/codex/pricing#credits-overview +// (cached input is the cheaper rate applied to cache-read tokens). + +export type CodexCreditRate = { + input: number + cachedInput: number + output: number +} + +const CREDITS_PER_MILLION: Record = { + 'gpt-5.5': { input: 125, cachedInput: 12.5, output: 750 }, + 'gpt-5.4': { input: 62.5, cachedInput: 6.25, output: 375 }, + 'gpt-5.4-mini': { input: 18.75, cachedInput: 1.875, output: 113 }, +} + +/// Resolve the credit rate for a Codex model name, tolerating suffix variants +/// (e.g. "gpt-5.5-codex"). Returns null when the model has no known credit rate. +export function codexCreditRate(model: string): CodexCreditRate | null { + const m = model.toLowerCase() + if (m.includes('5.4') && m.includes('mini')) return CREDITS_PER_MILLION['gpt-5.4-mini']! + if (m.includes('5.4')) return CREDITS_PER_MILLION['gpt-5.4']! + if (m.includes('5.5')) return CREDITS_PER_MILLION['gpt-5.5']! + return null +} + +export type CodexCreditTokens = { + /// Non-cached input tokens (CodeBurn normalizes Codex to Anthropic semantics, + /// so this excludes cache-read tokens). + inputTokens: number + /// Cache-read (cached input) tokens, billed at the cheaper cached rate. + cachedReadTokens: number + outputTokens: number + /// Reasoning tokens are billed as output, matching CodeBurn's cost model. + reasoningTokens?: number +} + +/// Credits consumed for one Codex usage record. Returns null when the model has +/// no known credit rate (caller decides how to surface "unknown"). +export function codexCredits(model: string, tokens: CodexCreditTokens): number | null { + const rate = codexCreditRate(model) + if (!rate) return null + const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0) + const PER_MILLION = 1_000_000 + const output = safe(tokens.outputTokens) + safe(tokens.reasoningTokens ?? 0) + return ( + (safe(tokens.inputTokens) / PER_MILLION) * rate.input + + (safe(tokens.cachedReadTokens) / PER_MILLION) * rate.cachedInput + + (output / PER_MILLION) * rate.output + ) +} diff --git a/src/config.ts b/src/config.ts index 2192a20..9b7cc38 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,8 @@ export type CodeburnConfig = { */ plans?: Record modelAliases?: Record + // Rates are stored as USD per 1,000,000 tokens; models.ts converts them to per-token ModelCosts. + priceOverrides?: Record network?: NetworkPolicy } diff --git a/src/context-tree-codex.ts b/src/context-tree-codex.ts new file mode 100644 index 0000000..2dedcfa --- /dev/null +++ b/src/context-tree-codex.ts @@ -0,0 +1,341 @@ +import { readdir, stat } from 'fs/promises' +import { existsSync } from 'fs' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionLines } from './fs-utils.js' +import { + add, + estimateTokens, + IMAGE_TOKEN_FALLBACK, + lineToText, + newAcc, + readChunk, + snapshot, + type Acc, + type ContextTreeResult, + type SessionRef, + type TitledSessionRef, +} from './context-tree.js' + +// Codex rollout counterpart of the Claude Code context tree. Rollouts carry +// full response items plus token_count events with exact totals: the last +// token_count gives the live context size and model_context_window, and the +// cumulative reasoning_output_tokens total prices reasoning exactly (reasoning +// item text is encrypted). `compacted` entries mark compactions and include +// the replacement_history the next window starts from. + +type CodexItem = { + type?: string + role?: string + content?: unknown + name?: string + arguments?: unknown + input?: unknown + action?: unknown + output?: unknown +} + +type CodexEntry = { + type?: string + payload?: { + type?: string + role?: string + model?: string + cwd?: string + id?: string + base_instructions?: { text?: unknown } | null + message?: unknown + replacement_history?: unknown + info?: { + total_token_usage?: { reasoning_output_tokens?: number } + last_token_usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number } + model_context_window?: number + } | null + } & CodexItem +} + +// Injected harness content: any tag-shaped block that isn't an image marker, +// plus the AGENTS.md / mentioned-files preambles Codex prepends to turns. +function isCodexMetaText(text: string): boolean { + const t = text.trimStart() + if (t.startsWith('<')) return !t.startsWith('" markers; the + // pixels never hit the file, so charge a flat estimate per marker. + if (b.text.trimStart().startsWith(' { + const full = newAcc() + let segment = newAcc() + let compactions = 0 + let model = 'unknown' + let systemTokens = 0 + let contextWindow: number | null = null + let lastTotalReasoning = 0 + let segmentStartReasoning = 0 + let lastUsage: { input_tokens?: number; output_tokens?: number; total_tokens?: number } | null = null + + for await (const line of readSessionLines(session.filePath)) { + const text = lineToText(line) + if (!text || text.charCodeAt(0) !== 123) continue + let entry: CodexEntry + try { + entry = JSON.parse(text) as CodexEntry + } catch { + continue + } + const payload = entry.payload + if (!payload) continue + + if (entry.type === 'session_meta') { + const instructions = payload.base_instructions?.text + if (typeof instructions === 'string') systemTokens = estimateTokens(instructions) + } else if (entry.type === 'turn_context') { + if (typeof payload.model === 'string' && payload.model) model = payload.model + } else if (entry.type === 'compacted') { + compactions += 1 + segment = newAcc() + segmentStartReasoning = lastTotalReasoning + if (typeof payload.message === 'string' && payload.message) { + add(segment.userCompactSummary, estimateTokens(payload.message)) + } + // The originals already landed in `full`, so the replacement history + // seeds only the new live window. + if (Array.isArray(payload.replacement_history)) { + for (const item of payload.replacement_history) { + if (item != null && typeof item === 'object') addCodexItem([segment], item as CodexItem) + } + } + } else if (entry.type === 'response_item') { + addCodexItem([full, segment], payload) + } else if (entry.type === 'event_msg' && payload.type === 'token_count') { + const info = payload.info + const totalReasoning = info?.total_token_usage?.reasoning_output_tokens + if (typeof totalReasoning === 'number') lastTotalReasoning = totalReasoning + if (info?.last_token_usage) lastUsage = info.last_token_usage + if (typeof info?.model_context_window === 'number' && info.model_context_window > 0) { + contextWindow = info.model_context_window + } + } + } + + full.assistantReasoning.tokens = lastTotalReasoning + segment.assistantReasoning.tokens = Math.max(0, lastTotalReasoning - segmentStartReasoning) + if (systemTokens > 0) { + add(full.system, systemTokens) + add(segment.system, systemTokens) + } + + let reported: ContextTreeResult['reported'] = null + if (lastUsage) { + const context = lastUsage.total_tokens ?? (lastUsage.input_tokens ?? 0) + (lastUsage.output_tokens ?? 0) + if (context > 0) { + // No guessing for OpenAI windows: without model_context_window the + // percentage is omitted rather than computed against a wrong constant. + reported = { context, window: contextWindow } + } + } + + return { + session, + model, + compactions, + reported, + effective: snapshot(segment), + full: snapshot(full), + } +} + +const ROLLOUT_RE = /^rollout-.{19}-(.+)\.jsonl$/ + +// Mirrors the CODEX_HOME handling of providers/codex.ts. +function codexSessionsRoot(): string { + return join(process.env['CODEX_HOME'] ?? join(homedir(), '.codex'), 'sessions') +} + +type RolloutFile = { filePath: string; sessionId: string } + +async function listRolloutFiles(): Promise { + const root = codexSessionsRoot() + if (!existsSync(root)) return [] + let files: string[] + try { + files = await readdir(root, { recursive: true }) + } catch { + return [] + } + const rollouts: RolloutFile[] = [] + for (const rel of files) { + const match = ROLLOUT_RE.exec(basename(rel)) + if (match) rollouts.push({ filePath: join(root, rel), sessionId: match[1] }) + } + return rollouts +} + +async function statRef(file: RolloutFile): Promise { + try { + const info = await stat(file.filePath) + if (!info.isFile() || info.size === 0) return null + return { ...file, project: '', mtimeMs: info.mtimeMs, sizeBytes: info.size } + } catch { + return null + } +} + +function newestFirst(refs: Array): SessionRef[] { + return refs.filter((r): r is SessionRef => r !== null).sort((a, b) => b.mtimeMs - a.mtimeMs) +} + +export async function listCodexSessionRefs(): Promise { + const files = await listRolloutFiles() + return newestFirst(await Promise.all(files.map(statRef))) +} + +// Id lookups match filenames directly so only the matching files get stated. +export async function findCodexSession(idPrefix: string): Promise { + const matches = (await listRolloutFiles()).filter((f) => f.sessionId.startsWith(idPrefix)) + return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null +} + +// Codex stores no session name; use the head chunk for the cwd (project) and +// the first real user message as a stand-in title. +async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; title: string }> { + let chunk: string + try { + chunk = await readChunk(ref.filePath, 0, 262_144) + } catch { + return { project: '', title: '' } + } + let project = '' + let title = '' + for (const line of chunk.split('\n')) { + if (project && title) break + let entry: CodexEntry + try { + entry = JSON.parse(line) as CodexEntry + } catch { + continue + } + const payload = entry.payload + if (!payload) continue + if (!project && entry.type === 'session_meta' && typeof payload.cwd === 'string' && payload.cwd) { + project = basename(payload.cwd) + } + if (!title && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) { + for (const block of payload.content) { + const b = block as { type?: string; text?: unknown } + if ((b.type === 'input_text' || b.type === 'text') && typeof b.text === 'string' && b.text.trim() && !isCodexMetaText(b.text)) { + title = b.text.replace(/\s+/g, ' ').trim().slice(0, 80) + break + } + } + } + } + return { project, title } +} + +export async function listRecentCodexSessions(limit = 15): Promise { + const refs = (await listCodexSessionRefs()).slice(0, limit) + return Promise.all( + refs.map(async (ref) => { + const info = await readCodexHeadInfo(ref) + return { ...ref, project: info.project, title: info.title } + }), + ) +} diff --git a/src/context-tree.ts b/src/context-tree.ts new file mode 100644 index 0000000..a8ad0e5 --- /dev/null +++ b/src/context-tree.ts @@ -0,0 +1,739 @@ +import { open, readdir, stat } from 'fs/promises' +import { existsSync } from 'fs' +import { delimiter, join } from 'path' +import { homedir } from 'os' +import chalk from 'chalk' + +import { readSessionLines } from './fs-utils.js' +import { formatTokens } from './format.js' + +// Block token counts are chars/4 estimates; the "context (exact)" line comes +// from the last assistant message's API usage. Transcripts store thinking +// blocks with their text stripped, so reasoning is derived per message as +// output_tokens minus the estimated visible output. + +const CHARS_PER_TOKEN = 4 +export const IMAGE_TOKEN_FALLBACK = 1600 + +export type BlockStat = { count: number; tokens: number } + +export type ContextSnapshot = { + messages: number + tokens: number + assistant: { + count: number + tokens: number + text: BlockStat + reasoning: BlockStat + toolCall: BlockStat + byTool: Array<{ tool: string; count: number; tokens: number }> + } + user: { + count: number + tokens: number + text: BlockStat + image: BlockStat + compactSummary: BlockStat + meta: BlockStat + } + toolResult: BlockStat + system: BlockStat +} + +export type SessionRef = { + filePath: string + sessionId: string + project: string + mtimeMs: number + sizeBytes: number +} + +export type ContextTreeResult = { + session: SessionRef + model: string + compactions: number + reported: { context: number; window: number | null } | null + effective: ContextSnapshot + full: ContextSnapshot +} + +// The fork's readSessionLines yields decoded UTF-8 strings one line at a time +// (bounded memory, U+FFFD on invalid bytes), so a raw session line here is a +// string. lineToText stays a thin passthrough so call sites read the same as +// upstream and stay ready if a Buffer variant is ever reintroduced. +export function lineToText(line: string): string | null { + return typeof line === 'string' ? line : null +} + +export type Acc = { + messages: number + assistantCount: number + assistantText: BlockStat + assistantReasoning: BlockStat + toolCall: BlockStat + byTool: Map + userCount: number + userText: BlockStat + userImage: BlockStat + userCompactSummary: BlockStat + userMeta: BlockStat + toolResult: BlockStat + system: BlockStat +} + +type RawUsage = { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +type RawEntry = { + type?: string + subtype?: string + uuid?: string + isSidechain?: boolean + isMeta?: boolean + isCompactSummary?: boolean + content?: unknown + attachment?: unknown + compactMetadata?: { preTokens?: number; preservedSegment?: { headUuid?: string } } + message?: { + id?: string + role?: string + model?: string + content?: unknown + usage?: RawUsage + } +} + +// Streamed assistant messages arrive as several transcript entries sharing one +// message id. Reasoning can only be settled once the whole message has been +// seen, so per-message state is buffered here and flushed at end of file. +type PendingAssistant = { + effective: boolean + visibleEstTokens: number + thinkingCount: number + outputTokens: number +} + +function newBlockStat(): BlockStat { + return { count: 0, tokens: 0 } +} + +export function newAcc(): Acc { + return { + messages: 0, + assistantCount: 0, + assistantText: newBlockStat(), + assistantReasoning: newBlockStat(), + toolCall: newBlockStat(), + byTool: new Map(), + userCount: 0, + userText: newBlockStat(), + userImage: newBlockStat(), + userCompactSummary: newBlockStat(), + userMeta: newBlockStat(), + toolResult: newBlockStat(), + system: newBlockStat(), + } +} + +export function estimateTokens(text: string): number { + return Math.ceil(text.length / CHARS_PER_TOKEN) +} + +export function add(stat: BlockStat, tokens: number): void { + stat.count += 1 + stat.tokens += tokens +} + +// Injected harness content (slash-command wrappers, system reminders, hook +// output) rather than something the user typed. +const META_TEXT_RE = /^\s*<(command-name|command-message|command-args|command-contents|local-command-stdout|local-command-stderr|system-reminder|task-notification)/ + +function pngDims(buf: Buffer): [number, number] | null { + if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) return null + return [buf.readUInt32BE(16), buf.readUInt32BE(20)] +} + +function jpegDims(buf: Buffer): [number, number] | null { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null + let i = 2 + while (i + 9 < buf.length) { + if (buf[i] !== 0xff) { + i++ + continue + } + const marker = buf[i + 1] + const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc + if (isSof) return [buf.readUInt16BE(i + 7), buf.readUInt16BE(i + 5)] + const len = buf.readUInt16BE(i + 2) + if (len < 2) return null + i += 2 + len + } + return null +} + +// Anthropic vision pricing: ~(w*h)/750 tokens after the API downscales to fit +// 1568px on the long edge / ~1.15MP total. +function imageTokens(source: unknown): number { + const data = (source as { data?: unknown } | undefined)?.data + if (typeof data !== 'string' || data.length === 0) return IMAGE_TOKEN_FALLBACK + let buf: Buffer + try { + buf = Buffer.from(data.slice(0, 262144), 'base64') + } catch { + return IMAGE_TOKEN_FALLBACK + } + const dims = pngDims(buf) ?? jpegDims(buf) + if (!dims) return IMAGE_TOKEN_FALLBACK + const [w, h] = dims + if (!(w > 0) || !(h > 0)) return IMAGE_TOKEN_FALLBACK + const scale = Math.min(1, 1568 / Math.max(w, h), Math.sqrt(1_150_000 / (w * h))) + return Math.max(1, Math.min(IMAGE_TOKEN_FALLBACK, Math.round((w * scale * h * scale) / 750))) +} + +function toolResultTokens(content: unknown): number { + if (typeof content === 'string') return estimateTokens(content) + if (!Array.isArray(content)) return 0 + let tokens = 0 + for (const block of content) { + if (block == null || typeof block !== 'object') continue + const b = block as { type?: string; text?: unknown; source?: unknown } + if (b.type === 'text' && typeof b.text === 'string') tokens += estimateTokens(b.text) + else if (b.type === 'image') tokens += imageTokens(b.source) + } + return tokens +} + +class TreeBuilder { + full = newAcc() + effective = newAcc() + pending = new Map() + model = 'unknown' + lastUsage: RawUsage | null = null + maxSeenTokens = 0 + + private accs(effective: boolean): Acc[] { + return effective ? [this.full, this.effective] : [this.full] + } + + addEntry(entry: RawEntry, effective: boolean): void { + const role = entry.message?.role + if (entry.type === 'assistant' && role === 'assistant') { + this.addAssistant(entry, effective) + } else if (entry.type === 'user' && role === 'user') { + this.addUser(entry, effective) + } else if (entry.type === 'system') { + const tokens = typeof entry.content === 'string' ? estimateTokens(entry.content) : 0 + for (const acc of this.accs(effective)) add(acc.system, tokens) + } else if (entry.type === 'attachment') { + let tokens = 0 + try { + tokens = entry.attachment == null ? 0 : estimateTokens(JSON.stringify(entry.attachment)) + } catch { + tokens = 0 + } + for (const acc of this.accs(effective)) add(acc.userMeta, tokens) + } + } + + private addAssistant(entry: RawEntry, effective: boolean): void { + const msg = entry.message + if (!msg) return + if (typeof msg.model === 'string' && msg.model && msg.model !== '') this.model = msg.model + const usage = msg.usage + if (usage && ((usage.input_tokens ?? 0) > 0 || (usage.cache_read_input_tokens ?? 0) > 0)) { + this.lastUsage = usage + } + + const id = msg.id ?? entry.uuid ?? '' + let pending = this.pending.get(id) + if (!pending) { + pending = { effective, visibleEstTokens: 0, thinkingCount: 0, outputTokens: 0 } + this.pending.set(id, pending) + for (const acc of this.accs(effective)) { + acc.assistantCount += 1 + acc.messages += 1 + } + } + if (usage?.output_tokens !== undefined) pending.outputTokens = usage.output_tokens + + const content = msg.content + if (!Array.isArray(content)) return + for (const block of content) { + if (block == null || typeof block !== 'object') continue + const b = block as { type?: string; text?: unknown; name?: unknown; input?: unknown; content?: unknown } + if (b.type === 'text' && typeof b.text === 'string') { + const tokens = estimateTokens(b.text) + pending.visibleEstTokens += tokens + for (const acc of this.accs(pending.effective)) add(acc.assistantText, tokens) + } else if (b.type === 'thinking' || b.type === 'redacted_thinking') { + pending.thinkingCount += 1 + } else if (b.type === 'tool_use' || b.type === 'server_tool_use') { + let tokens = 0 + try { + tokens = estimateTokens(JSON.stringify(b.input ?? {})) + } catch { + tokens = 0 + } + pending.visibleEstTokens += tokens + const tool = typeof b.name === 'string' && b.name ? b.name : 'unknown' + for (const acc of this.accs(pending.effective)) { + add(acc.toolCall, tokens) + const stat = acc.byTool.get(tool) ?? newBlockStat() + add(stat, tokens) + acc.byTool.set(tool, stat) + } + } else if (b.type === 'web_search_tool_result' || b.type === 'web_fetch_tool_result') { + for (const acc of this.accs(pending.effective)) add(acc.toolResult, toolResultTokens(b.content)) + } + } + } + + private addUser(entry: RawEntry, effective: boolean): void { + for (const acc of this.accs(effective)) { + acc.userCount += 1 + acc.messages += 1 + } + const content = entry.message?.content + const bucketFor = (acc: Acc, text: string): BlockStat => { + if (entry.isCompactSummary) return acc.userCompactSummary + if (entry.isMeta || META_TEXT_RE.test(text)) return acc.userMeta + return acc.userText + } + if (typeof content === 'string') { + for (const acc of this.accs(effective)) add(bucketFor(acc, content), estimateTokens(content)) + return + } + if (!Array.isArray(content)) return + for (const block of content) { + if (block == null || typeof block !== 'object') continue + const b = block as { type?: string; text?: unknown; source?: unknown; content?: unknown } + if (b.type === 'text' && typeof b.text === 'string') { + for (const acc of this.accs(effective)) add(bucketFor(acc, b.text), estimateTokens(b.text)) + } else if (b.type === 'image') { + const tokens = imageTokens(b.source) + for (const acc of this.accs(effective)) add(acc.userImage, tokens) + } else if (b.type === 'tool_result') { + const tokens = toolResultTokens(b.content) + for (const acc of this.accs(effective)) add(acc.toolResult, tokens) + } + } + } + + // Transcripts strip thinking text, so estimate reasoning as the message's + // output_tokens minus its estimated visible output. Only messages that + // actually contained thinking blocks get a reasoning row; the remainder for + // other messages is chars/4 drift, not reasoning. + flushReasoning(): void { + for (const pending of this.pending.values()) { + if (pending.thinkingCount === 0) continue + const tokens = Math.max(0, pending.outputTokens - pending.visibleEstTokens) + for (const acc of this.accs(pending.effective)) { + acc.assistantReasoning.count += pending.thinkingCount + acc.assistantReasoning.tokens += tokens + } + } + } +} + +export function snapshot(acc: Acc): ContextSnapshot { + const assistantTokens = acc.assistantText.tokens + acc.assistantReasoning.tokens + acc.toolCall.tokens + const userTokens = acc.userText.tokens + acc.userImage.tokens + acc.userCompactSummary.tokens + acc.userMeta.tokens + const byTool = [...acc.byTool.entries()] + .map(([tool, stat]) => ({ tool, count: stat.count, tokens: stat.tokens })) + .sort((a, b) => b.tokens - a.tokens) + return { + messages: acc.messages, + tokens: assistantTokens + userTokens + acc.toolResult.tokens + acc.system.tokens, + assistant: { + count: acc.assistantCount, + tokens: assistantTokens, + text: acc.assistantText, + reasoning: acc.assistantReasoning, + toolCall: acc.toolCall, + byTool, + }, + user: { + count: acc.userCount, + tokens: userTokens, + text: acc.userText, + image: acc.userImage, + compactSummary: acc.userCompactSummary, + meta: acc.userMeta, + }, + toolResult: acc.toolResult, + system: acc.system, + } +} + +const isFileSnapshotLine = (line: string): boolean => line.includes('"type":"file-history-snapshot"') + +// Pass 1: locate the last compaction. The live window starts at the preserved +// segment's head (messages Claude Code carried across the compaction), not at +// the boundary itself. +async function findLastBoundary(filePath: string): Promise<{ + headUuid: string | null + compactions: number + maxPreTokens: number +}> { + let headUuid: string | null = null + let compactions = 0 + let maxPreTokens = 0 + for await (const line of readSessionLines(filePath)) { + if (isFileSnapshotLine(line)) continue + if (!line.includes('"subtype":"compact_boundary"')) continue + let entry: RawEntry + try { + entry = JSON.parse(line) as RawEntry + } catch { + continue + } + if (entry.type !== 'system' || entry.subtype !== 'compact_boundary') continue + compactions += 1 + headUuid = entry.compactMetadata?.preservedSegment?.headUuid ?? null + maxPreTokens = Math.max(maxPreTokens, entry.compactMetadata?.preTokens ?? 0) + } + return { headUuid, compactions, maxPreTokens } +} + +// Claude models with a 1M window: opus-4-8 (auto-compactions on disk show +// ~1.0M preTokens) and the "[1m]" long-context variants. Others default to +// 200K unless the session itself proves bigger. +const MILLION_WINDOW_RE = /opus-4-8|\[1m\]/ + +export async function buildContextTree(session: SessionRef): Promise { + const boundary = await findLastBoundary(session.filePath) + const builder = new TreeBuilder() + builder.maxSeenTokens = boundary.maxPreTokens + + let boundariesSeen = 0 + let inPreservedSegment = false + for await (const line of readSessionLines(session.filePath)) { + if (isFileSnapshotLine(line)) continue + const text = lineToText(line) + if (!text || text.charCodeAt(0) !== 123) continue + let entry: RawEntry + try { + entry = JSON.parse(text) as RawEntry + } catch { + continue + } + if (entry.isSidechain === true) continue + if (entry.type === 'system' && entry.subtype === 'compact_boundary') { + boundariesSeen += 1 + continue + } + if (boundary.headUuid && entry.uuid === boundary.headUuid) inPreservedSegment = true + const effective = boundariesSeen >= boundary.compactions || inPreservedSegment + builder.addEntry(entry, effective) + } + builder.flushReasoning() + + let reported: ContextTreeResult['reported'] = null + if (builder.lastUsage) { + const context = + (builder.lastUsage.input_tokens ?? 0) + + (builder.lastUsage.cache_read_input_tokens ?? 0) + + (builder.lastUsage.cache_creation_input_tokens ?? 0) + + (builder.lastUsage.output_tokens ?? 0) + builder.maxSeenTokens = Math.max(builder.maxSeenTokens, context) + const million = MILLION_WINDOW_RE.test(builder.model) || builder.maxSeenTokens > 220_000 + reported = { context, window: million ? 1_000_000 : 200_000 } + } + + return { + session, + model: builder.model, + compactions: boundary.compactions, + reported, + effective: snapshot(builder.effective), + full: snapshot(builder.full), + } +} + +// Mirrors the env handling of providers/claude.ts so the context views cover +// the same session roots as usage tracking. +function claudeProjectRoots(): string[] { + const dirsEnv = process.env['CLAUDE_CONFIG_DIRS'] + const dirs = dirsEnv ? dirsEnv.split(delimiter).filter(Boolean) : [process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude')] + return dirs.map((d) => join(d, 'projects')) +} + +type SessionFile = { filePath: string; sessionId: string; project: string } + +async function listSessionFiles(): Promise { + const files: SessionFile[] = [] + for (const root of claudeProjectRoots()) { + if (!existsSync(root)) continue + let projectDirs: string[] + try { + projectDirs = await readdir(root) + } catch { + continue + } + for (const dir of projectDirs) { + let names: string[] + try { + names = await readdir(join(root, dir)) + } catch { + continue + } + for (const name of names) { + if (!name.endsWith('.jsonl')) continue + files.push({ + filePath: join(root, dir, name), + sessionId: name.slice(0, -'.jsonl'.length), + project: dir.split('-').filter(Boolean).pop() ?? dir, + }) + } + } + } + return files +} + +async function statRef(file: SessionFile): Promise { + try { + const info = await stat(file.filePath) + if (!info.isFile() || info.size === 0) return null + return { ...file, mtimeMs: info.mtimeMs, sizeBytes: info.size } + } catch { + return null + } +} + +function newestFirst(refs: Array): SessionRef[] { + return refs.filter((r): r is SessionRef => r !== null).sort((a, b) => b.mtimeMs - a.mtimeMs) +} + +export async function listRecentSessions(limit = 15): Promise { + const files = await listSessionFiles() + return newestFirst(await Promise.all(files.map(statRef))).slice(0, limit) +} + +// Id lookups match filenames directly so only the matching files get stated. +export async function findClaudeSession(idPrefix: string): Promise { + const matches = (await listSessionFiles()).filter((f) => f.sessionId.startsWith(idPrefix)) + return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null +} + +// Claude Code stores an AI-generated session name as "ai-title" entries (the +// last one is current; sessions get re-titled) and, in older sessions, as +// "summary" entries near the top. Scanning one tail and one head chunk finds +// it without reading a potentially 100MB transcript. +const TITLE_CHUNK_BYTES = 262_144 + +function titleFromChunk(chunk: string): string { + let title = '' + let summary = '' + for (const line of chunk.split('\n')) { + if (line.includes('"type":"ai-title"')) { + try { + const t = (JSON.parse(line) as { aiTitle?: unknown }).aiTitle + if (typeof t === 'string' && t) title = t + } catch { + continue + } + } else if (!summary && line.includes('"type":"summary"')) { + try { + const t = (JSON.parse(line) as { summary?: unknown }).summary + if (typeof t === 'string' && t) summary = t + } catch { + continue + } + } + } + return title || summary +} + +export async function readChunk(filePath: string, start: number, length: number): Promise { + const fd = await open(filePath, 'r') + try { + const buf = Buffer.alloc(length) + const { bytesRead } = await fd.read(buf, 0, length, start) + return buf.subarray(0, bytesRead).toString('utf-8') + } finally { + await fd.close() + } +} + +export async function readSessionTitle(ref: SessionRef): Promise { + try { + const tailStart = Math.max(0, ref.sizeBytes - TITLE_CHUNK_BYTES) + let title = titleFromChunk(await readChunk(ref.filePath, tailStart, TITLE_CHUNK_BYTES)) + if (!title && tailStart > 0) title = titleFromChunk(await readChunk(ref.filePath, 0, TITLE_CHUNK_BYTES)) + return title.replace(/\s+/g, ' ').trim() + } catch { + return '' + } +} + +async function resolveSession(arg: string | undefined, provider: 'claude' | 'codex'): Promise { + if (arg && (arg.endsWith('.jsonl') || arg.includes('/'))) { + if (!existsSync(arg)) return null + const info = await stat(arg) + const base = arg.split('/').pop() ?? arg + return { + filePath: arg, + sessionId: base.replace(/\.jsonl$/, ''), + project: '', + mtimeMs: info.mtimeMs, + sizeBytes: info.size, + } + } + if (provider === 'codex') { + const codex = await import('./context-tree-codex.js') + if (!arg) return (await codex.listRecentCodexSessions(1))[0] ?? null + return codex.findCodexSession(arg) + } + if (!arg) return (await listRecentSessions(1))[0] ?? null + return findClaudeSession(arg) +} + +function num(n: number): string { + return n.toLocaleString('en-US') +} + +export function relativeAge(mtimeMs: number): string { + const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000)) + if (mins < 60) return `${mins}m ago` + if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago` + return `${Math.round(mins / (60 * 24))}d ago` +} + +export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean } + +export function snapshotRows(view: ContextSnapshot): ContextRow[] { + const rows: ContextRow[] = [] + rows.push({ depth: 0, label: 'assistant', count: view.assistant.count, tokens: view.assistant.tokens, bold: true }) + rows.push({ depth: 1, label: 'text', count: view.assistant.text.count, tokens: view.assistant.text.tokens }) + if (view.assistant.reasoning.count > 0) rows.push({ depth: 1, label: 'reasoning', count: view.assistant.reasoning.count, tokens: view.assistant.reasoning.tokens }) + rows.push({ depth: 1, label: 'tool-call', count: view.assistant.toolCall.count, tokens: view.assistant.toolCall.tokens }) + for (const t of view.assistant.byTool) rows.push({ depth: 2, label: t.tool, count: t.count, tokens: t.tokens }) + rows.push({ depth: 0, label: 'user', count: view.user.count, tokens: view.user.tokens, bold: true }) + rows.push({ depth: 1, label: 'text', count: view.user.text.count, tokens: view.user.text.tokens }) + if (view.user.image.count > 0) rows.push({ depth: 1, label: 'image', count: view.user.image.count, tokens: view.user.image.tokens }) + if (view.user.compactSummary.count > 0) rows.push({ depth: 1, label: 'compact-summary', count: view.user.compactSummary.count, tokens: view.user.compactSummary.tokens }) + if (view.user.meta.count > 0) rows.push({ depth: 1, label: 'meta', count: view.user.meta.count, tokens: view.user.meta.tokens }) + rows.push({ depth: 0, label: 'tool', count: view.toolResult.count, tokens: view.toolResult.tokens, bold: true }) + rows.push({ depth: 1, label: 'tool-result', count: view.toolResult.count, tokens: view.toolResult.tokens }) + if (view.system.count > 0) rows.push({ depth: 0, label: 'system', count: view.system.count, tokens: view.system.tokens, bold: true }) + return rows +} + +function renderRows(rows: ContextRow[]): string[] { + const leftLen = (r: ContextRow): number => r.depth * 2 + (r.depth > 0 ? 2 : 0) + r.label.length + const labelWidth = Math.max(...rows.map(leftLen)) + 2 + const countWidth = Math.max(...rows.map((r) => num(r.count).length)) + 1 + const tokenWidth = Math.max(...rows.map((r) => num(r.tokens).length)) + return rows.map((r) => { + const indent = ' '.repeat(r.depth) + const bullet = r.depth > 0 ? chalk.dim('◦ ') : '' + const label = r.depth === 0 ? chalk.bold(r.label) : r.label + const pad = ' '.repeat(labelWidth - leftLen(r)) + const count = chalk.dim(`${num(r.count)}x`.padStart(countWidth + 1)) + const tokens = (r.bold ? chalk.cyan.bold : chalk.cyan)(num(r.tokens).padStart(tokenWidth + 2)) + return ` ${indent}${bullet}${label}${pad}${count}${tokens} ${chalk.dim('tokens')}` + }) +} + +export function renderContextTree(result: ContextTreeResult, opts: { full?: boolean } = {}): string { + const view = opts.full ? result.full : result.effective + const lines: string[] = [] + const scopeLabel = opts.full ? 'full session' : 'effective' + + lines.push('') + lines.push(` ${chalk.bold('Context Token Usage')} ${chalk.dim(`(${scopeLabel})`)}`) + const sizeMb = (result.session.sizeBytes / 1024 / 1024).toFixed(1) + const project = result.session.project ? `${result.session.project} · ` : '' + lines.push(chalk.dim(` session ${result.session.sessionId.slice(0, 8)} · ${project}${result.model} · ${relativeAge(result.session.mtimeMs)} · ${sizeMb}MB on disk`)) + lines.push('') + + const masked = Math.max(0, result.full.tokens - result.effective.tokens) + lines.push(` messages: ${chalk.bold(num(view.messages))}`) + lines.push(` tokens: ${chalk.bold(formatTokens(result.full.tokens))} ${chalk.dim('estimated across the session')}`) + if (result.compactions > 0) { + const pct = result.full.tokens > 0 ? Math.round((result.effective.tokens / result.full.tokens) * 100) : 0 + lines.push(` ${chalk.dim('◦')} ${formatTokens(masked)} ${chalk.dim(`compacted away (${num(result.compactions)} compaction${result.compactions === 1 ? '' : 's'})`)}`) + lines.push(` ${chalk.dim('◦')} ${formatTokens(result.effective.tokens)} ${chalk.dim(`effective (${pct}%)`)}`) + } + if (result.reported) { + const { context, window } = result.reported + const windowPart = window ? ` ${chalk.dim(`of ${formatTokens(window)} window (${Math.round((context / window) * 100)}%)`)}` : '' + lines.push(` context (exact, last turn): ${chalk.bold(formatTokens(context))}${windowPart}`) + const overhead = result.reported.context - result.effective.tokens + if (overhead >= 0) { + lines.push(` ${chalk.dim('◦')} ${formatTokens(overhead)} ${chalk.dim('system prompt, tools & memory (derived)')}`) + } + } + lines.push('') + + lines.push(...renderRows(snapshotRows(view))) + + lines.push('') + lines.push(chalk.dim(' block tokens are estimated (chars/4, images by pixel count, reasoning from per-message usage);')) + lines.push(chalk.dim(' "context (exact)" comes from API usage.')) + if (!opts.full && result.compactions > 0) lines.push(chalk.dim(' showing the live window since the last compaction; use --full for the whole session.')) + lines.push('') + return lines.join('\n') +} + +export type TitledSessionRef = SessionRef & { title: string } + +function renderSessionList(refs: TitledSessionRef[], provider: 'claude' | 'codex'): string { + const heading = provider === 'codex' ? 'Recent Codex sessions' : 'Recent Claude Code sessions' + const hint = provider === 'codex' ? 'codeburn context --provider codex to inspect one' : 'codeburn context to inspect one' + const lines = ['', ` ${chalk.bold(heading)}`, ''] + const projectWidth = Math.max(...refs.map((r) => r.project.length)) + for (const ref of refs) { + const sizeMb = (ref.sizeBytes / 1024 / 1024).toFixed(1).padStart(6) + const shortTitle = ref.title.length > 48 ? `${ref.title.slice(0, 47)}…` : ref.title + lines.push(` ${chalk.cyan(ref.sessionId.slice(0, 8))} ${chalk.dim(`${sizeMb}MB`)} ${relativeAge(ref.mtimeMs).padStart(7)} ${chalk.dim(ref.project.padEnd(projectWidth))} ${shortTitle}`) + } + lines.push('') + lines.push(chalk.dim(` ${hint}`)) + lines.push('') + return lines.join('\n') +} + +export async function listRecentTitledSessions(limit = 15): Promise { + const refs = await listRecentSessions(limit) + const titles = await Promise.all(refs.map(readSessionTitle)) + return refs.map((r, i) => ({ ...r, title: titles[i] ?? '' })) +} + +export async function runContextCommand( + sessionArg: string | undefined, + opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string }, +): Promise { + const provider: 'claude' | 'codex' = opts.provider === 'codex' ? 'codex' : 'claude' + if (opts.list) { + const refs = + provider === 'codex' ? await (await import('./context-tree-codex.js')).listRecentCodexSessions(15) : await listRecentTitledSessions(15) + if (refs.length === 0) { + console.log(provider === 'codex' ? 'No Codex sessions found.' : 'No Claude Code sessions found.') + return + } + if (opts.json) { + console.log(JSON.stringify({ sessions: refs }, null, 2)) + return + } + console.log(renderSessionList(refs, provider)) + return + } + const session = await resolveSession(sessionArg, provider) + if (!session) { + console.error(sessionArg ? `No ${provider} session matching "${sessionArg}".` : `No ${provider} sessions found.`) + process.exitCode = 1 + return + } + const result = + provider === 'codex' ? await (await import('./context-tree-codex.js')).buildCodexContextTree(session) : await buildContextTree(session) + if (opts.json) { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(renderContextTree(result, { full: opts.full })) +} diff --git a/src/context-tui.tsx b/src/context-tui.tsx new file mode 100644 index 0000000..4291af2 --- /dev/null +++ b/src/context-tui.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useState } from 'react' +import { render, Box, Text, useApp, useInput } from 'ink' + +import { formatTokens } from './format.js' +import { patchStdoutForWindows } from './ink-win.js' +import { + buildContextTree, + listRecentTitledSessions, + relativeAge, + snapshotRows, + type ContextTreeResult, + type TitledSessionRef, +} from './context-tree.js' +import { buildCodexContextTree, listRecentCodexSessions } from './context-tree-codex.js' + +type Provider = 'claude' | 'codex' +type Scope = 'effective' | 'full' + +const ORANGE = '#FF8C42' +const DIM = '#555555' +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const PROVIDERS: Array<{ key: Provider; label: string }> = [ + { key: 'claude', label: 'Claude Code' }, + { key: 'codex', label: 'Codex' }, +] + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text +} + +async function loadSessions(provider: Provider): Promise { + return provider === 'codex' ? listRecentCodexSessions(15) : listRecentTitledSessions(15) +} + +function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: Scope }) { + const view = scope === 'full' ? tree.full : tree.effective + const rows = snapshotRows(view) + const labelWidth = Math.max(...rows.map((r) => r.depth * 2 + r.label.length)) + 2 + const countWidth = Math.max(...rows.map((r) => `${r.count}x`.length)) + 1 + const tokenWidth = Math.max(...rows.map((r) => formatTokens(r.tokens).length)) + 2 + + const headline: string[] = [`model ${tree.model}`, `messages ${view.messages.toLocaleString('en-US')}`, `est ${formatTokens(view.tokens)}`] + if (tree.reported) { + const { context, window } = tree.reported + headline.push(window ? `context ${formatTokens(context)} / ${formatTokens(window)} (${Math.round((context / window) * 100)}%)` : `context ${formatTokens(context)} (exact)`) + } + if (tree.compactions > 0) headline.push(`${tree.compactions} compaction${tree.compactions === 1 ? '' : 's'}`) + + return ( + + + {headline.join(' · ')} + + + showing {scope === 'effective' ? 'live window' : 'full history'} · press f to switch + + + {rows.map((r, i) => ( + + {' '.repeat(r.depth * 2)} + + {(r.label + ' ').padEnd(labelWidth - r.depth * 2, r.bold ? ' ' : '·')} + + {`${r.count.toLocaleString('en-US')}x`.padStart(countWidth)} + + {formatTokens(r.tokens).padStart(tokenWidth)} + + + ))} + + ) +} + +function ContextTuiApp({ initialScope }: { initialScope: Scope }) { + const { exit } = useApp() + const [provider, setProvider] = useState('claude') + const [sessions, setSessions] = useState(null) + const [cursor, setCursor] = useState(0) + const [expandedId, setExpandedId] = useState(null) + const [scope, setScope] = useState(initialScope) + const [building, setBuilding] = useState(false) + const [frame, setFrame] = useState(0) + const [trees, setTrees] = useState>({}) + const [errors, setErrors] = useState>({}) + + useEffect(() => { + let alive = true + setSessions(null) + setCursor(0) + setExpandedId(null) + void loadSessions(provider).then((rows) => { + if (alive) setSessions(rows) + }) + return () => { + alive = false + } + }, [provider]) + + useEffect(() => { + if (!building) return + const t = setInterval(() => setFrame((f) => f + 1), 100) + return () => clearInterval(t) + }, [building]) + + const toggleExpand = (session: TitledSessionRef) => { + if (expandedId === session.sessionId) { + setExpandedId(null) + return + } + setExpandedId(session.sessionId) + const key = `${provider}:${session.sessionId}:${session.mtimeMs}` + if (trees[key]) return + setBuilding(true) + setErrors((e) => ({ ...e, [key]: '' })) + const build = provider === 'claude' ? buildContextTree(session) : buildCodexContextTree(session) + void build + .then((tree) => setTrees((t) => ({ ...t, [key]: tree }))) + .catch((err: unknown) => setErrors((e) => ({ ...e, [key]: err instanceof Error ? err.message : String(err) }))) + .finally(() => setBuilding(false)) + } + + useInput((input, key) => { + if (input === 'q' || key.escape) { + exit() + return + } + if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1)) + if (key.downArrow || input === 'j') setCursor((c) => Math.min((sessions?.length ?? 1) - 1, c + 1)) + if (key.tab || key.leftArrow || key.rightArrow) setProvider((p) => (p === 'claude' ? 'codex' : 'claude')) + if (input === 'f') setScope((s) => (s === 'effective' ? 'full' : 'effective')) + if ((key.return || input === ' ') && sessions && sessions[cursor]) toggleExpand(sessions[cursor]) + }) + + const titleWidth = 46 + + return ( + + + + Context{' '} + + {PROVIDERS.map((p) => ( + + {' '} + + {` ${p.label} `} + + + ))} + {' ↑↓ move · enter expand · tab provider · f scope · q quit'} + + + + {!sessions && Loading sessions…} + {sessions && sessions.length === 0 && No sessions found for this provider.} + + {sessions?.map((s, i) => { + const selected = i === cursor + const expanded = expandedId === s.sessionId + const key = `${provider}:${s.sessionId}:${s.mtimeMs}` + const tree = trees[key] + const error = errors[key] + return ( + + + {selected ? '❯ ' : ' '} + {s.sessionId.slice(0, 8)} + + {' '} + {truncate(s.title || 'untitled session', titleWidth).padEnd(titleWidth)} + + + {' '} + {truncate(s.project, 12).padEnd(12)} {relativeAge(s.mtimeMs).padStart(8)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)} + + + {expanded && error && ( + + could not read this session: {error} + + )} + {expanded && !tree && !error && ( + + {SPINNER[frame % SPINNER.length]} + reading transcript ({(s.sizeBytes / 1024 / 1024).toFixed(0)}MB)… + + )} + {expanded && tree && } + + ) + })} + + + block tokens are estimates; context (exact) comes from API usage + + ) +} + +export async function runContextTui(opts: { initialScope?: Scope } = {}): Promise { + patchStdoutForWindows() + const instance = render() + await instance.waitUntilExit() +} diff --git a/src/currency.ts b/src/currency.ts index c73ed4d..d883ffc 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -31,7 +31,7 @@ const USD: CurrencyState = { code: 'USD', rate: 1, symbol: '$' } // Intl renders some currencies with a locale-disambiguated symbol (e.g. CNY as // "CN¥" in en) where the bare glyph is what users expect. Override those. (#430) -const SYMBOL_OVERRIDES: Record = { CNY: '¥' } +const SYMBOL_OVERRIDES: Record = { CNY: '¥', RON: 'lei' } // Intl.NumberFormat throws on invalid ISO 4217 codes, so we use it as a validator export function isValidCurrencyCode(code: string): boolean { diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index abdda71..a6d7a4d 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -5,7 +5,14 @@ import { createHash, randomBytes } from 'node:crypto' import type { ParsedProviderCall } from './providers/types.js' -const CURSOR_CACHE_VERSION = 3 +// Version 4: cursor input accounting changed — conversations with real +// context tokens on disk (composerData.promptTokenBreakdown.totalUsedTokens / +// contextTokensUsed) are now credited once via a synthetic +// cursor:composer-input: record instead of the per-bubble text-length +// estimate, and composer-* models price at Cursor-published rates rather than +// a Sonnet proxy. Cached v3 results carry the old text-estimate input and the +// old composer costs, so they must be re-hydrated. +const CURSOR_CACHE_VERSION = 4 const FINGERPRINT_BYTES = 256 type DbFingerprint = { mtimeMs: number; size: number; headHash: string } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index ecdb047..7206a62 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -10,15 +10,28 @@ import type { DateRange, ProjectSummary } from './types.js' // at the 5-minute rate, so cached historical cost/model/provider/category // totals would remain under-reported unless discarded and recomputed from // raw sessions. Skips fork-internal v5. -export const DAILY_CACHE_VERSION = 6 -// MIN_SUPPORTED_VERSION bumped to 6 too. The migration path +// v7: several providers added since v6 (zerostack, grok, zcode, hermes, +// open-design, zed, lingtai-tui, devin) parse usage that older binaries +// skipped, so days cached at v6 omit them and report $0 for those providers +// across history. Raising MIN_SUPPORTED_VERSION to 7 too forces a one-time +// full re-hydration so the newly supported providers backfill without a +// manual cache clear. (upstream #550) +// v8: cursor accounting changed — conversations now credit their real +// composer context tokens once (conversation-anchored cursor:composer-input +// records) instead of the per-bubble text estimate, and composer-* models +// price at Cursor-published rates rather than the Sonnet proxy. Days finalized +// at v7 carry the old text-estimate input and sonnet-proxy composer costs, so +// raising MIN_SUPPORTED_VERSION to 8 forces the one-time full re-hydration +// that backfills history under the new cursor accounting. +export const DAILY_CACHE_VERSION = 8 +// MIN_SUPPORTED_VERSION bumped to 8 too. The migration path // (isMigratableCache + migrateDays) only fills in missing default fields; // it does NOT recompute the providers / categories / models rollups from // session data, because those raw sessions are not stored in the cache. -// So a migrated v4/v5 cache would carry forward stale pricing totals for -// the full cache retention window. Setting the floor to 6 forces older +// So a migrated older cache would carry forward stale pricing totals for +// the full cache retention window. Setting the floor to 8 forces older // caches to be discarded and recomputed cleanly. -const MIN_SUPPORTED_VERSION = 6 +const MIN_SUPPORTED_VERSION = 8 const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { @@ -214,9 +227,16 @@ export async function ensureCacheHydrated( return withDailyCacheLock(async () => { let c = await loadDailyCache() - const hadYesterday = c.days.some(d => d.date >= yesterdayStr) - if (hadYesterday) { - const freshDays = c.days.filter(d => d.date < yesterdayStr) + // Drop any cached entry dated today or later. The cache only ever stores + // complete past days (up to yesterday), so a >= today entry can only come + // from the clock moving backward or a stale older cache; left in place it + // would be served frozen instead of recomputed live. Yesterday and earlier + // stay cached, so this does not re-parse already-cached days — which is the + // repeated-status-parse win (previously yesterday was dropped and re-parsed + // on every run). (upstream #486/#499) + const todayStr = toDateString(now) + if (c.days.some(d => d.date >= todayStr)) { + const freshDays = c.days.filter(d => d.date < todayStr) const latestFresh = freshDays.length > 0 ? freshDays.at(-1)!.date : null c = { ...c, days: freshDays, lastComputedDate: latestFresh } } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 0568f59..da1193d 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -919,6 +919,10 @@ export async function renderDashboard(period: Period = 'week', provider: string await waitUntilExit() } else { const { unmount } = render(, { patchConsole: false }) + // Non-interactive one-shot output: ink schedules the frame through a + // throttled render, so yield a tick to let it flush to stdout before + // unmounting. Unmounting synchronously can race the flush and drop output. + await new Promise(resolve => setTimeout(resolve, 0)) unmount() } } diff --git a/src/device-name.ts b/src/device-name.ts new file mode 100644 index 0000000..62a32e2 --- /dev/null +++ b/src/device-name.ts @@ -0,0 +1,32 @@ +import { hostname } from 'node:os' +import { execFileSync } from 'node:child_process' + +// A clean, human-readable name for THIS machine, for display in the web +// dashboard's device list. Node's hostname() returns the noisy Bonjour/mDNS +// form (e.g. "soumyas-MacBook-Air-3.local" — a ".local" suffix plus an "-N" +// collision digit). On macOS the user-facing ComputerName ("Soumya's MacBook +// Air") is far nicer, so prefer it. Memoized: the name doesn't change within a +// process and scutil is a subprocess. +let cached: string | null = null + +export function getDeviceName(): string { + if (cached === null) cached = computeDeviceName() + return cached +} + +function computeDeviceName(): string { + if (process.platform === 'darwin') { + try { + const out = execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', timeout: 2000 }).trim() + // Strip a trailing " (2)" network-collision marker macOS appends when two + // devices share a ComputerName — noise for a single-device dashboard. + if (out) return out.replace(/\s*\(\d+\)$/, '') + } catch { + // scutil missing or sandboxed — fall through to the hostname fallback. + } + } + // Non-macOS / fallback: drop a trailing ".local" and a small "-N" collision + // suffix (kept to 1–2 digits so a legitimate name like "mac-mini-2020" is + // preserved). + return hostname().replace(/\.local$/i, '').replace(/-\d{1,2}$/, '') +} diff --git a/src/export.ts b/src/export.ts index 2189135..92d6d10 100644 --- a/src/export.ts +++ b/src/export.ts @@ -175,6 +175,25 @@ function buildToolRows(projects: ProjectSummary[]): Row[] { })) } +function buildMcpRows(projects: ProjectSummary[]): Row[] { + const mcpTotals: Record = Object.create(null) + for (const project of projects) { + for (const session of project.sessions) { + for (const [server, d] of Object.entries(session.mcpBreakdown)) { + mcpTotals[server] = (mcpTotals[server] ?? 0) + d.calls + } + } + } + const total = Object.values(mcpTotals).reduce((s, n) => s + n, 0) + return Object.entries(mcpTotals) + .sort(([, a], [, b]) => b - a) + .map(([server, calls]) => ({ + Server: server, + Calls: calls, + 'Share (%)': pct(calls, total), + })) +} + function buildBashRows(projects: ProjectSummary[]): Row[] { const bashTotals: Record = Object.create(null) for (const project of projects) { @@ -270,6 +289,7 @@ function buildReadme(periods: PeriodExport[]): string { ' projects.csv Spend per project folder for the selected detail period.', ' sessions.csv One row per session for the selected detail period.', ' tools.csv Tool invocations and share for the selected detail period.', + ' mcp.csv MCP server invocations and share for the selected detail period.', ' shell-commands.csv Shell commands executed via Bash tool for the selected detail period.', '', 'Notes', @@ -338,6 +358,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8') + await writeFile(join(folder, 'mcp.csv'), rowsToCsv(buildMcpRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'shell-commands.csv'), rowsToCsv(buildBashRows(thirtyDayProjects)), 'utf-8') return folder @@ -362,6 +383,7 @@ export async function exportJson(periods: PeriodExport[], outputPath: string): P projects: buildProjectRows(thirtyDayProjects), sessions: buildSessionRows(thirtyDayProjects), tools: buildToolRows(thirtyDayProjects), + mcp: buildMcpRows(thirtyDayProjects), shellCommands: buildBashRows(thirtyDayProjects), } diff --git a/src/fs-utils.ts b/src/fs-utils.ts index acdba69..0e52006 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -10,10 +10,11 @@ export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024 // Line-by-line streaming has bounded memory (one line at a time) and is not // constrained by V8's string limit, so it can safely handle multi-GB session -// files. The cap here is purely a sanity check against pathological inputs; -// real Codex sessions for heavy users have been observed at 250+ MB and will -// continue to grow as context windows expand. -export const MAX_STREAM_SESSION_FILE_BYTES = 2 * 1024 * 1024 * 1024 +// files. Heavy Codex sessions routinely reach several GB (image-heavy compacted +// turns), so the cap is generous and exists only to guard against truly +// pathological inputs. When a file IS skipped, notice() surfaces it (always on, +// not verbose-gated) so a dropped session never silently understates usage. +export const MAX_STREAM_SESSION_FILE_BYTES = 4 * 1024 * 1024 * 1024 function verbose(): boolean { return process.env.CODEBURN_VERBOSE === '1' @@ -23,6 +24,12 @@ export function warn(msg: string): void { if (verbose()) process.stderr.write(`codeburn: ${msg}\n`) } +// Always surfaced (not verbose-gated): dropping an entire session file silently +// understates reported usage with no signal, so oversize skips use this. +function notice(msg: string): void { + process.stderr.write(`codeburn: ${msg}\n`) +} + // Open + fstat + read on the SAME file descriptor closes the TOCTOU window // where stat() and the subsequent read could see different inodes (a swap // between a small regular file and a 2 GB FIFO, for example). The handle @@ -106,7 +113,7 @@ export async function* readSessionLines(filePath: string): AsyncGenerator MAX_STREAM_SESSION_FILE_BYTES) { - warn(`skipped oversize file ${filePath} (${size} bytes > stream cap ${MAX_STREAM_SESSION_FILE_BYTES})`) + notice(`skipped oversize session ${filePath} (${size} bytes > cap ${MAX_STREAM_SESSION_FILE_BYTES}); its usage is NOT counted`) return } diff --git a/src/main.ts b/src/main.ts index 124a7a8..54a9b81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,12 +3,15 @@ import { safeRunGit } from './git-safe.js' import { installMenubarApp } from './menubar-installer.js' import { installTrayApp } from './tray-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' -import { getShortModelName, loadPricing, setModelAliases } from './models.js' +import { getShortModelName, loadPricing, setModelAliases, setPriceOverrides } from './models.js' +import { renderOverview } from './overview.js' +import { codexCredits, codexCreditRate } from './codex-credits.js' import { parseAllSessions, filterProjectsByName, clearSessionCache } from './parser.js' import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' import { type PeriodData, type ProviderCost } from './menubar-json.js' import { buildMenubarPayload, computeValuation } from './menubar-json.js' +import { buildPeriodData, computeProviderCosts, buildMenubarPayloadForRange } from './usage-payload.js' import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' @@ -16,9 +19,22 @@ import { aggregateModelEfficiency } from './model-efficiency.js' import { renderDashboard } from './dashboard.js' import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize, scanAndDetect } from './optimize.js' +import { runContextCommand } from './context-tree.js' +import { registerActCommands } from './act/cli.js' import { renderCompare } from './compare.js' -import { getAllProviders } from './providers/index.js' -import { clearPlan, readConfig, readPlan, readAllPlans, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js' +import { runShareServer } from './sharing/share-run.js' +import { runWebDashboard } from './web-dashboard.js' +import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js' +import { browse } from './sharing/discovery.js' +import { ShareController } from './sharing/share-controller.js' +import { loadOrCreateIdentity } from './sharing/identity.js' +import { pairingCode } from './sharing/pairing.js' +import { promptChoice } from './sharing/prompt.js' +import { getSharingDir, loadRemotes, saveRemotes } from './sharing/store.js' +import type { UsageQuery } from './sharing/share-server.js' +import { hostname } from 'node:os' +import { getAllProviders, safeDiscoverSessions, allProviderNames } from './providers/index.js' +import { clearPlan, readConfig, readPlan, readAllPlans, saveConfig, savePlan, getConfigFilePath, type CodeburnConfig, type PlanId } from './config.js' import { detectPlans } from './plan-detect.js' import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js' import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js' @@ -77,6 +93,14 @@ async function hydrateCache() { } } +// A downstream reader that closes the pipe early (`| head`, quitting `less`, or +// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather +// than crashing with an unhandled error event. +process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0) + throw err +}) + function collect(val: string, acc: string[]): string[] { acc.push(val) return acc @@ -125,6 +149,24 @@ function assertFormat(value: string, allowed: readonly string[], command: string } } +function assertScope(value: string, allowed: readonly string[], command: string): void { + if (!allowed.includes(value)) { + process.stderr.write( + `codeburn ${command}: unknown scope "${value}". Valid values: ${allowed.join(', ')}.\n` + ) + process.exit(1) + } +} + +function assertProvider(value: string, command: string): void { + const names = allProviderNames() + if (value === 'all' || names.includes(value)) return + process.stderr.write( + `codeburn ${command}: unknown provider "${value}". Valid values: all, ${names.join(', ')}.\n` + ) + process.exit(1) +} + async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { await loadPricing() const { range, label } = getDateRange(period) @@ -157,6 +199,7 @@ program.hook('preAction', async (thisCommand) => { } const config = await readConfig() setModelAliases(config.modelAliases ?? {}) + setPriceOverrides(config.priceOverrides ?? {}) if (thisCommand.opts<{ verbose?: boolean }>().verbose) { process.env['CODEBURN_VERBOSE'] = '1' } @@ -240,6 +283,16 @@ function buildModelList(modelMap: Record, modelEfficiency name, ...rest, cost: convertCost(cost), + // Codex credits consumed by this model (null for non-Codex models — + // codexCreditRate returns null, which stands in for the missing + // per-model `provider` field). (#510) + credits: codexCreditRate(name) != null + ? codexCredits(name, { + inputTokens: rest.inputTokens, + cachedReadTokens: rest.cacheReadTokens, + outputTokens: rest.outputTokens, + }) + : null, editTurns: efficiency?.editTurns ?? 0, oneShotTurns: efficiency?.oneShotTurns ?? 0, oneShotRate: efficiency?.oneShotRate ?? null, @@ -264,20 +317,6 @@ function buildActivityList(catMap: Record) { })) } -function computeProviderCosts(projects: ProjectSummary[]): ProviderCost[] { - const providerCosts = new Map() - for (const proj of projects) { - for (const session of proj.sessions) { - for (const turn of session.turns) { - for (const call of turn.assistantCalls ?? []) { - providerCosts.set(call.provider, (providerCosts.get(call.provider) ?? 0) + call.costUSD) - } - } - } - } - return Array.from(providerCosts.entries()).map(([name, cost]) => ({ name, cost })) -} - function buildDailyHistory(days: ReturnType) { return days.map(d => ({ date: d.date, @@ -468,6 +507,7 @@ program .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'report') + assertProvider(opts.provider, 'report') let customRange: DateRange | null = null try { customRange = parseDateRangeFlags(opts.from, opts.to) @@ -498,53 +538,11 @@ program await renderDashboard(period, opts.provider, opts.refresh, projectScope.filter, opts.exclude, customRange, customRangeLabel) }) -function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { - const sessions = projects.flatMap(p => p.sessions) - // Null-prototype maps: model names (and categories) are untrusted transcript - // strings, so a "__proto__"/"constructor" key must not bind to Object.prototype - // and pollute it via `if (!map[k]) …; map[k].x += …`. See daily-cache.safeRecord. - const catTotals: Record = Object.create(null) - const modelTotals: Record = Object.create(null) - let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 - - for (const sess of sessions) { - inputTokens += sess.totalInputTokens - outputTokens += sess.totalOutputTokens - cacheReadTokens += sess.totalCacheReadTokens - cacheWriteTokens += sess.totalCacheWriteTokens - for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { - if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } - catTotals[cat].turns += d.turns - catTotals[cat].cost += d.costUSD - catTotals[cat].editTurns += d.editTurns - catTotals[cat].oneShotTurns += d.oneShotTurns - } - for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } - modelTotals[model].calls += d.calls - modelTotals[model].cost += d.costUSD - } - } - - return { - label, - cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), - calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), - sessions: projects.reduce((s, p) => s + p.sessions.length, 0), - inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, - categories: Object.entries(catTotals) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), - models: Object.entries(modelTotals) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, d]) => ({ name, ...d })), - } -} - program .command('status') .description('Compact status output (today + month)') .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal') + .option('--scope ', 'Usage scope for menubar-json: local, combined (paired devices)', 'local') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--project ', 'Show only projects matching name (repeatable). Default auto-scopes to the cwd git repo when run inside one.', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) @@ -553,6 +551,16 @@ program .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') .action(async (opts) => { assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') + assertScope(opts.scope, ['local', 'combined'], 'status') + assertProvider(opts.provider, 'status') + // --scope combined pulls each paired device's UNFILTERED usage, so a + // provider/project/exclude filter can't be honored across the fleet — + // reject the contradiction loudly rather than silently returning a local + // filter on a combined roll-up. + if (opts.scope === 'combined' && (opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0)) { + process.stderr.write('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)\n') + process.exit(1) + } await loadPricing() const pf = opts.provider const projectScope = resolveProjectScope(opts) @@ -656,7 +664,7 @@ program const candidatesToProbe = allProviders.filter(p => !providers.some(pc => pc.name === p.displayName)) const probed = await Promise.all(candidatesToProbe.map(async p => ({ provider: p, - hasSessions: (await p.discoverSessions()).length > 0, + hasSessions: (await safeDiscoverSessions(p)).length > 0, }))) for (const { provider, hasSessions } of probed) { if (hasSessions) providers.push({ name: provider.displayName, cost: 0 }) @@ -781,7 +789,24 @@ program byModel: routingWasteByModel.slice(0, 5), } - console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, valuation, retryTax, routingWaste))) + const payload = buildMenubarPayload(currentData, providers, optimize, dailyHistory, valuation, retryTax, routingWaste) + if (opts.scope === 'combined') { + // Combined multi-device usage is best-effort enrichment on the menubar's + // hot path. Never let pulling peers (or a corrupt remotes store) take + // down the base local payload: on any failure, emit local data with + // `combined` omitted so the menubar always gets a valid response. + try { + const localGetUsage = async (): Promise => payload + const results = await pullDevices(localGetUsage, { period: opts.period }, hostname(), {}) + payload.combined = summarizeDeviceUsage(results, { + start: toDateString(periodInfo.range.start), + end: toDateString(periodInfo.range.end), + }) + } catch { + // best-effort only: the local payload is still emitted below + } + } + console.log(JSON.stringify(payload)) return } @@ -835,6 +860,7 @@ program .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'today') + assertProvider(opts.provider, 'today') const projectScope = resolveProjectScope(opts) if (opts.format === 'json') { await runJsonReport('today', opts.provider, projectScope.filter, opts.exclude) @@ -854,6 +880,7 @@ program .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) .action(async (opts) => { assertFormat(opts.format, ['tui', 'json'], 'month') + assertProvider(opts.provider, 'month') const projectScope = resolveProjectScope(opts) if (opts.format === 'json') { await runJsonReport('month', opts.provider, projectScope.filter, opts.exclude) @@ -876,6 +903,7 @@ program .option('--redact-paths', 'Replace absolute project paths with salted hashes (safe to share)') .action(async (opts) => { assertFormat(opts.format, ['csv', 'json', 'html'], 'export') + assertProvider(opts.provider, 'export') await loadPricing() const pf = opts.provider // Resolve the effective project scope: explicit --project wins, then @@ -943,6 +971,179 @@ program console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`) }) +program + .command('share [action]') + .description("Securely share this device's usage with your other devices (opt-in LAN sharing). Actions: status. Supports --format json for status.") + .option('--port ', 'Port to listen on', parseInteger, 7777) + .option('--pair', 'Open a pairing window and print a PIN to add a new device') + .option('--always', 'Keep sharing until stopped (default stops after 10 min idle)') + .option('--format ', 'Output format: text, json', 'text') + .action(async (action: string | undefined, opts) => { + assertFormat(opts.format, ['text', 'json'], 'share') + if (action === 'status') { + const share = new ShareController(async () => ({}), opts.port) + const status = await share.status() + if (opts.format === 'json') { + console.log(JSON.stringify(status)) + return + } + console.log(`\n Sharing: ${status.sharing ? 'on' : 'off'}\n Name: ${status.name}\n Port: ${status.port}\n Paired peers: ${status.peers}\n`) + return + } + if (action !== undefined) { + process.stderr.write('codeburn share: unknown action. Valid values: status.\n') + process.exit(1) + } + if (opts.format === 'json') { + process.stderr.write('codeburn share: --format json is only supported for `share status`.\n') + process.exit(1) + } + await runShareServer({ port: opts.port, pair: !!opts.pair, always: !!opts.always }) + }) + +program + .command('devices [action] [target]') + .description('Combined usage across your devices. Actions: scan | add (find nearby & pair) | add --pin (manual) | rm . Supports --format json for read-only output and scan.') + .option('--pin ', 'Pairing PIN shown on the device you are adding') + .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .option('--port ', 'Default port when adding a device', parseInteger, 7777) + .option('--format ', 'Output format: text, json', 'text') + .action(async (action: string | undefined, target: string | undefined, opts) => { + assertFormat(opts.format, ['text', 'json'], 'devices') + await loadPricing() + if (action === 'scan') { + const dir = getSharingDir() + const id = await loadOrCreateIdentity(dir) + const pairedFps = new Set((await loadRemotes(dir)).map((r) => r.fingerprint)) + const found = (await browse(2500)) + .filter((d) => d.fingerprint !== id.fingerprint) + .map((d) => ({ + name: d.name, + host: d.host, + port: d.port, + fingerprint: d.fingerprint, + code: pairingCode(id.fingerprint, d.fingerprint), + paired: pairedFps.has(d.fingerprint), + })) + if (opts.format === 'json') { + console.log(JSON.stringify({ found })) + return + } + if (found.length === 0) { + console.log('\n No devices found. On the other Mac run `codeburn share`, and make sure both are on the same Wi-Fi.\n') + return + } + process.stdout.write('\n Found devices:\n') + for (const d of found) { + process.stdout.write(` ${d.name} (${d.host}:${d.port}) ${d.paired ? '[paired]' : `[code ${d.code}]`}\n`) + } + process.stdout.write('\n') + return + } + if (opts.format === 'json' && action !== undefined) { + process.stderr.write('codeburn devices: --format json is only supported for read-only devices output and scan.\n') + process.exit(1) + } + if (action === 'add') { + if (target && opts.pin) { + // Manual pairing trusts the address you typed: it sends the PIN to + // whoever answers at without first confirming it's the right + // device (unlike discover-and-approve, which matches a code on both + // ends). Warn so a mistyped/hijacked address can't silently capture the + // PIN. (security review — first-pairing TOCTOU) + process.stderr.write( + `\n ⚠ Manual pairing sends the PIN to ${target} without verifying it's the right device.\n` + + ` Double-check that address is the one shown on your other Mac.\n` + + ` Tip: 'codeburn devices add' (no host) discovers and confirms a matching code instead.\n` + ) + const device = await addRemote(target, opts.pin, { defaultPort: opts.port }) + console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\n`) + return + } + process.stdout.write('\n Looking for devices on your network...\n') + const found = await browse(3000) + if (found.length === 0) { + console.error(' No devices found. On the other Mac run `codeburn share`, and make sure both are on the same Wi-Fi.\n') + process.exit(1) + } + let chosen = found[0]! + if (found.length > 1) { + found.forEach((d, i) => process.stdout.write(` ${i + 1}) ${d.name} (${d.host})\n`)) + const n = await promptChoice(' Connect to which? [number]', found.length) + if (n < 1) { + console.error(' Cancelled.\n') + process.exit(1) + } + chosen = found[n - 1]! + } + const device = await linkRemote(chosen, { + onCode: (code) => + process.stdout.write(`\n Connecting to "${chosen.name}". Confirm this code on that device: ${code}\n Waiting for approval...\n`), + }) + console.log(`\n Paired with "${device.name}".\n`) + return + } + if (action === 'rm' || action === 'remove') { + const remotes = await loadRemotes() + const next = remotes.filter((r) => r.name !== target && `${r.host}:${r.port}` !== target) + await saveRemotes(next) + console.log(`\n Removed ${remotes.length - next.length} device(s).\n`) + return + } + const localGetUsage = async (q: UsageQuery) => { + const customRange = parseDateRangeFlags(q.from, q.to) + const periodInfo = customRange + ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) } + : getDateRange(toPeriod(q.period ?? opts.period)) + return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }) + } + const results = await pullDevices(localGetUsage, { period: opts.period }, hostname(), {}) + if (opts.format === 'json') { + console.log(JSON.stringify(summarizeDeviceUsage(results))) + return + } + process.stdout.write('\n' + renderDevices(results)) + }) + +program + .command('identity') + .description('Show this device identity (name + cert fingerprint) used for sharing') + .option('--format ', 'Output format: text, json', 'text') + .action(async (opts: { format: string }) => { + assertFormat(opts.format, ['text', 'json'], 'identity') + const id = await loadOrCreateIdentity(getSharingDir()) + if (opts.format === 'json') { + console.log(JSON.stringify({ name: id.name, fingerprint: id.fingerprint })) + return + } + console.log(`\n Name: ${id.name}\n Fingerprint: ${id.fingerprint}\n`) + }) + +program + .command('web') + .description('Open the local web dashboard in your browser (loopback-only)') + .option('-p, --period ', 'Initial period: today, week, 30days, month, all', 'today') + .option('--from ', 'Start date (YYYY-MM-DD)') + .option('--to ', 'End date (YYYY-MM-DD)') + .option('--provider ', 'Filter by provider (e.g. claude, codex, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--port ', 'Port to listen on (falls back to a free port if taken)', parseInteger, 4747) + .option('--no-open', 'Do not open the browser automatically') + .action(async (opts) => { + assertProvider(opts.provider, 'web') + await runWebDashboard({ + period: opts.period, + provider: opts.provider, + from: opts.from, + to: opts.to, + project: opts.project, + exclude: opts.exclude, + port: opts.port, + open: opts.open, + }) + }) + program .command('menubar') .description('Install and launch the macOS menubar app (one command, no clone)') @@ -1210,24 +1411,226 @@ program .description('Find token waste and get exact fixes') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--apply', 'Interactively apply config-class fixes (backed up, journaled, undoable)') + .option('--yes', 'With --apply: apply every appliable fix without prompting') + .option('--dry-run', 'With --apply: print the plan and exit without changing anything') + .option('--only ', 'With --apply: restrict to a comma-separated list of finding ids') .action(async (opts) => { + assertProvider(opts.provider, 'optimize') await loadPricing() const { range, label } = getDateRange(opts.period) const projects = await parseAllSessions(range, opts.provider) + if (opts.apply) { + const { runOptimizeApply } = await import('./act/optimize-apply.js') + await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only }) + return + } await runOptimize(projects, label, range) }) +program + .command('context [session]') + .description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No session argument opens an interactive browser.') + .option('--list', 'List recent sessions to pick from') + .option('--full', 'Cover the whole session history instead of the live (post-compaction) window') + .option('--json', 'JSON output') + .option('--provider ', 'Session source: claude or codex', 'claude') + .action(async (session: string | undefined, opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string }) => { + if (opts.provider !== 'claude' && opts.provider !== 'codex') { + console.error('context: --provider must be claude or codex') + process.exitCode = 1 + return + } + if (!session && !opts.list && !opts.json && process.stdout.isTTY && process.stdin.isTTY) { + const { runContextTui } = await import('./context-tui.js') + await runContextTui({ initialScope: opts.full ? 'full' : 'effective' }) + return + } + await runContextCommand(session, opts) + }) + program .command('compare') .description('Compare two AI models side-by-side') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .action(async (opts) => { + assertProvider(opts.provider, 'compare') await loadPricing() const { range } = getDateRange(opts.period) await renderCompare(range, opts.provider) }) +program + .command('overview') + .description('Plain-text usage overview, copy-pasteable (defaults to this month)') + .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') + .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') + .option('--provider ', 'Filter by provider (e.g. claude, codex, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--no-color', 'Disable ANSI colors') + .action(async (opts) => { + assertProvider(opts.provider, 'overview') + await loadPricing() + let customRange: DateRange | null = null + try { + customRange = parseDateRangeFlags(opts.from, opts.to) + } catch (err) { + console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) + } + const { range, label } = customRange + ? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) } + : getDateRange(toPeriod(opts.period)) + const projects = filterProjectsByName(await parseAllSessions(range, opts.provider), opts.project, opts.exclude) + process.stdout.write(renderOverview(projects, { label, color: opts.color })) + }) + +program + .command('audit') + .description("Token audit: raw provider token fields vs codeburn's displayed totals and cost derivation") + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('--from ', 'Custom range start (YYYY-MM-DD)') + .option('--to ', 'Custom range end (YYYY-MM-DD)') + .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') + .option('--format ', 'Output format: table, json', 'table') + .action(async (opts) => { + assertProvider(opts.provider, 'audit') + const { aggregateAudit, renderAuditTable, renderAuditJson } = await import('./audit-report.js') + await loadPricing() + + let range + if (opts.from || opts.to) { + const customRange = parseDateRangeFlags(opts.from, opts.to) + if (!customRange) { + process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n') + process.exit(1) + } + range = customRange + } else { + range = getDateRange(opts.period).range + } + + const projects = await parseAllSessions(range, opts.provider) + const rows = await aggregateAudit(projects) + + const fmt = (opts.format ?? 'table').toLowerCase() + if (fmt === 'json') { + process.stdout.write(renderAuditJson(rows) + '\n') + } else { + if (rows.length === 0) { + process.stdout.write('No model usage found for the selected period.\n') + return + } + process.stdout.write(renderAuditTable(rows) + '\n') + } + }) + +type PriceOverrideConfig = NonNullable[string] + +type PriceOverrideOptions = { + input?: number + output?: number + cacheRead?: number + cacheCreation?: number + remove?: string + list?: boolean +} + +function invalidUsdPerMillionRate(option: string, value: number | undefined): string | null { + if (value === undefined) return null + if (Number.isFinite(value) && value >= 0) return null + return `Invalid ${option}: expected a finite number >= 0 (USD per 1,000,000 tokens).` +} + +function formatPriceOverrideParts(rates: PriceOverrideConfig): string { + const parts = [`input ${rates.input}`, `output ${rates.output}`] + if (typeof rates.cacheRead === 'number') parts.push(`cache read ${rates.cacheRead}`) + if (typeof rates.cacheCreation === 'number') parts.push(`cache creation ${rates.cacheCreation}`) + return parts.join(', ') +} + +program + .command('price-override [model]') + .description('Override or add local model pricing. Rates are USD per 1,000,000 tokens (e.g. --input 0.27).') + .option('--input ', 'Input token price in USD per 1,000,000 tokens', parseNumber) + .option('--output ', 'Output token price in USD per 1,000,000 tokens', parseNumber) + .option('--cache-read ', 'Cache-read token price in USD per 1,000,000 tokens', parseNumber) + .option('--cache-creation ', 'Cache-creation token price in USD per 1,000,000 tokens', parseNumber) + .option('--remove ', 'Remove a price override') + .option('--list', 'List configured price overrides') + .action(async (model?: string, opts?: PriceOverrideOptions) => { + const config = await readConfig() + const overrides = new Map(Object.entries(config.priceOverrides ?? {})) + + if (opts?.list || (!model && !opts?.remove)) { + const entries = [...overrides.entries()] + if (entries.length === 0) { + console.log('\n No price overrides configured.') + console.log(' Rates use USD per 1,000,000 tokens.') + console.log(` Config: ${getConfigFilePath()}`) + console.log(' Add one with: codeburn price-override --input --output \n') + } else { + console.log('\n Price overrides (USD per 1,000,000 tokens):') + for (const [name, rates] of entries) { + console.log(` ${name}: ${formatPriceOverrideParts(rates)}`) + } + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + if (opts?.remove) { + if (!overrides.has(opts.remove)) { + console.error(`\n Price override not found: ${opts.remove}\n`) + process.exitCode = 1 + return + } + overrides.delete(opts.remove) + config.priceOverrides = overrides.size > 0 ? Object.fromEntries(overrides) : undefined + await saveConfig(config) + console.log(`\n Removed price override: ${opts.remove}\n`) + return + } + + const input = opts?.input + const output = opts?.output + const cacheRead = opts?.cacheRead + const cacheCreation = opts?.cacheCreation + if (!model || input === undefined || output === undefined) { + console.error('\n Usage: codeburn price-override --input --output [--cache-read ] [--cache-creation ]\n') + process.exitCode = 1 + return + } + + const invalidRate = [ + invalidUsdPerMillionRate('--input', input), + invalidUsdPerMillionRate('--output', output), + invalidUsdPerMillionRate('--cache-read', cacheRead), + invalidUsdPerMillionRate('--cache-creation', cacheCreation), + ].find((message): message is string => message !== null) + if (invalidRate) { + console.error(`\n ${invalidRate}\n`) + process.exitCode = 1 + return + } + + const override: PriceOverrideConfig = { + input, + output, + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheCreation !== undefined ? { cacheCreation } : {}), + } + overrides.set(model, override) + config.priceOverrides = Object.fromEntries(overrides) + await saveConfig(config) + console.log(`\n Price override saved: ${model}: ${formatPriceOverrideParts(override)}`) + console.log(' Unit: USD per 1,000,000 tokens') + console.log(` Config: ${getConfigFilePath()}\n`) + }) + program .command('yield') .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') @@ -1270,4 +1673,7 @@ program if (report.errors.length > 0) process.exit(1) }) +// Review/undo the acting layer's applied changes: `act list | undo | report`. (#610) +registerActCommands(program) + program.parse() diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 1809e19..d947f61 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -10,6 +10,9 @@ export type PeriodData = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + /// Total Codex credits consumed in the period (issues #408/#495). Optional so + /// non-menubar PeriodData producers don't have to compute it. + codexCredits?: number categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }> models: Array<{ name: string; cost: number; calls: number }> } @@ -79,6 +82,92 @@ export type ValuationBlock = { plan: { id: string; displayName: string; monthlyUsd: number } | null } +/** + * One machine's usage in a multi-device ("Totals by machine") report. `id` is + * the cert fingerprint for remotes, or 'local' for this Mac. `error` is set + * (and the numeric fields are zero) when a paired device was unreachable or + * returned no payload, so the UI can show a degraded row instead of dropping + * the device. Cache create/read are split out; totalTokens is their sum with + * input + output. (device-sharing #567) + */ +export type DeviceSummary = { + id: string + name: string + local: boolean + error?: string + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number +} + +/** + * The joined view across this Mac + every paired remote: one summary per + * device plus the `combined` roll-up. `deviceCount` counts all devices; + * `reachableCount` counts only those that answered (error-free), so the UI can + * say "3 of 4 devices" when one is powered off. (device-sharing #567) + */ +export type CombinedUsage = { + perDevice: DeviceSummary[] + combined: { + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number + deviceCount: number + reachableCount: number + } +} + +/** + * Time-bucketed usage series for the web dashboard's granular chart. Each + * point is one bucket (e.g. 15/60/1440 min); `models`/`sessions` break the + * bucket's cost+tokens down by model and by session. Populated only on the + * web-dashboard payload path (buildMenubarPayloadForRange with enrich:true); + * the menubar/GNOME/sharing paths omit it. (web dashboard #573) + */ +export type GranularSeries = { id: string; label: string } +export type GranularValue = { seriesId: string; cost: number; tokens: number } +export type GranularPoint = { + timestamp: string + cost: number + tokens: number + models: GranularValue[] + sessions: GranularValue[] +} +export type GranularHistory = { + bucketMinutes: number + modelSeries: GranularSeries[] + sessionSeries: GranularSeries[] + points: GranularPoint[] +} + +/** + * The extra `current` breakdowns the web dashboard renders (By tool / Skills / + * MCP servers / Subagents / Top projects / Model efficiency panels). Optional + * because only the web-dashboard payload path fills them — the menubar's hot + * status path and the device-sharing path omit them (smaller payload, and the + * sharing egress surface stays aggregate-only). Additive within schema v1: + * the Swift/GNOME decoders ignore unknown keys, and the SPA's normalizePayload + * defaults every one to []. (web dashboard #533/#534/#554/#586) + */ +export type WebEnrichment = { + topProjects: Array<{ name: string; cost: number; sessions: number; avgCostPerSession: number }> + tools: Array<{ name: string; calls: number }> + subagents: Array<{ name: string; calls: number; cost: number }> + skills: Array<{ name: string; turns: number; cost: number }> + mcpServers: Array<{ name: string; calls: number }> + modelEfficiency: Array<{ name: string; costPerEdit: number; oneShotRate: number }> +} + export type MenubarPayload = { schemaVersion: typeof MENUBAR_SCHEMA_VERSION generated: string @@ -99,6 +188,8 @@ export type MenubarPayload = { cacheReadTokens: number cacheWriteTokens: number cacheHitPercent: number + /// Codex credits consumed in the period; 0 when there is no Codex usage. + codexCredits: number topActivities: Array<{ name: string cost: number @@ -144,6 +235,15 @@ export type MenubarPayload = { savingsUSD: number }> } + /// Web-dashboard-only breakdowns (By tool / Skills / MCP / Subagents / + /// Top projects / Model efficiency). Present only when the payload is built + /// with enrich:true; omitted on the menubar + sharing paths. See WebEnrichment. + topProjects?: WebEnrichment['topProjects'] + tools?: WebEnrichment['tools'] + subagents?: WebEnrichment['subagents'] + skills?: WebEnrichment['skills'] + mcpServers?: WebEnrichment['mcpServers'] + modelEfficiency?: WebEnrichment['modelEfficiency'] } optimize: { findingCount: number @@ -156,7 +256,18 @@ export type MenubarPayload = { } history: { daily: DailyHistoryEntry[] + /// Time-bucketed granular series for the web dashboard's usage chart. + /// Present only on the enriched web-dashboard payload path. (#573) + timeline?: GranularHistory } + /** + * Optional multi-device roll-up, present only when `status --scope combined` + * successfully pulls paired devices. Additive within schema v1 — older + * decoders ignore it. Omitted (not null) on the local-only path or when a + * pull fails, so the menubar always still gets a valid local payload. + * (device-sharing #567) + */ + combined?: CombinedUsage } // Clamp non-finite values (NaN/Infinity from a malformed session file) to 0 @@ -330,6 +441,7 @@ export function buildMenubarPayload( cacheReadTokens: finite(current.cacheReadTokens), cacheWriteTokens: finite(current.cacheWriteTokens), cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens, current.cacheWriteTokens), + codexCredits: current.codexCredits ?? 0, topActivities: buildTopActivities(current.categories), topModels: buildTopModels(current.models), providers: buildProviders(providers), diff --git a/src/models.ts b/src/models.ts index a6b51af..b03df52 100644 --- a/src/models.ts +++ b/src/models.ts @@ -26,6 +26,13 @@ export type ModelCosts = { fastMultiplier: number } +type PriceOverrideRates = { + input: number + output: number + cacheRead?: number + cacheCreation?: number +} + type LiteLLMEntry = { input_cost_per_token?: number output_cost_per_token?: number @@ -46,6 +53,49 @@ const FAST_MULTIPLIERS: Record = { 'claude-opus-4-6': 6, } +// Explicit USD/token prices for Cursor's house "composer" models, which have +// no LiteLLM pricing entry. Cursor publishes these house-model rates in its +// models table at cursor.com/docs/models (provider "Cursor", USD per 1M +// tokens): composer-2/2.5: $0.50 input, $2.50 output, $0.20 cache read; +// composer-1.5: $3.50/$17.50/$0.35; composer-1: $1.25/$10/$0.125. Cursor +// publishes no separate cache-write rate for these, so cache write uses the +// input rate. These are self-contained (SnapshotEntry tuples fed through +// buildOverrideCosts) so they do not depend on the LiteLLM snapshot and win +// over any stale/aliased pricing. Previously composer-* aliased to Claude +// Sonnet, which billed Cursor house usage at Anthropic proxy rates. +const BUILTIN_PRICE_OVERRIDES: Record = { + 'composer-2.5': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6], + 'composer-2': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6], + 'composer-1.5': [3.5e-6, 17.5e-6, 3.5e-6, 0.35e-6], + 'composer-1': [1.25e-6, 10e-6, 1.25e-6, 0.125e-6], +} + +// Build a ModelCosts from a SnapshotEntry-shaped tuple, applying the same +// finite/non-negative floors and cache-cost heuristics as loadSnapshot so the +// override entries behave identically to snapshot entries. Kept self-contained +// (no tupleToCosts helper in this fork). +function buildOverrideCosts(raw: SnapshotEntry): ModelCosts { + const [input, output, cacheWrite, cacheRead] = raw + const inRate = Number.isFinite(input) && input >= 0 ? input : 0 + const outRate = Number.isFinite(output) && output >= 0 ? output : 0 + return { + inputCostPerToken: inRate, + outputCostPerToken: outRate, + cacheWriteCostPerToken: typeof cacheWrite === 'number' && Number.isFinite(cacheWrite) && cacheWrite >= 0 ? cacheWrite : inRate * 1.25, + cacheWrite1hCostPerToken: inRate * 2, + cacheReadCostPerToken: typeof cacheRead === 'number' && Number.isFinite(cacheRead) && cacheRead >= 0 ? cacheRead : inRate * 0.1, + webSearchCostPerRequest: WEB_SEARCH_COST, + fastMultiplier: 1, + } +} + +function applyBuiltinPriceOverrides(pricing: Map): Map { + for (const [name, raw] of Object.entries(BUILTIN_PRICE_OVERRIDES)) { + pricing.set(name, buildOverrideCosts(raw)) + } + return pricing +} + function loadSnapshot(): Map { const map = new Map() for (const [name, raw] of Object.entries(snapshotData as unknown as Record)) { @@ -86,7 +136,7 @@ function loadSnapshot(): Map { return map } -let pricingCache: Map = loadSnapshot() +let pricingCache: Map = applyBuiltinPriceOverrides(loadSnapshot()) let sortedPricingKeys: string[] | null = null function getSortedPricingKeys(): string[] { @@ -214,7 +264,10 @@ function mergeSnapshotFallbacks(pricing: Map): Map { @@ -269,6 +322,22 @@ const BUILTIN_ALIASES: Record = { 'kimi-auto': 'kimi-k2-thinking', 'kimi-code': 'kimi-k2-thinking', 'kimi-for-coding': 'kimi-k2-thinking', + 'mimo-v2-flash': 'xiaomi/mimo-v2-flash', + 'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro', + // ZCode / Hermes run GLM-5.2 (both spellings). Not yet in the bundled + // snapshot; price via the GLM-5.1 sibling `glm-5` so cost isn't $0. resolveAlias + // lowercases before lookup, so the lowercase entry also covers 'GLM-5.2'. (#537/#544/#545) + 'GLM-5.2': 'glm-5', + 'glm-5.2': 'glm-5', + // Grok Build's own id (`grok-build`/`grok-build-0.1`) isn't in LiteLLM, so + // upstream's alias resolves to $0. Price via grok-code-fast-1 — xAI's coding + // model that Grok Build runs on — so the default model isn't silently free. + // Still flagged costIsEstimated. (#521, corrected) + 'grok-build': 'grok-code-fast-1', + 'grok-build-0.1': 'grok-code-fast-1', + // open-design records Codex models with an `openai-codex:` prefix that the + // canonicalizer (slash-only) doesn't strip. (#559) + 'openai-codex:gpt-5.5': 'gpt-5.5', // Cursor emits dot-version tier-last names plus tier/reasoning suffixes // that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`, // `-high-thinking`, `-fast-mode`). Missing aliases here surface as $0 in @@ -308,6 +377,10 @@ const BUILTIN_ALIASES: Record = { } let userAliases: Record = {} +let userPriceOverrides: Map = new Map() +let userPriceOverridesConfig: Record = {} +let sortedPriceOverrideKeys: string[] | null = null +let lowercasePriceOverrideIndex: Map | null = null // Called once during CLI startup after config is loaded. // User aliases take precedence over built-ins. @@ -315,9 +388,118 @@ export function setModelAliases(aliases: Record): void { userAliases = aliases } +// USD-per-1M -> USD/token, clamped through the same safePerTokenRate guard the +// snapshot/LiteLLM paths use so a hand-edited config can't cascade NaN/negatives. +function priceOverrideRatePerToken(usdPerMillion: number | undefined): number | null { + if (typeof usdPerMillion !== 'number') return null + return safePerTokenRate(usdPerMillion / 1_000_000) +} + +// Called once during CLI startup after config is loaded. +// Config/CLI rates are USD per 1,000,000 tokens; ModelCosts stores USD/token. +export function setPriceOverrides(overrides: Record): void { + const next = new Map() + const nextConfig: Record = {} + for (const [model, rates] of Object.entries(overrides)) { + if (!model || !rates || typeof rates !== 'object') continue + nextConfig[model] = { ...rates } + const input = priceOverrideRatePerToken(rates.input) + const output = priceOverrideRatePerToken(rates.output) + if (input === null || output === null) continue + const cacheCreation = priceOverrideRatePerToken(rates.cacheCreation) + const cacheRead = priceOverrideRatePerToken(rates.cacheRead) + // Build ModelCosts inline (mirrors loadSnapshot): default cache-write from + // 1.25× input and cache-read from 0.1× input when the override omits them. + next.set(model, { + inputCostPerToken: input, + outputCostPerToken: output, + cacheWriteCostPerToken: cacheCreation ?? input * 1.25, + cacheWrite1hCostPerToken: input * 2, + cacheReadCostPerToken: cacheRead ?? input * 0.1, + webSearchCostPerRequest: WEB_SEARCH_COST, + fastMultiplier: 1, + }) + } + userPriceOverrides = next + userPriceOverridesConfig = nextConfig + sortedPriceOverrideKeys = null + lowercasePriceOverrideIndex = null +} + +function getSortedPriceOverrideKeys(): string[] { + if (sortedPriceOverrideKeys === null) { + sortedPriceOverrideKeys = Array.from(userPriceOverrides.keys()).sort((a, b) => b.length - a.length) + } + return sortedPriceOverrideKeys +} + +function getLowercasePriceOverrideIndex(): Map { + if (lowercasePriceOverrideIndex === null) { + lowercasePriceOverrideIndex = new Map() + for (const [key, costs] of userPriceOverrides) { + const lk = key.toLowerCase() + if (!lowercasePriceOverrideIndex.has(lk)) lowercasePriceOverrideIndex.set(lk, costs) + } + } + return lowercasePriceOverrideIndex +} + +function getPriceOverrideExact(...keys: string[]): ModelCosts | null { + for (const key of keys) { + const costs = userPriceOverrides.get(key) + if (costs) return costs + } + return null +} + +function getPriceOverridePrefix(canonical: string): ModelCosts | null { + for (const key of getSortedPriceOverrideKeys()) { + if (canonical.startsWith(key + '-') || canonical === key) { + return userPriceOverrides.get(key)! + } + } + return null +} + +function getPriceOverrideCaseInsensitive(canonical: string, withPrefix: string): ModelCosts | null { + const lowerIndex = getLowercasePriceOverrideIndex() + return lowerIndex.get(canonical.toLowerCase()) ?? lowerIndex.get(withPrefix.toLowerCase()) ?? null +} + +// Deterministic fingerprint of the configured overrides. Used by the daily +// cache to invalidate when override rates change (config values, not the +// per-token ModelCosts, so the empty-override hash stays ''). +export function getPriceOverridesConfigHash(): string { + // Control-char field (0x01) / record (0x02) separators so a value that + // contains a plain delimiter can't forge a hash collision. Matches the + // getLocalModelSavingsConfigHash convention upstream. + const FIELD_SEP = String.fromCharCode(1) + const RECORD_SEP = String.fromCharCode(2) + // The builtin composer overrides participate so that editing + // BUILTIN_PRICE_OVERRIDES in a release invalidates cached daily costs the + // same way a user override does — otherwise days finalized under the old + // composer pricing (Sonnet proxy rates) would never be recomputed. + const builtin = `builtin${FIELD_SEP}${JSON.stringify(BUILTIN_PRICE_OVERRIDES)}` + const keys = Object.keys(userPriceOverridesConfig).sort() + if (keys.length === 0) return builtin + const parts = keys.map(k => { + const rates = userPriceOverridesConfig[k]! + return [ + k, + rates.input, + rates.output, + rates.cacheRead ?? '', + rates.cacheCreation ?? '', + ].join(FIELD_SEP) + }) + return [builtin, ...parts].join(RECORD_SEP) +} + function resolveAlias(model: string): string { if (Object.hasOwn(userAliases, model)) return userAliases[model]! if (Object.hasOwn(BUILTIN_ALIASES, model)) return BUILTIN_ALIASES[model]! + const lowercase = model.toLowerCase() + if (lowercase !== model && Object.hasOwn(BUILTIN_ALIASES, lowercase)) return BUILTIN_ALIASES[lowercase]! return model } function getCanonicalName(model: string): string { @@ -327,14 +509,41 @@ function getCanonicalName(model: string): string { .replace(/^[^/]+\//, '') // strip provider prefix: anthropic/foo -> foo } +// Some models are indexed by LiteLLM under a base id but reported with a +// pricing-equivalent suffix (`:thinking`, `:cloud`, `-TEE`). Strip a known +// suffix so the base entry is found instead of falling through to $0. (#634) +function stripKnownPricingVariantSuffix(model: string): string | null { + const withoutColonSuffix = model.replace(/:(thinking|cloud)$/i, '') + if (withoutColonSuffix !== model) return withoutColonSuffix + + const withoutTeeSuffix = model.replace(/-TEE$/i, '') + if (withoutTeeSuffix !== model) return withoutTeeSuffix + + return null +} + export function getModelCosts(model: string): ModelCosts | null { // Try with provider prefix preserved (azure/gpt-5.4, openrouter/anthropic/claude-opus-4.6) const withPrefix = model.replace(/@.*$/, '').replace(/-\d{8}$/, '') + const canonicalName = getCanonicalName(model) + const canonical = resolveAlias(canonicalName) + + // A user price override is authoritative: it must win over snapshot/LiteLLM + // pricing and over configured aliases. Checked exact-first (raw model, + // prefixed, pre-alias canonical, resolved canonical) before any cache hit. + const override = getPriceOverrideExact(model, withPrefix, canonicalName, canonical) + if (override) return override + if (pricingCache.has(withPrefix)) return pricingCache.get(withPrefix)! - const canonical = resolveAlias(getCanonicalName(model)) if (pricingCache.has(canonical)) return pricingCache.get(canonical)! + // Prefix override before the sorted-key snapshot scan, so `gpt-5` overrides + // `gpt-5-foo` — but only after the exact snapshot checks above, so a more + // specific snapshot entry (`gpt-5-mini`) still wins for its own id. + const prefixOverride = getPriceOverridePrefix(canonical) + if (prefixOverride) return prefixOverride + // Iterate keys longest-first so a model id like `gpt-5-mini` matches the // `gpt-5-mini` entry rather than collapsing to the shorter `gpt-5` entry // due to dictionary insertion order. @@ -344,6 +553,26 @@ export function getModelCosts(model: string): ModelCosts | null { } } + // Case-insensitive override as a last resort before falling through to the + // suffix-variant retry and $0. Never changes an exact/prefix match above. + const caseInsensitiveOverride = getPriceOverrideCaseInsensitive(canonical, withPrefix) + if (caseInsensitiveOverride) return caseInsensitiveOverride + + // Last resort: strip a known pricing-equivalent suffix (`:thinking`, + // `:cloud`, `-TEE`) and re-resolve, so a variant prices like its base + // instead of falling through to $0. (#634) + const withPrefixVariant = stripKnownPricingVariantSuffix(withPrefix) + if (withPrefixVariant && withPrefixVariant !== withPrefix) { + const variantCosts = getModelCosts(withPrefixVariant) + if (variantCosts) return variantCosts + } + + const canonicalVariant = stripKnownPricingVariantSuffix(canonical) + if (canonicalVariant && canonicalVariant !== canonical && canonicalVariant !== withPrefixVariant) { + const variantCosts = getModelCosts(canonicalVariant) + if (variantCosts) return variantCosts + } + return null } @@ -459,6 +688,8 @@ const autoModelNames: Record = { } const SHORT_NAMES: Record = { + // ZCode/Hermes GLM-5.2 resolves to the priced `glm-5` sibling; label it. (#537) + 'glm-5': 'GLM-5', // TEMP (2026-06-09): until deriveClaudeShortName or LiteLLM cover them. (upstream #463) 'claude-fable-5': 'Fable 5', 'claude-mythos-5': 'Mythos 5', @@ -487,6 +718,7 @@ const SHORT_NAMES: Record = { 'gpt-5.4-nano': 'GPT-5.4 Nano', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', + 'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark', 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5.3': 'GPT-5.3', 'gpt-5.2-pro': 'GPT-5.2 Pro', diff --git a/src/optimize.ts b/src/optimize.ts index fb70bd4..12d076b 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -25,12 +25,12 @@ const RED = '#F55B5B' // Token estimation constants // ============================================================================ -const AVG_TOKENS_PER_READ = 600 -const TOKENS_PER_MCP_TOOL = 400 -const TOOLS_PER_MCP_SERVER = 5 -const TOKENS_PER_AGENT_DEF = 80 -const TOKENS_PER_SKILL_DEF = 80 -const TOKENS_PER_COMMAND_DEF = 60 +export const AVG_TOKENS_PER_READ = 600 +export const TOKENS_PER_MCP_TOOL = 400 +export const TOOLS_PER_MCP_SERVER = 5 +export const TOKENS_PER_AGENT_DEF = 80 +export const TOKENS_PER_SKILL_DEF = 80 +export const TOKENS_PER_COMMAND_DEF = 60 const CLAUDEMD_TOKENS_PER_LINE = 13 const BASH_TOKENS_PER_CHAR = 0.25 @@ -47,7 +47,7 @@ const MIN_DUPLICATE_READS_TO_FLAG = 5 const DUPLICATE_READS_HIGH_THRESHOLD = 30 const DUPLICATE_READS_MEDIUM_THRESHOLD = 10 const MIN_EDITS_FOR_RATIO = 10 -const HEALTHY_READ_EDIT_RATIO = 4 +export const HEALTHY_READ_EDIT_RATIO = 4 const LOW_RATIO_HIGH_THRESHOLD = 2 const LOW_RATIO_MEDIUM_THRESHOLD = 3 const MIN_API_CALLS_FOR_CACHE = 10 @@ -149,20 +149,63 @@ const GHOST_CLEANUP_COMMANDS_LIMIT = 10 export type Impact = 'high' | 'medium' | 'low' export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F' +// `destination` marks where a paste fix ultimately belongs so the apply layer +// can route it: 'claude-md' upserts a marker block into the project CLAUDE.md, +// 'shell-config' into the shell rc, 'session-opener'/'prompt' are one-time +// pastes with no appliable target. Absent means an unrouted plain paste. +export type PasteDestination = + | 'claude-md' // permanent project rule, append to CLAUDE.md + | 'session-opener' // one-time paste at the start of a NEW session + | 'prompt' // one-time ask in the current Claude conversation + | 'shell-config' // append to ~/.zshrc / ~/.bashrc + export type WasteAction = - | { type: 'paste'; label: string; text: string } + | { type: 'paste'; label: string; text: string; destination?: PasteDestination } | { type: 'command'; label: string; text: string } | { type: 'file-content'; label: string; path: string; content: string } export type Trend = 'active' | 'improving' +// Stable, kebab-case identifier per detector. Used to route findings to +// appliable plans (src/act/plans.ts) and for the `--only` filter, so these +// strings must not change once shipped. +export type FindingId = + | 'read-edit-ratio' + | 'build-folder-reads' + | 'redundant-rereads' + | 'warmup-heavy' + | 'unused-mcp' + | 'mcp-low-coverage' + | 'mcp-project-scope' + | 'retry-heavy-capabilities' + | 'low-worth-sessions' + | 'context-heavy-sessions' + | 'cost-outliers' + | 'claude-md-too-long' + | 'bash-output-cap' + | 'unused-agents' + | 'unused-skills' + | 'unused-commands' + +// Machine-readable payload the apply layer needs but the human-facing `fix` +// text can't carry losslessly (full lists, per-server keeper paths). Only set +// on findings that have an appliable plan; absent otherwise. +export type FindingApply = + | { kind: 'mcp-remove'; servers: string[] } + // keepProjects gain the entry; removeProjects (the cold projects) are the + // only per-project containers a scoped removal may touch. + | { kind: 'mcp-project-scope'; servers: Array<{ server: string; keepProjects: string[]; removeProjects: string[] }> } + | { kind: 'archive'; names: string[] } + export type WasteFinding = { + id: FindingId title: string explanation: string impact: Impact tokensSaved: number fix: WasteAction trend?: Trend + apply?: FindingApply } export type OptimizeResult = { @@ -172,6 +215,36 @@ export type OptimizeResult = { healthGrade: HealthGrade } +export type OptimizeJsonReport = { + period: { + label: string + start: string | null + end: string | null + } + summary: { + healthScore: number + healthGrade: HealthGrade + findingCount: number + periodCostUSD: number + sessions: number + calls: number + potentialSavingsTokens: number + potentialSavingsCostUSD: number + potentialSavingsPercent: number | null + costRateUSD: number + } + findings: Array<{ + id: FindingId + title: string + explanation: string + severity: Impact + trend: Trend | null + tokensSaved: number + estimatedSavingsUSD: number + fix: WasteAction + }> +} + export type ToolCall = { name: string input: Record @@ -492,6 +565,7 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste const dirsToAvoid = [...detected, ...extras].join(', ') return { + id: 'build-folder-reads', title: 'Claude is reading build/dependency folders', explanation: `Claude read into ${dirList} (${totalJunkReads} reads). These are generated or dependency directories, not your code. Tell Claude in CLAUDE.md to avoid them.`, impact: totalJunkReads > JUNK_READS_HIGH_THRESHOLD ? 'high' : totalJunkReads > JUNK_READS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -551,6 +625,7 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ return { + id: 'redundant-rereads', title: 'Claude is re-reading the same files', explanation: `${totalDuplicates} redundant re-reads across sessions. Top repeats: ${worst}. Each re-read loads the same content into context again.`, impact: totalDuplicates > DUPLICATE_READS_HIGH_THRESHOLD ? 'high' : totalDuplicates > DUPLICATE_READS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -865,6 +940,7 @@ export function detectMcpToolCoverage( : 'medium' return { + id: 'mcp-low-coverage', title: `${flagged.length} MCP server${flagged.length === 1 ? '' : 's'} with low tool coverage`, explanation: `Schema for unused tools is loaded into the system prompt every session and ` + @@ -879,6 +955,7 @@ export function detectMcpToolCoverage( : 'Remove underused servers, or trim their tools in your MCP config:', text: removeCommands.join('\n'), }, + apply: { kind: 'mcp-remove', servers: flaggedServers }, } } @@ -935,10 +1012,12 @@ export function detectUnusedMcp( const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1) return { + id: 'unused-mcp', title: `${unused.length} MCP server${unused.length > 1 ? 's' : ''} configured but never used`, explanation: `Never called in this period: ${unused.join(', ')}. Each server loads ~${TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL} tokens of tool schema into every session.`, impact: unused.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium', tokensSaved, + apply: { kind: 'mcp-remove', servers: unused }, fix: { type: 'command', label: `Remove unused server${unused.length > 1 ? 's' : ''}:`, @@ -998,6 +1077,7 @@ export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | }).join(', ') return { + id: 'claude-md-too-long', title: `Your CLAUDE.md is too long`, explanation: `${list}. CLAUDE.md plus all @-imported files load into every API call. Trimming below ${CLAUDEMD_HEALTHY_LINES} lines saves ~${formatTokens(tokensSaved)} tokens per call.`, impact: worst.expandedLines > CLAUDEMD_HIGH_THRESHOLD_LINES ? 'high' : 'medium', @@ -1010,8 +1090,8 @@ export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | } } -const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) -const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit']) +export const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) +export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit']) export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { let reads = 0 @@ -1045,6 +1125,7 @@ export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { if (trend === 'resolved') return null return { + id: 'read-edit-ratio', title: 'Claude edits more than it reads', explanation: `Claude made ${reads} reads and ${edits} edits (ratio ${ratio.toFixed(1)}:1). A healthy ratio is ${HEALTHY_READ_EDIT_RATIO}+ reads per edit. Editing without reading leads to retries and wasted tokens.`, impact, @@ -1115,6 +1196,7 @@ export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSumma } return { + id: 'warmup-heavy', title: 'Session warmup is unusually large', explanation: `Median cache_creation per call is ${formatTokens(median)} tokens, about ${formatTokens(excess)} above your baseline of ${formatTokens(baseline)}.${versionNote}`, impact: excess > CACHE_EXCESS_HIGH_THRESHOLD ? 'high' : 'medium', @@ -1166,6 +1248,7 @@ export async function detectGhostAgents(calls: ToolCall[]): Promise GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') return { + id: 'unused-agents', title: `${ghosts.length} custom agent${ghosts.length > 1 ? 's' : ''} you never use`, explanation: `Defined in ~/.claude/agents/ but never invoked in this period: ${list}. Each adds ~${TOKENS_PER_AGENT_DEF} tokens to the Task tool schema on every session.`, impact: ghosts.length >= GHOST_AGENTS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_AGENTS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -1175,6 +1258,7 @@ export async function detectGhostAgents(calls: ToolCall[]): Promise 1 ? 's' : ''}:`, text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/agents/${name}.md ~/.claude/agents/.archived/`).join('\n'), }, + apply: { kind: 'archive', names: ghosts }, } } @@ -1196,6 +1280,7 @@ export async function detectGhostSkills(calls: ToolCall[]): Promise GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') return { + id: 'unused-skills', title: `${ghosts.length} skill${ghosts.length > 1 ? 's' : ''} you never use`, explanation: `In ~/.claude/skills/ but not invoked this period: ${list}. Each adds ~${TOKENS_PER_SKILL_DEF} tokens of metadata to every session.`, impact: ghosts.length >= GHOST_SKILLS_HIGH_THRESHOLD ? 'high' : ghosts.length >= GHOST_SKILLS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -1205,6 +1290,7 @@ export async function detectGhostSkills(calls: ToolCall[]): Promise 1 ? 's' : ''}:`, text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/skills/${name} ~/.claude/skills/.archived/`).join('\n'), }, + apply: { kind: 'archive', names: ghosts }, } } @@ -1228,6 +1314,7 @@ export async function detectGhostCommands(userMessages: string[]): Promise GHOST_NAMES_PREVIEW ? `, +${ghosts.length - GHOST_NAMES_PREVIEW} more` : '') return { + id: 'unused-commands', title: `${ghosts.length} slash command${ghosts.length > 1 ? 's' : ''} you never use`, explanation: `In ~/.claude/commands/ but not referenced this period: ${list}. Each adds ~${TOKENS_PER_COMMAND_DEF} tokens of definition per session.`, impact: ghosts.length >= GHOST_COMMANDS_MEDIUM_THRESHOLD ? 'medium' : 'low', @@ -1237,6 +1324,7 @@ export async function detectGhostCommands(userMessages: string[]): Promise 1 ? 's' : ''}:`, text: ghosts.slice(0, GHOST_CLEANUP_COMMANDS_LIMIT).map(name => `mv ~/.claude/commands/${name}.md ~/.claude/commands/.archived/`).join('\n'), }, + apply: { kind: 'archive', names: ghosts }, } } @@ -1264,12 +1352,14 @@ export function detectBashBloat(): WasteFinding | null { const tokensSaved = Math.round(extraChars * BASH_TOKENS_PER_CHAR) return { + id: 'bash-output-cap', title: 'Shrink bash output limit', explanation: `Your bash output cap is ${(limit / 1000).toFixed(0)}K chars (${configured ? 'configured' : 'default'}). Most output fits in ${(BASH_RECOMMENDED_LIMIT / 1000).toFixed(0)}K. The extra ~${formatTokens(tokensSaved)} tokens per bash call is trailing noise.`, impact: 'medium', tokensSaved, fix: { type: 'paste', + destination: 'shell-config', label: 'Add to ~/.zshrc or ~/.bashrc:', text: `export BASH_MAX_OUTPUT_LENGTH=${BASH_RECOMMENDED_LIMIT}`, }, @@ -1455,6 +1545,7 @@ export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding } return { + id: 'low-worth-sessions', title: `${candidates.length} possibly low-worth expensive session${candidates.length === 1 ? '' : 's'}`, explanation: `Sessions with meaningful spend but weak delivery signals: ${list}${extra}. This is a review candidate, not proof of waste: CodeBurn flags missing edit turns, repeated retries, and sessions without git delivery commands so you can decide whether the work was worth its cost before it becomes a habit.`, impact, @@ -1567,6 +1658,7 @@ export function detectContextBloat(projects: ProjectSummary[], excludedSessionId } return { + id: 'context-heavy-sessions', title: `${candidates.length} context-heavy session${candidates.length === 1 ? '' : 's'}`, explanation: `Effective input/cache tokens swamp output in these sessions: ${list}${extra}. This can come from stale context carryover, inherently context-heavy work, or abandoned runs that loaded too much context; starting fresh with only the current goal and relevant files can cut repeated prompt overhead.`, impact, @@ -1636,6 +1728,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio const totalExcessCost = outliers.reduce((sum, o) => sum + Math.max(0, o.cost - o.avgCost), 0) return { + id: 'cost-outliers', title: `${outliers.length} high-cost session outlier${outliers.length === 1 ? '' : 's'}`, explanation: `Sessions costing more than ${SESSION_OUTLIER_MULTIPLIER}x their peer-session average in the same project: ${list}${extra}. These usually come from broad prompts, runaway loops, or context-heavy work that should be split into smaller sessions.`, impact: outliers.length >= 3 || totalExcessCost >= 10 ? 'high' : 'medium', @@ -1724,7 +1817,7 @@ function sessionTrend( const INPUT_COST_RATIO = 0.7 const DEFAULT_COST_PER_TOKEN = 0 -function computeInputCostRate(projects: ProjectSummary[]): number { +export function computeInputCostRate(projects: ProjectSummary[]): number { const sessions = projects.flatMap(p => p.sessions) const totalCost = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0) const totalTokens = sessions.reduce((s, sess) => @@ -1875,6 +1968,8 @@ function renderOptimize( callCount: number, healthScore: number, healthGrade: HealthGrade, + appliedHeader?: string, + previouslyApplied?: Record, ): string { const lines: string[] = [] lines.push('') @@ -1889,6 +1984,9 @@ function renderOptimize( chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(healthDetail)}`, ].join(chalk.hex(DIM)(' '))) + // Realized-savings line from applied actions (act report). Best-effort: + // absent when nothing has been applied or the journal is empty. + if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader)) lines.push('') if (findings.length === 0) { @@ -1911,7 +2009,10 @@ function renderOptimize( lines.push('') for (let i = 0; i < findings.length; i++) { - lines.push(...renderFinding(i + 1, findings[i], costRate)) + const f = findings[i]! + const appliedOn = previouslyApplied?.[f.id] + const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f + lines.push(...renderFinding(i + 1, shown, costRate)) } lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) @@ -1924,19 +2025,75 @@ export async function runOptimize( projects: ProjectSummary[], periodLabel: string, dateRange?: DateRange, + opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record } = {}, ): Promise { - if (projects.length === 0) { + const format = opts.format ?? 'text' + if (projects.length === 0 && format === 'text') { console.log(chalk.dim('\n No usage data found for this period.\n')) return } - process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + if (format === 'text') { + process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + } - const { findings, costRate, healthScore, healthGrade } = await scanAndDetect(projects, dateRange) + const result = await scanAndDetect(projects, dateRange) + const { findings, costRate, healthScore, healthGrade } = result const sessions = projects.flatMap(p => p.sessions) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade) + if (format === 'json') { + console.log(JSON.stringify(buildOptimizeJsonReport(projects, periodLabel, result, dateRange), null, 2)) + return + } + + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, opts.appliedHeader, opts.previouslyApplied) console.log(output) } + +export function buildOptimizeJsonReport( + projects: ProjectSummary[], + periodLabel: string, + result: OptimizeResult, + dateRange?: DateRange, +): OptimizeJsonReport { + const sessions = projects.flatMap(p => p.sessions) + const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const calls = projects.reduce((s, p) => s + p.totalApiCalls, 0) + const potentialSavingsTokens = result.findings.reduce((s, f) => s + f.tokensSaved, 0) + const potentialSavingsCostUSD = potentialSavingsTokens * result.costRate + const potentialSavingsPercent = periodCostUSD > 0 + ? Math.round((potentialSavingsCostUSD / periodCostUSD) * 1000) / 10 + : null + + return { + period: { + label: periodLabel, + start: dateRange?.start.toISOString() ?? null, + end: dateRange?.end.toISOString() ?? null, + }, + summary: { + healthScore: result.healthScore, + healthGrade: result.healthGrade, + findingCount: result.findings.length, + periodCostUSD, + sessions: sessions.length, + calls, + potentialSavingsTokens, + potentialSavingsCostUSD, + potentialSavingsPercent, + costRateUSD: result.costRate, + }, + findings: result.findings.map(f => ({ + id: f.id, + title: f.title, + explanation: f.explanation, + severity: f.impact, + trend: f.trend ?? null, + tokensSaved: f.tokensSaved, + estimatedSavingsUSD: f.tokensSaved * result.costRate, + fix: f.fix, + })), + } +} diff --git a/src/overview.ts b/src/overview.ts new file mode 100644 index 0000000..5405fb0 --- /dev/null +++ b/src/overview.ts @@ -0,0 +1,237 @@ +import { Chalk, type ChalkInstance } from 'chalk' + +import { homedir } from 'os' + +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' +import { formatCost as baseCost } from './currency.js' +import { formatTokens as baseTokens } from './format.js' +import { getShortModelName } from './models.js' +import { dateKey } from './day-aggregator.js' + +// Display-only helpers. The shared formatters omit thousands separators and stop +// at M; aggregation uses raw numbers, these only affect rendering. +function formatCost(usd: number): string { + return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') +} +function formatTokens(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B` + return baseTokens(n) +} +function projectName(p: ProjectSummary): string { + const path = p.projectPath + if (path) { + if (path === homedir()) return 'Home' + const base = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() + if (base) return base + } + return p.project.split('-').filter(Boolean).pop() || p.project +} + +type Col = { header: string; right?: boolean } + +// Visible width, ignoring ANSI color codes, so padding stays aligned. +function vlen(s: string): number { + // eslint-disable-next-line no-control-regex + return s.replace(/\[[0-9;]*m/g, '').length +} + +function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string { + const widths = cols.map((col, i) => + Math.max(vlen(col.header), ...rows.map((r) => vlen(r[i] ?? ''))), + ) + const pad = (s: string, w: number, right?: boolean): string => { + const fill = ' '.repeat(Math.max(0, w - vlen(s))) + return right ? fill + s : s + fill + } + const sep = ' ' + c.dim('│') + ' ' + const edge = c.dim('│') + const bar = (l: string, mid: string, r: string): string => + c.dim(l + widths.map((w) => '─'.repeat(w + 2)).join(mid) + r) + const line = (cells: string[], header = false): string => + edge + ' ' + cells.map((cell, i) => { + const padded = pad(cell, widths[i]!, cols[i]!.right) + return header ? c.bold(padded) : padded + }).join(sep) + ' ' + edge + return [ + bar('┌', '┬', '┐'), + line(cols.map((col) => col.header), true), + bar('├', '┼', '┤'), + ...rows.map((r) => line(r)), + bar('└', '┴', '┘'), + ].join('\n') +} + +export function renderOverview( + projects: ProjectSummary[], + opts: { label: string; color: boolean }, +): string { + const c = new Chalk(opts.color ? {} : { level: 0 }) + const heading = (text: string): string => c.cyan.bold(text) + const out: string[] = [] + + out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label)) + out.push('') + + if (projects.length === 0) { + out.push(c.dim(`No usage found for ${opts.label}.`)) + return out.join('\n') + '\n' + } + + let cost = 0, calls = 0, sessions = 0 + let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0 + const byProvider = new Map() + const byModel = new Map() + const byCat = new Map() + const byTool = new Map() + const byDay = new Map }>() + const byProject = new Map() + + for (const p of projects) { + cost += p.totalCostUSD + calls += p.totalApiCalls + sessions += p.sessions.length + const pname = projectName(p) + const pe = byProject.get(pname) ?? { cost: 0, sessions: 0 } + pe.cost += p.totalCostUSD + pe.sessions += p.sessions.length + byProject.set(pname, pe) + for (const s of p.sessions) { + inTok += s.totalInputTokens + outTok += s.totalOutputTokens + cacheR += s.totalCacheReadTokens + cacheW += s.totalCacheWriteTokens + for (const [m, d] of Object.entries(s.modelBreakdown)) { + const e = byModel.get(m) ?? { cost: 0, calls: 0, tokens: 0 } + e.cost += d.costUSD + e.calls += d.calls + e.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens + byModel.set(m, e) + } + for (const [cat, d] of Object.entries(s.categoryBreakdown)) { + const e = byCat.get(cat) ?? { cost: 0, turns: 0 } + e.cost += d.costUSD + e.turns += d.turns + byCat.set(cat, e) + } + for (const [tool, d] of Object.entries(s.toolBreakdown)) { + byTool.set(tool, (byTool.get(tool) ?? 0) + d.calls) + } + for (const t of s.turns) { + const day = dateKey(t.timestamp || t.assistantCalls[0]?.timestamp || '') + for (const call of t.assistantCalls) { + const tk = call.usage.inputTokens + call.usage.outputTokens + call.usage.cacheReadInputTokens + call.usage.cacheCreationInputTokens + const pv = byProvider.get(call.provider) ?? { cost: 0, tokens: 0 } + pv.cost += call.costUSD + pv.tokens += tk + byProvider.set(call.provider, pv) + if (day) { + const dd = byDay.get(day) ?? { cost: 0, tokens: 0, providers: new Set() } + dd.cost += call.costUSD + dd.tokens += tk + dd.providers.add(call.provider) + byDay.set(day, dd) + } + } + } + } + } + + const totalTokens = inTok + outTok + cacheR + cacheW + const cacheHitDenom = inTok + cacheR + const cacheHit = cacheHitDenom > 0 ? (cacheR / cacheHitDenom) * 100 : 0 + + // Totals + out.push(heading('Totals')) + const kv = (k: string, v: string): string => ' ' + c.dim(k.padEnd(11)) + v + out.push(kv('Cost', c.bold(formatCost(cost)))) + out.push(kv('Tokens', formatTokens(totalTokens) + c.dim(` in ${formatTokens(inTok)} / out ${formatTokens(outTok)} / cache-w ${formatTokens(cacheW)} / cache-r ${formatTokens(cacheR)}`))) + out.push(kv('Calls', calls.toLocaleString() + c.dim(' sessions ') + sessions.toLocaleString())) + out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`)) + out.push('') + + // By tool (provider) + const providerRows = [...byProvider.entries()] + .filter(([, v]) => v.cost > 0 || v.tokens > 0) + .sort((a, b) => b[1].cost - a[1].cost) + if (providerRows.length) { + out.push(heading('By tool')) + out.push(renderTable(c, + [{ header: 'Tool' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Share', right: true }], + providerRows.map(([name, v]) => [name, formatCost(v.cost), formatTokens(v.tokens), cost > 0 ? `${Math.round((v.cost / cost) * 100)}%` : '0%']), + )) + out.push('') + } + + // Top models + const modelRows = [...byModel.entries()].filter(([, v]) => v.cost > 0 || v.tokens > 0).sort((a, b) => b[1].cost - a[1].cost).slice(0, 10) + if (modelRows.length) { + out.push(heading('Top models')) + out.push(renderTable(c, + [{ header: 'Model' }, { header: 'Cost', right: true }, { header: 'Calls', right: true }, { header: 'Tokens', right: true }], + modelRows.map(([m, v]) => [getShortModelName(m), formatCost(v.cost), v.calls.toLocaleString(), formatTokens(v.tokens)]), + )) + out.push('') + } + + // Highest-value days + const topDays = [...byDay.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 5) + if (topDays.length) { + out.push(heading('Highest-value days')) + out.push(renderTable(c, + [{ header: '#' }, { header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }], + topDays.map(([d, v], i) => [String(i + 1), d, formatCost(v.cost), formatTokens(v.tokens)]), + )) + out.push('') + } + + // Top projects + const projRows = [...byProject.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10) + if (projRows.length) { + out.push(heading('Top projects')) + out.push(renderTable(c, + [{ header: 'Project' }, { header: 'Cost', right: true }, { header: 'Sessions', right: true }], + projRows.map(([name, v]) => [name, formatCost(v.cost), v.sessions.toLocaleString()]), + )) + out.push('') + } + + // Daily + const dailyRows = [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + if (dailyRows.length) { + out.push(heading('Daily')) + out.push(renderTable(c, + [{ header: 'Date' }, { header: 'Cost', right: true }, { header: 'Tokens', right: true }, { header: 'Providers' }], + dailyRows.map(([d, v]) => [d, formatCost(v.cost), formatTokens(v.tokens), [...v.providers].sort().join(', ')]), + )) + out.push('') + } + + // By activity + const catRows = [...byCat.entries()].filter(([, v]) => v.cost > 0 || v.turns > 0).sort((a, b) => b[1].cost - a[1].cost) + if (catRows.length) { + out.push(heading('By activity')) + out.push(renderTable(c, + [{ header: 'Activity' }, { header: 'Cost', right: true }, { header: 'Turns', right: true }], + catRows.map(([cat, v]) => [CATEGORY_LABELS[cat as TaskCategory] ?? cat, formatCost(v.cost), v.turns.toLocaleString()]), + )) + out.push('') + } + + // Tools + const toolRows = [...byTool.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12) + if (toolRows.length) { + out.push(heading('Tools')) + out.push(renderTable(c, + [{ header: 'Tool' }, { header: 'Calls', right: true }], + toolRows.map(([t, n]) => [t, n.toLocaleString()]), + )) + out.push('') + } + + const topTool = providerRows[0]?.[0] + const topModel = modelRows[0] ? getShortModelName(modelRows[0][0]) : '' + const mostly = topTool ? `, mostly ${topTool}${topModel ? ` / ${topModel}` : ''}` : '' + out.push(c.dim('Bottom line: ') + `${opts.label} totals ${formatCost(cost)} across ${formatTokens(totalTokens)} tokens${mostly}.`) + + return out.join('\n') + '\n' +} diff --git a/src/parser.ts b/src/parser.ts index 0af7e3f..8550224 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -226,7 +226,7 @@ function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): { } } -function parseApiCall(entry: JournalEntry): ParsedApiCall | null { +export function parseApiCall(entry: JournalEntry): ParsedApiCall | null { if (entry.type !== 'assistant') return null const msg = entry.message as AssistantMessageContent | undefined if (!msg?.usage || !msg?.model) return null @@ -301,7 +301,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { } } -function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { +export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { const firstIdxById = new Map() const lastIdxById = new Map() for (let i = 0; i < entries.length; i++) { diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 63bc317..7f12db8 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -15,6 +15,7 @@ const modelDisplayNames: Record = { 'gpt-5.5': 'GPT-5.5', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', + 'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark', 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5.2-low': 'GPT-5.2 Low', 'gpt-5.2': 'GPT-5.2', @@ -450,6 +451,16 @@ function processCodexEntry(state: CodexParserState, entry: CodexEntry, source: S state.pendingTools.push('Edit') return } + // Recent Codex emits MCP calls as `event_msg`/`mcp_tool_call_end` instead of a + // `function_call` response_item, so the call was never attributed. Rebuild the + // canonical `mcp____` name the classifier recognizes. (#513) + if (entry.type === 'event_msg' && payloadType === 'mcp_tool_call_end') { + const inv = (entry.payload as Record)['invocation'] as Record | undefined + const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : '' + const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : '' + if (server && tool) state.pendingTools.push(`mcp__${server}__${tool}`) + return + } if (entry.type === 'response_item' && payloadType === 'message' && entry.payload?.role === 'user') { handleUserMessage(state, entry) return diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts index 681b303..b091370 100644 --- a/src/providers/cursor-agent.ts +++ b/src/providers/cursor-agent.ts @@ -44,6 +44,7 @@ const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i const USER_QUERY_OPEN = '' const USER_QUERY_CLOSE = '' +const warnedUnrecognizedTranscripts = new Set() const CONVERSATION_SUMMARY_QUERY = ` SELECT conversationId, model, title, updatedAt FROM conversation_summaries @@ -360,7 +361,10 @@ function createParser( const parsed = isJsonl ? parseJsonlTranscript(transcript) : parseTranscript(transcript) if (!parsed.recognized) { - process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`) + if (!warnedUnrecognizedTranscripts.has(source.path)) { + warnedUnrecognizedTranscripts.add(source.path) + process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`) + } return } diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 105c261..ec1b9c1 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -37,6 +37,9 @@ type BubbleRow = { text_length: number | null bubble_type: number | null code_blocks: Uint8Array | string | null + /// Only populated on the paged scan path (BUBBLE_QUERY_PAGE) used for very + /// large databases; undefined on the un-paged BUBBLE_QUERY_SINCE path. + rid?: number } type AgentKvRow = { @@ -128,6 +131,26 @@ const AGENTKV_QUERY = ` ORDER BY ROWID ASC ` +// Cursor leaves the per-bubble tokenCount at {0,0} on current builds, so the +// text-length estimate that follows systematically undercounts the real prompt +// (a bubble's visible text is a small fraction of the context window actually +// sent). The one real input figure on disk is Cursor's own context meter, +// recorded per conversation in composerData.promptTokenBreakdown.totalUsedTokens +// or, on older builds, contextTokensUsed. It is the LATEST context-window +// snapshot for the conversation, not a per-turn sum, so we credit it exactly +// ONCE per conversation (see the synthetic cursor:composer-input: record in +// parseBubbles) rather than per bubble. The key-range predicate seeks the +// composerData primary-key span instead of scanning the whole table. +const COMPOSER_META_QUERY = ` + SELECT + substr(key, length('composerData:') + 1) as composer_id, + json_extract(value, '$.promptTokenBreakdown.totalUsedTokens') as used, + json_extract(value, '$.contextTokensUsed') as ctx, + json_extract(value, '$.createdAt') as created_at + FROM cursorDiskKV + WHERE key >= 'composerData:' AND key < 'composerData;' +` + const USER_MESSAGES_QUERY = ` SELECT json_extract(value, '$.conversationId') as conversation_id, @@ -152,6 +175,29 @@ const BUBBLE_QUERY_SINCE_TAIL = ` ` const BUBBLE_QUERY_SINCE = BUBBLE_QUERY_SINCE_HEAD + BUBBLE_QUERY_SINCE_TAIL +// Paged variant for very large DBs: fetches one ROWID-descending page below a +// cursor. Returns ROWID and createdAt so the caller can stop once it has paged +// past the requested window floor. No date predicate here — the caller filters +// by createdAt in JS so it can see the window boundary. +const BUBBLE_QUERY_PAGE = ` + SELECT + key as bubble_key, + ROWID as rid, + json_extract(value, '$.tokenCount.inputTokens') as input_tokens, + json_extract(value, '$.tokenCount.outputTokens') as output_tokens, + json_extract(value, '$.modelInfo.modelName') as model, + json_extract(value, '$.createdAt') as created_at, + json_extract(value, '$.conversationId') as conversation_id, + CAST(substr(json_extract(value, '$.text'), 1, 500) AS BLOB) as user_text, + length(json_extract(value, '$.text')) as text_length, + json_extract(value, '$.type') as bubble_type, + CAST(json_extract(value, '$.codeBlocks') AS BLOB) as code_blocks + FROM cursorDiskKV + WHERE key LIKE 'bubbleId:%' AND ROWID < ? + ORDER BY ROWID DESC + LIMIT ? +` + function validateSchema(db: SqliteDatabase): boolean { try { const rows = db.query<{ cnt: number }>( @@ -200,6 +246,76 @@ function takeUserMessage(queues: Map, conversationId: return msg } +/// Real per-conversation context-token figure from Cursor's context meter, +/// plus the conversation's own createdAt (epoch ms) so the synthetic input +/// record can be anchored to the conversation's start rather than the parse +/// time — that keeps the daily-cache bucket deterministic across re-parses. +type ComposerMeta = { tokens: number; createdAt: number | null } + +function loadComposerMeta(db: SqliteDatabase): Map { + const map = new Map() + try { + const rows = db.query<{ composer_id: string; used: number | null; ctx: number | null; created_at: number | null }>( + COMPOSER_META_QUERY + ) + for (const r of rows) { + // `||` (not `??`): a recorded-but-zero promptTokenBreakdown must fall + // through to the context meter rather than shadowing it at 0. + const tokens = (r.used || r.ctx) ?? 0 + if (r.composer_id && tokens > 0) { + map.set(r.composer_id, { tokens, createdAt: r.created_at ?? null }) + } + } + } catch { + /* best-effort: callers fall back to the per-bubble text estimate */ + } + return map +} + +/// Scans bubbles for very large DBs by paging ROWID-descending (newest first), +/// keeping only rows within the requested window (createdAt > timeFloor), and +/// stopping once a full page lands below the floor. A `budget` caps the number +/// of in-range bubbles collected so a genuinely enormous in-range scan can't +/// stall; `truncated` is set only when that budget is actually hit, so the +/// caller warns only when older in-range sessions were really dropped. +function scanBubblesPaged( + db: SqliteDatabase, + timeFloor: string, + budget: number, +): { rows: BubbleRow[]; truncated: boolean } { + const BATCH = 25_000 + const collected: BubbleRow[] = [] + let beforeRowId = Number.MAX_SAFE_INTEGER + let truncated = false + + paging: while (true) { + let batch: BubbleRow[] + try { + batch = db.query(BUBBLE_QUERY_PAGE, [beforeRowId, BATCH]) + } catch { + break + } + if (batch.length === 0) break + + for (const row of batch) { + if (collected.length >= budget) { truncated = true; break paging } + if (row.created_at != null && row.created_at > timeFloor) collected.push(row) + } + + const oldest = batch[batch.length - 1]! + beforeRowId = oldest.rid ?? 0 + if (beforeRowId <= 0) break + if (batch.length < BATCH) break // exhausted the table + // Pages are ROWID-descending (~chronological), so once the oldest row in a + // full page predates the window, every older page does too. + if (oldest.created_at != null && oldest.created_at <= timeFloor) break + } + + // Restore ROWID-ascending order to match the un-paged query's row ordering. + collected.sort((a, b) => (a.rid ?? 0) - (b.rid ?? 0)) + return { rows: collected, truncated } +} + function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: ParsedProviderCall[] } { const results: ParsedProviderCall[] = [] let skipped = 0 @@ -207,79 +323,111 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse const LOOKBACK_DAYS = 180 const timeFloor = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString() - // Hard cap on rows to scan. The BUBBLE_QUERY_SINCE filter relies on - // json_extract over the value BLOB, which SQLite cannot serve from an - // index — every row is JSON-decoded. Multi-GB Cursor DBs (power users, - // years of usage) regularly exceed 500k bubble rows and were producing - // 30s+ parse stalls. Compute a ROWID cutoff that limits the scan to the - // MAX_BUBBLES most-recent bubbles when the user is over the cap, and - // warn so they know older sessions may be missing. - const MAX_BUBBLES = 250_000 - let rowIdCutoff = 0 + // The bubble timestamp lives inside the JSON value (no index), so the date + // filter forces a full JSON decode per row. Multi-GB Cursor DBs (500k+ + // bubbles) were producing 30s+ parse stalls, so the scan is bounded. The old + // approach kept only the most-recent MAX_BUBBLES by ROWID, which dropped + // in-range older sessions and warned even when the requested window fit + // comfortably. Instead, for large DBs we page the requested window + // (ROWID-descending, stopping past the window floor) and only fall back to a + // hard budget — warning — when the in-range scan genuinely exceeds it. + // Override the budget in tests via CODEBURN_CURSOR_MAX_BUBBLES. + const MAX_BUBBLES = Number(process.env['CODEBURN_CURSOR_MAX_BUBBLES']) || 250_000 + + let total = 0 try { const countRows = db.query<{ cnt: number }>( "SELECT COUNT(*) as cnt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'" ) - const total = countRows[0]?.cnt ?? 0 - if (total > MAX_BUBBLES) { - // Find the ROWID of the (MAX_BUBBLES)th most-recent bubble. Anything - // below this rowid is older and gets skipped. Bubbles are written - // chronologically so ROWID order ≈ insertion order. - const cutoffRows = db.query<{ rid: number }>( - `SELECT MIN(rid) as rid FROM ( - SELECT ROWID as rid FROM cursorDiskKV - WHERE key LIKE 'bubbleId:%' - ORDER BY ROWID DESC - LIMIT ? - )`, - [MAX_BUBBLES] - ) - rowIdCutoff = cutoffRows[0]?.rid ?? 0 - process.stderr.write( - `codeburn: Cursor database has ${total.toLocaleString()} bubbles, ` + - `scanning the most recent ${MAX_BUBBLES.toLocaleString()}. ` + - `Older sessions may be missing from this report.\n` - ) - } - } catch { /* best-effort diagnostic */ } + total = countRows[0]?.cnt ?? 0 + } catch { /* best-effort */ } const userMessages = buildUserMessageMap(db, timeFloor) - - // Append the rowid cutoff when active. Empty string when not capped so the - // query string compares identically to the un-capped version on small DBs. - const rowIdFilter = rowIdCutoff > 0 ? ' AND ROWID >= ?' : '' - const params: unknown[] = rowIdCutoff > 0 ? [timeFloor, rowIdCutoff] : [timeFloor] - const cappedQuery = BUBBLE_QUERY_SINCE_HEAD + rowIdFilter + BUBBLE_QUERY_SINCE_TAIL + const composerMeta = loadComposerMeta(db) let rows: BubbleRow[] try { - rows = db.query(cappedQuery, params) + if (total > MAX_BUBBLES) { + const scan = scanBubblesPaged(db, timeFloor, MAX_BUBBLES) + rows = scan.rows + if (scan.truncated) { + process.stderr.write( + `codeburn: Cursor database has ${total.toLocaleString()} bubbles and the ` + + `requested range exceeds the ${MAX_BUBBLES.toLocaleString()}-bubble scan budget; ` + + `the oldest sessions in range may be missing from this report.\n` + ) + } + } else { + rows = db.query(BUBBLE_QUERY_SINCE, [timeFloor]) + } } catch { return { calls: results } } + // Pre-scan the fetched rows (no extra table scan) for the per-conversation + // facts the composer-meter crediting needs: + // firstBubbleTs - earliest bubble timestamp, the fallback anchor + // when composerData.createdAt is absent. + // convModel - a model seen on the conversation's bubbles; user + // (type=1) bubbles carry no modelInfo. + // meteredConversations - conversations whose real context tokens (Cursor's + // meter) will be credited once, below, via a + // synthetic cursor:composer-input: record. A + // conversation is metered iff it has a composerData + // meter AND at least one in-range bubble, so the + // credit always lands on a real, dated conversation. + // For these we suppress the per-bubble text estimate + // on the INPUT side so the meter figure is not + // double-counted against the visible-text estimate. + const firstBubbleTs = new Map() + const convModel = new Map() + const meteredConversations = new Set() + for (const row of rows) { + const cid = row.conversation_id ?? 'unknown' + if (row.created_at) { + const prev = firstBubbleTs.get(cid) + if (!prev || row.created_at < prev) firstBubbleTs.set(cid, row.created_at) + } + if (!convModel.has(cid) && row.model) convModel.set(cid, row.model) + if (composerMeta.has(cid)) meteredConversations.add(cid) + } + for (const row of rows) { try { + const createdAt = row.created_at ?? '' + // Bubbles without a createdAt timestamp were defaulting to new Date() + // downstream, misattributing historical or undated usage to Today and + // inflating the daily chart. Skip them outright. Ports upstream #321. + if (!createdAt) continue + const conversationId = row.conversation_id ?? 'unknown' + // Does this conversation have a real context-token figure on disk? If so + // its input is credited once, after the loop, from Cursor's meter — not + // per bubble from visible text. + const hasMeter = composerMeta.has(conversationId) + let inputTokens = row.input_tokens ?? 0 let outputTokens = row.output_tokens ?? 0 - // Cursor v3 stores zero token counts — estimate from text length + // Cursor's current builds store zero per-bubble token counts. Estimate + // output from reply text as before. For input, prefer the conversation's + // real context meter (credited once below) over the text estimate: + // when a meter exists, mark the conversation and do NOT bill per-bubble + // input text here. if (inputTokens === 0 && outputTokens === 0) { const textLen = row.text_length ?? 0 - if (textLen === 0) continue if (row.bubble_type === 1) { + // Input is credited once from the meter (below); this user bubble + // contributes nothing on its own, so drop it rather than emitting a + // $0 call or double-counting via the text estimate. + if (hasMeter) continue + if (textLen === 0) continue inputTokens = Math.ceil(textLen / CHARS_PER_TOKEN) } else { + if (textLen === 0) continue outputTokens = Math.ceil(textLen / CHARS_PER_TOKEN) } } - const createdAt = row.created_at ?? '' - // Bubbles without a createdAt timestamp were defaulting to new Date() - // downstream, misattributing historical or undated usage to Today and - // inflating the daily chart. Skip them outright. Ports upstream #321. - if (!createdAt) continue - const conversationId = row.conversation_id ?? 'unknown' // Use the SQLite row key (bubbleId:) as the dedup key. // Cursor mutates token counts on the row in place when streaming // completes — including tokens in the dedup key (the previous @@ -329,6 +477,63 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse } } + // Credit each metered conversation's REAL context tokens exactly once, via a + // synthetic conversation-level input record. Cursor's meter + // (promptTokenBreakdown.totalUsedTokens / contextTokensUsed) is the LATEST + // context-window size, not a per-turn sum, so crediting it once per + // conversation — rather than per bubble — is the honest figure and never + // double counts. The record is: + // - anchored to composerData.createdAt (falling back to the conversation's + // first in-range bubble timestamp) so the daily-cache bucket is + // deterministic across re-parses and cache gap-fills, independent of the + // parse window; and + // - keyed cursor:composer-input: so a re-parse or an overlapping + // daily-cache range dedupes on it instead of multiplying the credit. + for (const conversationId of meteredConversations) { + const meta = composerMeta.get(conversationId) + const inputTokens = meta?.tokens ?? 0 + if (inputTokens <= 0) continue + + const dedupKey = `cursor:composer-input:${conversationId}` + if (seenKeys.has(dedupKey)) continue + + const createdAtMs = meta?.createdAt + const timestamp = typeof createdAtMs === 'number' && createdAtMs > 0 + ? new Date(createdAtMs).toISOString() + : firstBubbleTs.get(conversationId) ?? '' + // No anchor (composerData.createdAt absent and no dated bubble in range) — + // skip rather than default to now and misattribute to Today. + if (!timestamp) continue + seenKeys.add(dedupKey) + + const model = convModel.get(conversationId) ?? null + const pricingModel = resolveModel(model) + const costUSD = calculateCost(pricingModel, inputTokens, 0, 0, 0, 0) + + results.push({ + provider: 'cursor', + model: modelForDisplay(model), + inputTokens, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + // The meter is the latest context snapshot, not an exact per-turn sum, + // so this input figure (and thus its cost) is an estimate. + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId: conversationId, + }) + } + if (skipped > 0) { process.stderr.write(`codeburn: skipped ${skipped} unreadable Cursor entries\n`) } diff --git a/src/providers/devin.ts b/src/providers/devin.ts new file mode 100644 index 0000000..3631683 --- /dev/null +++ b/src/providers/devin.ts @@ -0,0 +1,555 @@ +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { getShortModelName } from '../models.js' +import { openDatabase } from '../sqlite.js' +import { readConfig } from '../config.js' +import type { + Provider, + SessionParser, + SessionSource, + ParsedProviderCall, +} from './types.js' +import { readSessionFile } from '../fs-utils.js' + +// The fork's parser.ts does not export the numeric helpers upstream devin +// relied on, so they are defined locally (mirroring mistral-vibe.ts / warp.ts). +function isPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function safeNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0 + return Math.max(0, value) +} + +// The fork's CodeburnConfig type does not (yet) declare a `devin` section. +// Read the ACU rate defensively without widening the shared config type. +function readDevinAcuRate(config: unknown): number | undefined { + if (!config || typeof config !== 'object') return undefined + const devin = (config as { devin?: unknown }).devin + if (!devin || typeof devin !== 'object') return undefined + const rate = (devin as { acuUsdRate?: unknown }).acuUsdRate + return typeof rate === 'number' ? rate : undefined +} + +type AgentTrajectory = { + schema_version: string + session_id?: string + agent: Agent + steps: StepType[] + final_metrics?: FinalMetrics +} + +type FinalMetrics = { + total_prompt_tokens?: number + total_completion_tokens?: number + total_cached_tokens?: number + total_steps?: number +} + +type DevinAgentExtra = { + backend?: string + permission_mode?: string +} + +type Agent = { + name: string + version: string + model_name?: string + tool_definitions?: unknown + extra?: Extra +} + +type ToolCall = { + tool_call_id: string + function_name: string + arguments: unknown +} + +type DevinMetadata = { + created_at?: string + committed_acu_cost?: number + generation_model?: string + is_user_input?: boolean + num_tokens?: number + request_id?: string + finish_reason?: string + metrics?: { + input_tokens?: number + output_tokens?: number + cache_creation_tokens?: number + cache_read_tokens?: number + tokens_per_sec?: number + total_time_ms?: number + ttft_ms?: number + tpot_ms?: number + } +} + +type ContentPart = ContentPartText | ContentPartImage + +type ContentPartText = { + type: 'text' + text: string +} + +type ContentPartImage = { + type: 'image' + source: ImageSource +} + +function isTextContentPart( + contentPart: ContentPart, +): contentPart is ContentPartText { + return contentPart.type === 'text' +} + +type ImageSource = { + media_type: string + path: string +} + +type Step = { + step_id: number + timestamp?: string + source: string + model_name?: string + message: string | Array + tool_calls?: Array + extra?: StepExtra + observation?: Observation + metrics?: Metrics +} + +type DevinTelemetry = { + source?: string + operation?: string +} + +type DevinStepExtra = { + committed_acu_cost?: number + generation_model?: string + telemetry?: DevinTelemetry +} + +type Observation = { + results: Array +} + +type ObservationResult = { + source_call_id?: string + content?: string | Array +} + +type Metrics = { + prompt_tokens?: number + completion_tokens?: number + cached_tokens?: number + extra?: Extra +} + +type DevinMetricsExtra = { + cache_creation_input_tokens?: number +} + +type DevinStep = Step & { + metadata?: DevinMetadata +} + +type DevinAgentTrajectory = AgentTrajectory + +type DevinSessionMetadata = { + id: string + workingDirectory: string + model: string + title?: string + createdAt: string + lastActivityAt: string + hidden: boolean +} + +type DevinUsage = { + committedAcuCost: number + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number +} + +const DEFAULT_DEVIN_CLI_DIR = join( + homedir(), + '.local', + 'share', + 'devin', + 'cli', +) + +const DEFAULT_MODEL_NAME = 'devin' +const DEVIN_PROVIDER_NAME = 'devin' +const DEVIN_PROVIDER_DISPLAY_NAME = 'Devin' +const DEVIN_TRANSCRIPTS_SUBDIR = 'transcripts' +const DEVIN_SESSIONS_DB = 'sessions.db' + +function parseTranscript(raw: string): DevinAgentTrajectory | null { + try { + return JSON.parse(raw) as DevinAgentTrajectory + } catch { + return null + } +} + +function parseNumericTimestamp(value: number): string { + const millis = value < 10_000_000_000 ? value * 1000 : value + return new Date(millis).toISOString() +} + +function getCommittedAcuCost(step: DevinStep): number { + const acuCost = [ + step.metadata?.committed_acu_cost, + step.extra?.committed_acu_cost, + ].filter((cost) => isPositiveNumber(cost)) + + return acuCost.shift() || 0 +} + +function hasAnyTokenField( + metrics: Metrics | null | undefined, +): boolean { + if (!metrics) return false + return [ + metrics.prompt_tokens, + metrics.completion_tokens, + metrics.cached_tokens, + metrics.extra?.cache_creation_input_tokens, + ].some((value) => value != null) +} + +function getMetricsFromStep( + step: DevinStep, +): Metrics | null { + // Prefer step.metrics (standard ATIF v1.7) only when it actually carries + // token fields; a present-but-empty metrics object must not shadow the + // legacy metadata.metrics location. + if (hasAnyTokenField(step.metrics)) { + return step.metrics ?? null + } + + if (step.metadata) { + return getDevinMetricsFromMetadata(step.metadata) + } + + return step.metrics ?? null +} + +function getDevinMetricsFromMetadata( + metadata: DevinMetadata, +): Metrics { + return { + prompt_tokens: metadata.metrics?.input_tokens, + completion_tokens: metadata.metrics?.output_tokens, + cached_tokens: metadata.metrics?.cache_read_tokens, + extra: { + cache_creation_input_tokens: metadata.metrics?.cache_creation_tokens, + }, + } +} + +function getUsage(step: DevinStep): DevinUsage | null { + const committedAcuCost = getCommittedAcuCost(step) + const metrics = getMetricsFromStep(step) + + const hasAnyUsage = [ + committedAcuCost, + metrics?.prompt_tokens, + metrics?.completion_tokens, + metrics?.extra?.cache_creation_input_tokens, + metrics?.cached_tokens, + ].some((x) => isPositiveNumber(x)) + + if (!hasAnyUsage) return null + + return { + committedAcuCost, + inputTokens: safeNumber(metrics?.prompt_tokens), + outputTokens: safeNumber(metrics?.completion_tokens), + cacheCreationInputTokens: safeNumber( + metrics?.extra?.cache_creation_input_tokens, + ), + cacheReadInputTokens: safeNumber(metrics?.cached_tokens), + } +} + +function getSessionId( + source: SessionSource, + transcript: DevinAgentTrajectory, +): string { + const fromTranscript = transcript.session_id?.trim() + return fromTranscript || basename(source.path, '.json') +} + +function projectNameFromPath(path: string): string { + const normalized = path.trim().replace(/[/\\]+$/, '') + return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path +} + +function getProjectName( + source: SessionSource, + session: DevinSessionMetadata | null, +): string { + if (session?.workingDirectory) + return projectNameFromPath(session.workingDirectory) + if (session?.title) return session.title + return source.project +} + +function getProjectPath( + session: DevinSessionMetadata | null, +): string | undefined { + return session?.workingDirectory +} + +function getTimestamp( + step: DevinStep, + session: DevinSessionMetadata | null, +): string | undefined { + return [ + step.metadata?.created_at, + session?.lastActivityAt, + session?.createdAt, + ] + .filter(Boolean) + .shift() +} + +function getModelName( + transcript: DevinAgentTrajectory, + step: DevinStep, + session: DevinSessionMetadata | null, +): string { + return ( + [ + step.metadata?.generation_model, + step.model_name, + transcript.agent?.model_name, + session?.model, + ] + .filter(Boolean) + .shift() || DEFAULT_MODEL_NAME + ) +} + +function getToolNames(step: DevinStep): string[] { + return (step.tool_calls ?? []).map((call) => call.function_name) +} + +function normalizeContentPartMessage(contentPart: ContentPart): string { + if (isTextContentPart(contentPart)) { + return contentPart.text + } else { + return contentPart.source.path + } +} + +function normalizeStepMessage(message: string | Array): string { + if (Array.isArray(message)) { + return message.map((x) => normalizeContentPartMessage(x).trim()).join(' ') + } + return message.trim() +} + +function getFirstUserMessageBeforeStep( + steps: DevinStep[], + index: number, +): string | null { + for (let i = index - 1; i >= 0; i--) { + const step = steps[i] + if (!step?.metadata?.is_user_input) continue + const message = step.message + ? normalizeStepMessage(step.message) + : undefined + if (message) return message + } + return null +} + +function loadSessionMetadata( + dbPath: string, +): Map { + const sessions = new Map() + let db: ReturnType | null = null + try { + db = openDatabase(dbPath) + const rows = db.query<{ + id: string + working_directory: string + model: string + title: string | null + created_at: number + last_activity_at: number + hidden: number + }>( + `SELECT id, working_directory, model, title, created_at, last_activity_at, hidden + FROM sessions`, + ) + for (const row of rows) { + if (!row.id) continue + sessions.set(row.id, { + id: row.id, + workingDirectory: row.working_directory, + model: row.model, + title: row.title ?? undefined, + createdAt: parseNumericTimestamp(row.created_at), + lastActivityAt: parseNumericTimestamp(row.last_activity_at), + hidden: !!row.hidden, + }) + } + } catch { + return sessions + } finally { + db?.close() + } + return sessions +} + +async function getCostFactor(): Promise { + const configRate = readDevinAcuRate(await readConfig()) + return isPositiveNumber(configRate) ? configRate : null +} + +class DevinSessionParser implements SessionParser { + constructor( + private source: SessionSource, + private seenKeys: Set, + private sessionMetadata: Map, + ) {} + + async *parse(): AsyncGenerator { + const raw = await readSessionFile(this.source.path) + if (!raw) return + + const transcript = parseTranscript(raw) + if (!transcript?.steps) return + + const sessionId = getSessionId(this.source, transcript) + const session = this.sessionMetadata.get(sessionId) ?? null + if (session?.hidden) return + + const project = getProjectName(this.source, session) + const projectPath = getProjectPath(session) + const costFactor = await getCostFactor() + if (costFactor === null) return + + for (let index = 0; index < transcript.steps.length; index++) { + const step = transcript.steps[index]! + if (step.metadata?.is_user_input) continue + + const usage = getUsage(step) + if (!usage) continue + + const timestamp = getTimestamp(step, session) ?? '' + + const deduplicationKey = `devin:${sessionId}:${step.step_id}` + + if (this.seenKeys.has(deduplicationKey)) continue + this.seenKeys.add(deduplicationKey) + + const model = getModelName(transcript, step, session) + const tools = getToolNames(step) + const userMessage = + getFirstUserMessageBeforeStep(transcript.steps, index) ?? '' + + yield { + provider: DEVIN_PROVIDER_NAME, + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: usage.committedAcuCost * costFactor, + tools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey, + userMessage, + sessionId, + project, + projectPath, + } + } + } +} + +export function createDevinProvider(cliDir: string): Provider { + const sessionsDbPath = join(cliDir, DEVIN_SESSIONS_DB) + let sessionMetadata: Map | null = null + + const getSessionMetadata = () => { + if (!sessionMetadata) sessionMetadata = loadSessionMetadata(sessionsDbPath) + return sessionMetadata + } + + return { + name: DEVIN_PROVIDER_NAME, + displayName: DEVIN_PROVIDER_DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if ((await getCostFactor()) === null) return [] + + const transcriptsDir = join(cliDir, DEVIN_TRANSCRIPTS_SUBDIR) + const entries = await readdir(transcriptsDir).catch(() => []) + const metadata = getSessionMetadata() + const sources: SessionSource[] = [] + + for (const entry of entries) { + if (!entry.endsWith('.json')) continue + + const filePath = join(transcriptsDir, entry) + const pathStats = await stat(filePath).catch(() => null) + + if (!pathStats?.isFile()) continue + + const session = metadata.get(basename(filePath, '.json')) ?? null + if (session?.hidden) continue + + const tmpSource: SessionSource = { + path: filePath, + project: DEVIN_PROVIDER_NAME, + provider: DEVIN_PROVIDER_NAME, + } + + const project = getProjectName(tmpSource, session) + + sources.push({ + path: filePath, + project, + provider: DEVIN_PROVIDER_NAME, + }) + } + + return sources + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set, + ): SessionParser { + return new DevinSessionParser(source, seenKeys, getSessionMetadata()) + }, + } +} + +export const devin = createDevinProvider(DEFAULT_DEVIN_CLI_DIR) diff --git a/src/providers/grok.ts b/src/providers/grok.ts new file mode 100644 index 0000000..13ecd4f --- /dev/null +++ b/src/providers/grok.ts @@ -0,0 +1,273 @@ +import { readdir, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// Grok Build (xAI's coding CLI) stores one session per directory at +// /sessions///, where grok-home is $GROK_HOME +// or ~/.grok. Each session dir holds summary.json, signals.json, and the ACP +// log updates.jsonl. +// +// Grok does NOT record billable input/output tokens. signals.json carries +// `contextTokensUsed` (current context fill) and updates.jsonl carries a running +// `_meta.totalTokens` per streamed chunk; there is no per-call input/output +// split. We reconstruct an ESTIMATE from the per-turn totalTokens curve. Agentic +// turns re-send the growing context every call, and that re-sent context is +// cached server-side, so we bill the unique context (summed per compaction segment) as fresh input, +// the re-sent remainder as cache reads, and the per-turn growth as output. Cost +// is flagged estimated; grok-build is priced via its grok-build-0.1 alias. + +const toolNameMap: Record = { + bash: 'Bash', + run_terminal_command: 'Bash', + read_file: 'Read', + read: 'Read', + write_file: 'Write', + edit_file: 'Edit', + edit: 'Edit', + list_dir: 'Glob', + glob: 'Glob', + grep: 'Grep', + search: 'WebSearch', + web_search: 'WebSearch', + fetch: 'WebFetch', + task: 'Agent', + search_replace: 'Edit', + todo_write: 'TodoWrite', + spawn_subagent: 'Agent', +} + +function defaultSessionsDir(): string { + const home = process.env['GROK_HOME'] ?? join(homedir(), '.grok') + return join(home, 'sessions') +} + +type GrokSummary = { + info?: { id?: string; cwd?: string } + created_at?: string + updated_at?: string + last_active_at?: string + current_model_id?: string + session_summary?: string + generated_title?: string +} + +type GrokSignals = { + primaryModelId?: string + modelsUsed?: string[] + toolsUsed?: string[] +} + +async function readJson(path: string): Promise { + const content = await readSessionFile(path) + if (content === null) return null + try { + return JSON.parse(content) as T + } catch { + return null + } +} + +function safeDecode(name: string): string { + try { + return decodeURIComponent(name) + } catch { + return name + } +} + +// updates.jsonl is one ACP JSON-RPC notification per line; streamed chunks carry +// params._meta.{totalTokens, promptId}. totalTokens is the running context size, +// so grouping by promptId (one per turn) gives each turn's first/last value. +type GrokUpdate = { + params?: { + _meta?: { totalTokens?: number; promptId?: string } + update?: { sessionUpdate?: string; title?: string; rawInput?: { command?: unknown; subagent_type?: unknown } } + } +} + +// Single pass over updates.jsonl: per-turn totalTokens for the cost estimate, +// plus the real tool calls (each tool_call's title -> a tool, and +// run_terminal_command's rawInput.command -> shell commands). +function parseUpdates(updates: string): { + input: number + cacheRead: number + output: number + tools: string[] + bashCommands: string[] +} { + const turns = new Map() + const tools: string[] = [] + const bashCommands: string[] = [] + // Compaction-aware fresh input: a large drop in totalTokens means the context + // was compacted and rebuilt, so we sum each segment's peak rather than the + // single global peak (which would lose everything before the last compaction). + let prevTotal = -1 + let segmentPeak = 0 + let inputFresh = 0 + + for (const line of updates.split('\n')) { + if (!line.trim()) continue + let params: GrokUpdate['params'] + try { + params = (JSON.parse(line) as GrokUpdate).params + } catch { + continue + } + if (!params) continue + + const total = params._meta?.totalTokens + if (typeof total === 'number') { + if (prevTotal >= 0 && total < prevTotal * 0.5) { + inputFresh += segmentPeak // close the segment a compaction just ended + segmentPeak = 0 + } + if (total > segmentPeak) segmentPeak = total + prevTotal = total + + const promptId = params._meta?.promptId + if (promptId) { + const turn = turns.get(promptId) + if (!turn) turns.set(promptId, { first: total, last: total }) + else turn.last = total + } + } + + const update = params.update + if (update?.sessionUpdate === 'tool_call' && typeof update.title === 'string') { + tools.push(toolNameMap[update.title] ?? update.title) + if (update.title === 'run_terminal_command' && typeof update.rawInput?.command === 'string') { + bashCommands.push(...extractBashCommands(update.rawInput.command)) + } + } + } + + inputFresh += segmentPeak // close the final segment + let sumFirst = 0 + let output = 0 + for (const { first, last } of turns.values()) { + sumFirst += first + output += Math.max(0, last - first) + } + // Fresh input (summed segment peaks) is billed once; the rest of the per-turn + // re-sends are cache reads (Grok caches them, even though it reports nothing). + const cacheRead = Math.max(0, sumFirst - inputFresh) + return { input: inputFresh, cacheRead, output, tools, bashCommands } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const dir = dirname(source.path) + const summary = await readJson(join(dir, 'summary.json')) + const updates = await readSessionFile(source.path) + if (!summary || updates === null) return + + const { input, cacheRead, output, tools, bashCommands } = parseUpdates(updates) + if (input === 0 && output === 0) return + + const signals = await readJson(join(dir, 'signals.json')) + const model = + summary.current_model_id ?? signals?.primaryModelId ?? signals?.modelsUsed?.[0] ?? 'grok-build' + const timestamp = summary.updated_at ?? summary.last_active_at ?? summary.created_at ?? '' + const sessionId = summary.info?.id ?? basename(dir) + + const dedupKey = `${source.provider}:${dir}:${timestamp}:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + yield { + provider: source.provider, + model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: 0, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(model, input, output, 0, cacheRead, 0), + costIsEstimated: true, + tools, + bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: summary.session_summary ?? summary.generated_title ?? '', + sessionId, + project: source.project, + projectPath: summary.info?.cwd, + } + }, + } +} + +async function discoverSessions(sessionsDir: string): Promise { + const sources: SessionSource[] = [] + + let cwdDirs: string[] + try { + cwdDirs = await readdir(sessionsDir) + } catch { + return sources + } + + for (const cwdName of cwdDirs) { + const cwdPath = join(sessionsDir, cwdName) + const cwdStat = await stat(cwdPath).catch(() => null) + if (!cwdStat?.isDirectory()) continue + + let sessionDirs: string[] + try { + sessionDirs = await readdir(cwdPath) + } catch { + continue + } + + for (const sessionName of sessionDirs) { + const sessionPath = join(cwdPath, sessionName) + const sessionStat = await stat(sessionPath).catch(() => null) + if (!sessionStat?.isDirectory()) continue + + const summary = await readJson(join(sessionPath, 'summary.json')) + if (!summary) continue + + const cwd = summary.info?.cwd ?? safeDecode(cwdName) + sources.push({ path: join(sessionPath, 'updates.jsonl'), project: basename(cwd), provider: 'grok' }) + } + } + + return sources +} + +export function createGrokProvider(sessionsDir?: string): Provider { + const dir = sessionsDir ?? defaultSessionsDir() + + return { + name: 'grok', + displayName: 'Grok Build', + + modelDisplayName(model: string): string { + if (model.startsWith('grok-build')) return 'Grok Build' + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessions(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const grok = createGrokProvider() diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts new file mode 100644 index 0000000..6ffd6a5 --- /dev/null +++ b/src/providers/hermes.ts @@ -0,0 +1,471 @@ +import { readdir, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type HermesSessionRow = { + id: string + source: string | null + model: string | null + cwd: string | null + billing_provider: string | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + reasoning_tokens: number | null + estimated_cost_usd: number | null + actual_cost_usd: number | null + api_call_count: number | null + tool_call_count: number | null + started_at: number | null + ended_at: number | null + title: string | null +} + +type HermesMessageRow = { + id: number | null + role: string + content: string | null + tool_calls: string | null + tool_name: string | null + timestamp: number | null +} + +type HermesToolCall = { + function?: { + name?: string + arguments?: string + } +} + +type ProfileDb = { + dbPath: string + profile: string +} + +type TableInfoRow = { + name: string +} + +type TableColumn = keyof HermesSessionRow | keyof HermesMessageRow + +const toolNameMap: Record = { + terminal: 'Bash', + execute_code: 'CodeExecution', + read_file: 'Read', + search_files: 'Grep', + write_file: 'Write', + patch: 'Edit', + browser_navigate: 'Browser', + browser_click: 'Browser', + browser_type: 'Browser', + browser_press: 'Browser', + browser_scroll: 'Browser', + browser_snapshot: 'Browser', + browser_vision: 'Vision', + browser_console: 'Browser', + browser_get_images: 'Browser', + web_search: 'WebSearch', + web_extract: 'WebFetch', + delegate_task: 'Agent', + vision_analyze: 'Vision', + process: 'Bash', + todo: 'TodoWrite', + skill_view: 'Skill', + skill_manage: 'Skill', + skills_list: 'Skill', + memory: 'Memory', + session_search: 'SessionSearch', +} + +function getHermesHome(override?: string): string { + return override ?? process.env['HERMES_HOME'] ?? join(homedir(), '.hermes') +} + +function sanitizeProject(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return 'hermes' + return trimmed.replace(/^[/\\]+/, '').replace(/[:/\\]/g, '-') +} + +function parseProfileName(dbPath: string, hermesHome: string): string { + const profilesDir = join(hermesHome, 'profiles') + const dir = dirname(dbPath) + if (dirname(dir) === profilesDir) return basename(dir) + return 'default' +} + +async function findStateDbs(hermesHome: string): Promise { + const dbs: ProfileDb[] = [] + const rootDb = join(hermesHome, 'state.db') + const rootStat = await stat(rootDb).catch(() => null) + if (rootStat?.isFile()) dbs.push({ dbPath: rootDb, profile: 'default' }) + + const profilesDir = join(hermesHome, 'profiles') + const profiles = await readdir(profilesDir, { withFileTypes: true }).catch(() => []) + for (const entry of profiles) { + if (!entry.isDirectory()) continue + const dbPath = join(profilesDir, entry.name, 'state.db') + const s = await stat(dbPath).catch(() => null) + if (s?.isFile()) dbs.push({ dbPath, profile: entry.name }) + } + return dbs +} + +function encodeSourcePath(dbPath: string, sessionId: string): string { + return `${dbPath}#hermes-session=${encodeURIComponent(sessionId)}` +} + +function decodeSourcePath(path: string): { dbPath: string; sessionId: string } | null { + const marker = '#hermes-session=' + const idx = path.lastIndexOf(marker) + if (idx === -1) return null + return { + dbPath: path.slice(0, idx), + sessionId: decodeURIComponent(path.slice(idx + marker.length)), + } +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query('SELECT session_id, role, content, tool_calls FROM messages LIMIT 1') + const columns = getSessionColumns(db) + return columns.has('id') && columns.has('input_tokens') && columns.has('output_tokens') + } catch { + return false + } +} + +function getSessionColumns(db: SqliteDatabase): Set { + return new Set(db.query('PRAGMA table_info(sessions)').map(row => row.name)) +} + +function numberColumn(columns: Set, name: TableColumn): string { + return columns.has(name) ? `coalesce(${name}, 0) AS ${name}` : `0 AS ${name}` +} + +function nullableColumn(columns: Set, name: TableColumn): string { + return columns.has(name) ? name : `NULL AS ${name}` +} + +function getMessageColumns(db: SqliteDatabase): Set { + return new Set(db.query('PRAGMA table_info(messages)').map(row => row.name)) +} + +function usageExpression(columns: Set): string { + const usageColumns: Array = [ + 'input_tokens', + 'output_tokens', + 'cache_read_tokens', + 'cache_write_tokens', + 'reasoning_tokens', + ] + const parts = usageColumns + .filter(name => columns.has(name)) + .map(name => `coalesce(${name}, 0)`) + return parts.length > 0 ? parts.join(' + ') : '0' +} + +function parseTimestamp(raw: number | null): string { + if (raw == null) return '' + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function firstUserMessage(messages: HermesMessageRow[]): string { + const msg = messages.find(m => m.role === 'user' && typeof m.content === 'string' && m.content.trim().length > 0) + return Array.from(msg?.content ?? '').slice(0, 500).join('') +} + +function mapToolName(raw: string): string { + // Composio MCP tools are matched first — the generic mcp_ prefix on line + // below would also match composio names, so order matters here. + if (raw.startsWith('mcp_composio_')) return 'MCP' + if (raw.startsWith('mcp_') || raw.startsWith('mcp__')) return raw + if (raw.startsWith('browser_')) return 'Browser' + return toolNameMap[raw] ?? raw +} + +function parseToolCalls(raw: string | null): HermesToolCall[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? parsed as HermesToolCall[] : [] + } catch { + return [] + } +} + +function collectTools(messages: HermesMessageRow[]): { tools: string[]; toolSequence: string[][]; bashCommands: string[] } { + const tools: string[] = [] + const toolSequence: string[][] = [] + const bashCommands: string[] = [] + + for (const msg of messages) { + if (msg.role === 'assistant') { + const currentTurnTools: string[] = [] + for (const call of parseToolCalls(msg.tool_calls)) { + const rawName = call.function?.name ?? '' + if (!rawName) continue + const mapped = mapToolName(rawName) + tools.push(mapped) + currentTurnTools.push(mapped) + const rawArgs = call.function?.arguments + if (rawArgs) { + try { + const args = JSON.parse(rawArgs) as Record + const command = args['command'] + if (typeof command === 'string') { + bashCommands.push(command) + } + } catch { + // Ignore malformed arguments from historical sessions. + } + } + } + if (currentTurnTools.length > 0) { + toolSequence.push(currentTurnTools) + } + } else if (msg.role === 'tool' && msg.tool_name) { + tools.push(mapToolName(msg.tool_name)) + } + } + + return { + tools: [...new Set(tools)], + toolSequence, + bashCommands, + } +} + +function inferProject(messages: HermesMessageRow[], fallback: string): { project: string; projectPath?: string } { + const cwdPattern = /^Current working directory:\s*([a-zA-Z]:\\[^\r\n`"]+|\/[^\r\n`"\\]+)/m + for (const msg of messages) { + if (msg.role !== 'user' && msg.role !== 'system') continue + const text = msg.content ?? '' + const match = cwdPattern.exec(text) + if (match?.[1]) { + const projectPath = match[1].trim() + return { project: sanitizeProject(projectPath), projectPath } + } + } + return { project: fallback } +} + +async function discoverFromDb(dbPath: string, profile: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + if (!validateSchema(db)) return [] + const columns = getSessionColumns(db) + const usage = usageExpression(columns) + const orderBy = columns.has('started_at') ? 'started_at DESC' : 'id DESC' + const rows = db.query( + `SELECT id, + ${nullableColumn(columns, 'title')}, + ${numberColumn(columns, 'input_tokens')}, + ${numberColumn(columns, 'output_tokens')}, + ${numberColumn(columns, 'cache_read_tokens')}, + ${numberColumn(columns, 'cache_write_tokens')}, + ${numberColumn(columns, 'reasoning_tokens')} + FROM sessions + WHERE ${usage} > 0 + ORDER BY ${orderBy} + LIMIT 10000`, + ) + + return rows.map(row => ({ + path: encodeSourcePath(dbPath, row.id), + project: sanitizeProject(profile), + provider: 'hermes', + })) + } catch (err) { + process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) + return [] + } finally { + db.close() + } +} + +function createParser(source: SessionSource, seenKeys: Set, hermesHome: string): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const decoded = decodeSourcePath(source.path) + if (!decoded) return + const profile = parseProfileName(decoded.dbPath, hermesHome) + + let db: SqliteDatabase + try { + db = openDatabase(decoded.dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Hermes database: ${err instanceof Error ? err.message : err}\n`) + return + } + + let result: ParsedProviderCall | undefined + try { + if (!validateSchema(db)) return + const columns = getSessionColumns(db) + const rows = db.query( + `SELECT id, + ${nullableColumn(columns, 'source')}, + ${nullableColumn(columns, 'model')}, + ${nullableColumn(columns, 'cwd')}, + ${nullableColumn(columns, 'billing_provider')}, + ${numberColumn(columns, 'input_tokens')}, + ${numberColumn(columns, 'output_tokens')}, + ${numberColumn(columns, 'cache_read_tokens')}, + ${numberColumn(columns, 'cache_write_tokens')}, + ${numberColumn(columns, 'reasoning_tokens')}, + ${nullableColumn(columns, 'estimated_cost_usd')}, + ${nullableColumn(columns, 'actual_cost_usd')}, + ${numberColumn(columns, 'api_call_count')}, + ${numberColumn(columns, 'tool_call_count')}, + ${nullableColumn(columns, 'started_at')}, + ${nullableColumn(columns, 'ended_at')}, + ${nullableColumn(columns, 'title')} + FROM sessions + WHERE id = ?`, + [decoded.sessionId], + ) + const row = rows[0] + if (!row) return + + const messageColumns = getMessageColumns(db) + const orderColumns = ['timestamp', 'id'].filter(name => messageColumns.has(name)) + const orderBy = orderColumns.length > 0 ? `ORDER BY ${orderColumns.join(' ASC, ')} ASC` : '' + const messages = db.query( + `SELECT ${numberColumn(messageColumns, 'id')}, + role, + content, + tool_calls, + ${nullableColumn(messageColumns, 'tool_name')}, + ${nullableColumn(messageColumns, 'timestamp')} + FROM messages + WHERE session_id = ? + ${orderBy}`, + [decoded.sessionId], + ) + + const inputTokens = row.input_tokens ?? 0 + const outputTokens = row.output_tokens ?? 0 + const cacheReadTokens = row.cache_read_tokens ?? 0 + const cacheWriteTokens = row.cache_write_tokens ?? 0 + const reasoningTokens = row.reasoning_tokens ?? 0 + if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens === 0) return + + const model = row.model ?? 'unknown' + const { tools, toolSequence, bashCommands } = collectTools(messages) + // Hermes records the session's working directory in sessions.cwd. + // Prefer it; fall back to scraping a "Current working directory:" line + // from the transcript (older builds), then to the profile name. + const cwd = row.cwd?.trim() + const projectInfo = cwd + ? { project: sanitizeProject(cwd), projectPath: cwd } + : inferProject(messages, sanitizeProject(profile)) + const timestamp = parseTimestamp(row.started_at) + const dedupKey = `hermes:${profile}:${row.id}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + // Hermes bills reasoning tokens at the output rate (same as Gemini). + // The LiteLLM model table is used as a fallback when Hermes has not + // stored an actual or estimated cost for the session. + const calculatedCost = calculateCost( + model, + inputTokens, + outputTokens + reasoningTokens, + cacheWriteTokens, + cacheReadTokens, + 0, + ) + const recordedCost = + (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! + : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! + : null + // When Hermes stored no cost (e.g. subscription-billed sessions), the + // figure is our LiteLLM-priced estimate from the session token totals. + const costUSD = recordedCost ?? calculatedCost + const costIsEstimated = recordedCost === null + + result = { + provider: 'hermes', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheWriteTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: cacheReadTokens, + reasoningTokens, + webSearchRequests: 0, + costUSD, + costIsEstimated, + tools, + bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + turnId: `${row.id}:session`, + toolSequence: toolSequence.length > 0 ? toolSequence : undefined, + userMessage: firstUserMessage(messages), + sessionId: row.id, + project: projectInfo.project, + projectPath: projectInfo.projectPath, + } + } catch (err) { + process.stderr.write(`codeburn: error querying Hermes database: ${err instanceof Error ? err.message : err}\n`) + return + } finally { + db.close() + } + + if (result) yield result + }, + } +} + +export function createHermesProvider(hermesHomeOverride?: string): Provider { + const hermesHome = getHermesHome(hermesHomeOverride) + return { + name: 'hermes', + displayName: 'Hermes Agent', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbs = await findStateDbs(hermesHome) + const sessions: SessionSource[] = [] + for (const { dbPath, profile } of dbs) { + sessions.push(...await discoverFromDb(dbPath, profile)) + } + return sessions + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys, hermesHome) + }, + } +} + +export const hermes = createHermesProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 0de11f0..100b42f 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -15,10 +15,14 @@ import { openclaw } from './openclaw.js' import { pi, omp } from './pi.js' import { qwen } from './qwen.js' import { rooCode } from './roo-code.js' +import { grok } from './grok.js' +import { lingtaiTui } from './lingtai-tui.js' +import { openDesign } from './open-design.js' +import { zerostack } from './zerostack.js' import type { Provider, SessionSource } from './types.js' /// Eagerly-imported providers: no native deps, cheap to load on every CLI invocation. -const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kimi, kiloCode, kiro, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kimi, kiloCode, kiro, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, grok, lingtaiTui, openDesign, zerostack] /// Lazy-loaded providers: open native sqlite / large json on disk, may fail when the /// underlying tool isn't installed. Each entry is a literal `() => import(...)` so the @@ -38,6 +42,10 @@ const LAZY_PROVIDERS: readonly LazyProviderSpec[] = [ { name: 'cursor-agent', load: () => import('./cursor-agent.js'), exportName: 'cursor_agent' }, { name: 'crush', load: () => import('./crush.js'), exportName: 'crush' }, { name: 'forge', load: () => import('./forge.js'), exportName: 'forge' }, + { name: 'zcode', load: () => import('./zcode.js'), exportName: 'zcode' }, + { name: 'hermes', load: () => import('./hermes.js'), exportName: 'hermes' }, + { name: 'zed', load: () => import('./zed.js'), exportName: 'zed' }, + { name: 'devin', load: () => import('./devin.js'), exportName: 'devin' }, // 'warp' is intentionally NOT registered in this fork. Opening Warp's group // container (~/Library/Group Containers/2BBY89MBSN.dev.warp/.../warp.sqlite) // trips macOS's "access data from other apps" prompt on every menubar @@ -66,6 +74,18 @@ async function loadLazy(name: string): Promise { } } +// Canonical set of every provider name (core + lazy), used to validate the +// --provider CLI flag. Computed lazily so importing this module never depends on +// every provider object being defined at load time. (upstream #501) +let allProviderNamesCache: string[] | undefined +export function allProviderNames(): readonly string[] { + allProviderNamesCache ??= [ + ...coreProviders.map(p => p.name), + ...LAZY_PROVIDERS.map(s => s.name), + ].sort() + return allProviderNamesCache +} + export async function getAllProviders(): Promise { const lazy = await Promise.all(LAZY_PROVIDERS.map(p => loadLazy(p.name))) return [...coreProviders, ...lazy.filter((p): p is Provider => p != null)] @@ -73,14 +93,40 @@ export async function getAllProviders(): Promise { export const providers = coreProviders -export async function discoverAllSessions(providerFilter?: string): Promise { - const allProviders = await getAllProviders() +// Isolate one provider's discovery. A provider that throws (a crafted/corrupt +// file reaching a string op, an unexpected on-disk shape) must never take down +// the whole scan and blank every other provider's usage. Warn once per +// provider per run, then skip it. Mirrors the parse-failure isolation already +// used per-file in parser.ts. (upstream #650) +const warnedDiscoveryFailures = new Set() +export async function safeDiscoverSessions(provider: Provider): Promise { + try { + return await provider.discoverSessions() + } catch (err) { + if (!warnedDiscoveryFailures.has(provider.name)) { + warnedDiscoveryFailures.add(provider.name) + const msg = err instanceof Error ? err.message : String(err) + process.stderr.write( + `codeburn: skipped ${provider.name} discovery after an error: ${msg}\n` + ) + } + return [] + } +} + +export async function discoverAllSessions( + providerFilter?: string, + // Injectable for tests so the isolation loop itself is exercised, not just + // the helper. Defaults to the real registry. + providerList?: Provider[], +): Promise { + const allProviders = providerList ?? await getAllProviders() const filtered = providerFilter && providerFilter !== 'all' ? allProviders.filter(p => p.name === providerFilter) : allProviders const all: SessionSource[] = [] for (const provider of filtered) { - const sessions = await provider.discoverSessions() + const sessions = await safeDiscoverSessions(provider) all.push(...sessions) } return all diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 0bfaed6..58f4242 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -41,6 +41,9 @@ const toolNameMap: Record = { find_files: 'Glob', webSearch: 'WebSearch', web_search: 'WebSearch', + web_fetch: 'WebFetch', + code: 'Read', + subagent: 'Agent', } type KiroChatMessage = { @@ -271,6 +274,7 @@ export function createKiroProvider(agentDirOverride?: string, workspaceStorageDi }, toolDisplayName(rawTool: string): string { + if (rawTool.startsWith('mcp__')) return rawTool return toolNameMap[rawTool] ?? rawTool }, diff --git a/src/providers/lingtai-tui.ts b/src/providers/lingtai-tui.ts new file mode 100644 index 0000000..4c39d6f --- /dev/null +++ b/src/providers/lingtai-tui.ts @@ -0,0 +1,426 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, delimiter, dirname, join, resolve } from 'path' +import { homedir } from 'os' + +import { readSessionLines } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +type JsonObject = Record + +type LingTaiAgentManifest = { + agent_id?: string + agent_name?: string + address?: string + nickname?: string | null + llm?: { + model?: string + base_url?: string + } +} + +type LingTaiLedgerEntry = { + source?: string + em_id?: string + run_id?: string + ts?: string | number + input?: number | string + output?: number | string + thinking?: number | string + cached?: number | string + model?: string + endpoint?: string +} + +type LingTaiProviderOptions = { + lingtaiHomeOverride?: string + defaultHomeOverride?: string + globalDirOverride?: string + cwdOverride?: string +} + +type LingTaiHome = { + path: string + projectPrefix?: string +} + +function normalizeOptions(options?: string | LingTaiProviderOptions): LingTaiProviderOptions { + return typeof options === 'string' + ? { lingtaiHomeOverride: options } + : options ?? {} +} + +function expandHome(raw: string): string { + if (raw === '~') return homedir() + if (raw.startsWith('~/') || raw.startsWith('~\\')) return join(homedir(), raw.slice(2)) + return raw +} + +function splitPathList(raw: string | undefined): string[] { + return (raw ?? '') + .split(delimiter) + .map(p => p.trim()) + .filter(Boolean) +} + +async function existingDir(path: string): Promise { + const resolved = resolve(expandHome(path)) + const s = await stat(resolved).catch(() => null) + return s?.isDirectory() ? resolved : null +} + +function getDefaultLingTaiHome(options: LingTaiProviderOptions): string { + return options.defaultHomeOverride ?? join(homedir(), '.lingtai') +} + +function getLingTaiGlobalDir(options: LingTaiProviderOptions): string { + return options.globalDirOverride + ?? process.env['LINGTAI_TUI_GLOBAL_DIR'] + ?? join(homedir(), '.lingtai-tui') +} + +function projectPrefixFromHome(lingtaiHome: string, defaultLingTaiHome: string): string | undefined { + const defaultHome = resolve(expandHome(defaultLingTaiHome)) + const resolved = resolve(lingtaiHome) + if (resolved === defaultHome) return undefined + + const projectName = basename(dirname(resolved)) + return projectName && projectName !== '.' ? sanitizeProject(projectName) : undefined +} + +async function readRegisteredProjectPaths(globalDir: string): Promise { + const projects: string[] = [] + + const registryRaw = await readFile(join(globalDir, 'registry.jsonl'), 'utf-8').catch(() => '') + for (const line of registryRaw.split(/\r?\n/)) { + if (!line.trim()) continue + try { + const obj = asObject(JSON.parse(line)) + const path = stringField(obj, 'path') + if (path) projects.push(path) + } catch { + // Ignore corrupt registry rows; LingTai treats this as append-only state. + } + } + + const briefDir = join(globalDir, 'brief', 'projects') + const entries = await readdir(briefDir, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const meta = await readJson(join(briefDir, entry.name, 'meta.json')) + const path = stringField(meta, 'project_path') + if (path) projects.push(path) + } + + return projects +} + +function cwdLingTaiHomes(cwd: string): string[] { + const homes: string[] = [] + let current = resolve(cwd) + for (;;) { + homes.push(join(current, '.lingtai')) + const parent = dirname(current) + if (parent === current) break + current = parent + } + return homes +} + +async function getLingTaiHomes(options: LingTaiProviderOptions): Promise { + const explicit = splitPathList(options.lingtaiHomeOverride ?? process.env['LINGTAI_HOME'] ?? process.env['LINGTAI_TUI_HOME']) + const defaultHome = getDefaultLingTaiHome(options) + const candidates = explicit.length + ? explicit + : [ + defaultHome, + ...(await readRegisteredProjectPaths(getLingTaiGlobalDir(options))).map(project => join(project, '.lingtai')), + ...cwdLingTaiHomes(options.cwdOverride ?? process.cwd()), + ] + + const seen = new Set() + const homes: LingTaiHome[] = [] + for (const candidate of candidates) { + const path = await existingDir(candidate) + if (!path || seen.has(path)) continue + seen.add(path) + homes.push({ path, projectPrefix: explicit.length ? undefined : projectPrefixFromHome(path, defaultHome) }) + } + + return homes +} + +function sanitizeProject(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) return 'lingtai' + return trimmed.replace(/^[/\\]+/, '').replace(/[:/\\]/g, '-') +} + +function asObject(value: unknown): JsonObject | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : null +} + +function stringField(obj: JsonObject | null, key: string): string | undefined { + const value = obj?.[key] + return typeof value === 'string' && value.trim() ? value : undefined +} + +function numericField(obj: JsonObject, key: keyof LingTaiLedgerEntry): number { + const raw = obj[key] + const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN + if (!Number.isFinite(n) || n <= 0) return 0 + return Math.trunc(n) +} + +async function readJson(path: string): Promise { + const raw = await readFile(path, 'utf-8').catch(() => null) + if (!raw) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +async function readAgentManifest(agentDir: string): Promise { + const obj = asObject(await readJson(join(agentDir, '.agent.json'))) + if (!obj) return null + // .agent.json is untrusted: a planted file can be valid JSON with wrong-typed + // fields (e.g. `agent_name: {}`). Reading it as a raw cast let a non-string + // field reach sanitizeProject().trim() and throw — and because + // discoverAllSessions loops providers without a try/catch, that one file took + // down usage discovery for EVERY provider. Normalize to string-or-undefined + // here so no downstream string op ever sees a non-string. + const llm = asObject(obj['llm']) + return { + agent_id: stringField(obj, 'agent_id'), + agent_name: stringField(obj, 'agent_name'), + address: stringField(obj, 'address'), + nickname: stringField(obj, 'nickname') ?? null, + llm: llm + ? { model: stringField(llm, 'model'), base_url: stringField(llm, 'base_url') } + : undefined, + } +} + +function agentDirFromLedgerPath(ledgerPath: string): string { + return dirname(dirname(ledgerPath)) +} + +function projectFromManifest(manifest: LingTaiAgentManifest | null, fallback: string, prefix?: string): string { + const name = sanitizeProject( + manifest?.nickname + ?? manifest?.agent_name + ?? manifest?.address + ?? fallback, + ) + return prefix ? `${prefix}-${name}` : name +} + +function parseTimestamp(raw: unknown): string { + if (typeof raw === 'number' && Number.isFinite(raw)) { + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() + } + if (typeof raw !== 'string' || !raw.trim()) return '' + const d = new Date(raw) + return Number.isNaN(d.getTime()) ? '' : d.toISOString() +} + +function parseLedgerLine(line: string | Buffer): LingTaiLedgerEntry | null { + const text = Buffer.isBuffer(line) ? line.toString('utf-8') : line + if (!text.trim()) return null + try { + const parsed = JSON.parse(text) as unknown + const obj = asObject(parsed) + return obj ? obj as LingTaiLedgerEntry : null + } catch { + return null + } +} + +function activityForSource(sourceLabel: string): { userMessage: string; tools: string[] } { + const normalized = sourceLabel.trim().toLowerCase() + + if (normalized === 'tc_wake' || normalized.startsWith('tc_') || normalized.includes('wake')) { + return { + userMessage: 'LingTai task coordinator wake', + tools: ['Agent'], + } + } + + if (normalized === 'daemon') { + return { + userMessage: 'LingTai daemon task', + tools: ['Agent'], + } + } + + if (normalized === 'summarize_apriori' || normalized.includes('summar')) { + return { + userMessage: 'LingTai planning summary', + tools: ['EnterPlanMode'], + } + } + + return { + userMessage: normalized === 'main' + ? 'LingTai main conversation' + : `LingTai ${sourceLabel || 'main'} conversation`, + tools: [], + } +} + +async function discoverLedgersInHome(home: LingTaiHome): Promise { + const entries = await readdir(home.path, { withFileTypes: true }).catch(() => []) + const sources: SessionSource[] = [] + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const agentDir = join(home.path, entry.name) + const ledgerPath = join(agentDir, 'logs', 'token_ledger.jsonl') + const s = await stat(ledgerPath).catch(() => null) + if (!s?.isFile()) continue + + const manifest = await readAgentManifest(agentDir) + sources.push({ + path: ledgerPath, + project: projectFromManifest(manifest, entry.name, home.projectPrefix), + provider: 'lingtai-tui', + }) + } + + return sources +} + +async function discoverLedgers(homes: LingTaiHome[]): Promise { + const sources: SessionSource[] = [] + const seen = new Set() + + for (const home of homes) { + for (const source of await discoverLedgersInHome(home)) { + if (seen.has(source.path)) continue + seen.add(source.path) + sources.push(source) + } + } + + return sources +} + +function createParser(source: SessionSource): SessionParser { + return { + async *parse(): AsyncGenerator { + const agentDir = agentDirFromLedgerPath(source.path) + const manifest = await readAgentManifest(agentDir) + const agentId = manifest?.agent_id ?? basename(agentDir) + const fallbackModel = manifest?.llm?.model ?? 'unknown' + const fallbackEndpoint = manifest?.llm?.base_url ?? '' + const project = source.project || projectFromManifest(manifest, basename(agentDir)) + const projectPath = agentDir + + let lineNo = 0 + for await (const line of readSessionLines(source.path)) { + lineNo += 1 + const entry = parseLedgerLine(line) + if (!entry) continue + + const obj = entry as JsonObject + const inputTotal = numericField(obj, 'input') + const outputTokens = numericField(obj, 'output') + const reasoningTokens = numericField(obj, 'thinking') + const cachedInputTokens = numericField(obj, 'cached') + const totalTokens = inputTotal + outputTokens + reasoningTokens + cachedInputTokens + if (totalTokens === 0) continue + + // LingTai records provider-normalized input totals plus a separate + // cached count. Match CodeBurn's normal shape by billing cached tokens + // in cacheReadInputTokens, not again as fresh input. + const inputTokens = Math.max(0, inputTotal - cachedInputTokens) + const model = stringField(obj, 'model') ?? fallbackModel + const endpoint = stringField(obj, 'endpoint') ?? fallbackEndpoint + const timestamp = parseTimestamp(entry.ts) + const sourceLabel = stringField(obj, 'source') ?? 'main' + const emId = stringField(obj, 'em_id') ?? '' + const runId = stringField(obj, 'run_id') ?? '' + const sessionId = runId || `${agentId}:${sourceLabel}` + const activity = activityForSource(sourceLabel) + const dedupKey = [ + 'lingtai-tui', + source.path, + lineNo, + timestamp, + model, + endpoint, + sourceLabel, + emId, + runId, + inputTotal, + outputTokens, + reasoningTokens, + cachedInputTokens, + ].join(':') + + const costUSD = calculateCost( + model, + inputTokens, + outputTokens + reasoningTokens, + 0, + cachedInputTokens, + 0, + ) + + yield { + provider: 'lingtai-tui', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: cachedInputTokens, + cachedInputTokens, + reasoningTokens, + webSearchRequests: 0, + costUSD, + tools: activity.tools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + turnId: `${sessionId}:line:${lineNo}`, + userMessage: activity.userMessage, + sessionId, + project, + projectPath, + } + } + }, + } +} + +export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptions): Provider { + const providerOptions = normalizeOptions(options) + + return { + name: 'lingtai-tui', + displayName: 'LingTai TUI', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + return discoverLedgers(await getLingTaiHomes(providerOptions)) + }, + + createSessionParser(source: SessionSource): SessionParser { + return createParser(source) + }, + } +} + +export const lingtaiTui = createLingTaiTuiProvider() diff --git a/src/providers/open-design.ts b/src/providers/open-design.ts new file mode 100644 index 0000000..5a2a079 --- /dev/null +++ b/src/providers/open-design.ts @@ -0,0 +1,259 @@ +import { readdir, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir, platform } from 'os' + +import { readSessionLines } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const PROVIDER_NAME = 'open-design' +const ENV_DIR = 'CODEBURN_OPEN_DESIGN_DIR' + +const modelDisplayNames = new Map([ + ['openai-codex:gpt-5.5', 'GPT-5.5'], + ['glm-5.2', 'GLM-5.2'], + ['GLM-5.2', 'GLM-5.2'], +]) + +type OpenDesignEntry = { + id?: unknown + event?: unknown + data?: unknown + timestamp?: unknown +} + +type TokenUsage = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + reasoningTokens: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function tokenValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +function timestampValue(value: unknown): string { + const text = stringValue(value) + if (text) return text + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '' : date.toISOString() +} + +function parseEvent(line: string | Buffer): OpenDesignEntry | null { + const text = (typeof line === 'string' ? line : line.toString('utf-8')).trim() + if (!text) return null + + try { + const parsed = JSON.parse(text) as unknown + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +function parseUsage(data: unknown): TokenUsage | null { + if (!isRecord(data) || data['type'] !== 'usage') return null + const usage = data['usage'] + if (!isRecord(usage)) return null + + return { + inputTokens: tokenValue(usage['input_tokens']), + outputTokens: tokenValue(usage['output_tokens']), + cacheReadTokens: tokenValue(usage['cached_read_tokens']), + reasoningTokens: tokenValue(usage['thought_tokens']), + } +} + +function getOpenDesignDir(): string { + const override = process.env[ENV_DIR] + if (override) return override + + const home = homedir() + const os = platform() + if (os === 'darwin') { + return join(home, 'Library', 'Application Support', 'Open Design') + } + if (os === 'win32') { + return join(process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming'), 'Open Design') + } + return join(home, '.config', 'Open Design') +} + +function namespaceFromDataDir(dataDir: string): string { + const ns = basename(dirname(dataDir)) + return ns && ns !== 'namespaces' ? ns : PROVIDER_NAME +} + +function namespaceFromRunsDir(runsDir: string): string { + return namespaceFromDataDir(dirname(runsDir)) +} + +async function discoverRunsDir(runsDir: string, project: string): Promise { + const sources: SessionSource[] = [] + let runDirs: string[] + try { + runDirs = await readdir(runsDir) + } catch { + return sources + } + + for (const runDir of runDirs) { + const eventsPath = join(runsDir, runDir, 'events.jsonl') + const s = await stat(eventsPath).catch(() => null) + if (!s?.isFile()) continue + sources.push({ path: eventsPath, project, provider: PROVIDER_NAME }) + } + + return sources +} + +async function discoverNamespacesDir(namespacesDir: string): Promise { + const sources: SessionSource[] = [] + let namespaces: string[] + try { + namespaces = await readdir(namespacesDir) + } catch { + return sources + } + + for (const ns of namespaces) { + const runsDir = join(namespacesDir, ns, 'data', 'runs') + sources.push(...await discoverRunsDir(runsDir, ns)) + } + + return sources +} + +function dedupeSources(sources: SessionSource[]): SessionSource[] { + const seen = new Set() + const out: SessionSource[] = [] + for (const source of sources) { + if (seen.has(source.path)) continue + seen.add(source.path) + out.push(source) + } + return out +} + +async function discoverOpenDesignSessions(baseDir: string): Promise { + const baseName = basename(baseDir) + if (baseName === 'runs') { + return discoverRunsDir(baseDir, namespaceFromRunsDir(baseDir)) + } + if (baseName === 'data') { + return discoverRunsDir(join(baseDir, 'runs'), namespaceFromDataDir(baseDir)) + } + + const sources: SessionSource[] = [] + sources.push(...await discoverRunsDir(join(baseDir, 'data', 'runs'), basename(baseDir) || PROVIDER_NAME)) + sources.push(...await discoverRunsDir(join(baseDir, 'runs'), basename(baseDir) || PROVIDER_NAME)) + sources.push(...await discoverNamespacesDir(baseName === 'namespaces' ? baseDir : join(baseDir, 'namespaces'))) + return dedupeSources(sources) +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const sessionId = basename(dirname(source.path)) + let currentModel = '' + let fallbackEventCounter = 0 + + for await (const line of readSessionLines(source.path)) { + const entry = parseEvent(line) + if (!entry) continue + + const eventName = stringValue(entry.event) + const data = entry.data + + if (eventName === 'start' && isRecord(data)) { + const model = stringValue(data['model']) + if (model) currentModel = model + continue + } + + if (eventName !== 'agent' || !isRecord(data)) continue + + if (data['type'] === 'status') { + const model = stringValue(data['model']) + if (model) currentModel = model + continue + } + + const usage = parseUsage(data) + if (!usage || !currentModel) continue + + const eventId = stringValue(entry.id) ?? `line-${fallbackEventCounter++}` + const dedupKey = `${PROVIDER_NAME}:${sessionId}:${eventId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const uncachedInputTokens = Math.max(0, usage.inputTokens - usage.cacheReadTokens) + const costUSD = calculateCost( + currentModel, + uncachedInputTokens, + usage.outputTokens + usage.reasoningTokens, + 0, + usage.cacheReadTokens, + 0, + ) + + yield { + provider: PROVIDER_NAME, + sessionId, + project: source.project, + model: currentModel, + inputTokens: uncachedInputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: usage.cacheReadTokens, + cachedInputTokens: usage.cacheReadTokens, + reasoningTokens: usage.reasoningTokens, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: timestampValue(entry.timestamp), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + } + } + }, + } +} + +export function createOpenDesignProvider(overrideDir?: string): Provider { + return { + name: PROVIDER_NAME, + displayName: 'Open Design', + + modelDisplayName(model: string): string { + return modelDisplayNames.get(model) ?? model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + return discoverOpenDesignSessions(overrideDir ?? getOpenDesignDir()) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const openDesign = createOpenDesignProvider() diff --git a/src/providers/opencode-file-parser.ts b/src/providers/opencode-file-parser.ts new file mode 100644 index 0000000..5a3549e --- /dev/null +++ b/src/providers/opencode-file-parser.ts @@ -0,0 +1,154 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { buildAssistantCall, sanitize, type MessageData, type PartData } from './session-message.js' +import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// OpenCode 1.1+ stores sessions as file-based JSON instead of a SQLite DB: +// storage/session//.json session metadata +// storage/message//.json one file per message +// storage/part//.json one file per part +// The message/part shape matches the SQLite layout, so the per-message build +// logic is shared via buildAssistantCall. + +type SessionMeta = { + id?: string + directory?: string + title?: string + time?: { created?: number } +} + +type FileMessageData = MessageData & { + id?: string + time?: { created?: number } +} + +async function readJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as T + } catch { + return null + } +} + +async function readParts(dataDir: string, messageId: string): Promise { + const dir = join(dataDir, 'storage', 'part', messageId) + let files: string[] + try { + files = (await readdir(dir)).sort() + } catch { + return [] + } + const parts: PartData[] = [] + for (const f of files) { + if (!f.endsWith('.json')) continue + const part = await readJson(join(dir, f)) + if (part) parts.push(part) + } + return parts +} + +export async function discoverOpenCodeFileSessions( + dataDir: string, + providerName: string, +): Promise { + const sessionRoot = join(dataDir, 'storage', 'session') + let projectDirs: string[] + try { + projectDirs = await readdir(sessionRoot) + } catch { + return [] + } + + const sources: SessionSource[] = [] + for (const project of projectDirs) { + let files: string[] + try { + files = await readdir(join(sessionRoot, project)) + } catch { + continue + } + for (const f of files) { + if (!f.endsWith('.json')) continue + const path = join(sessionRoot, project, f) + const meta = await readJson(path) + if (!meta?.id) continue + sources.push({ + path, + project: sanitize(meta.directory || meta.title || ''), + provider: providerName, + }) + } + } + return sources +} + +export function createOpenCodeFileSessionParser( + source: SessionSource, + seenKeys: Set, + dataDir: string, + providerName: string, +): SessionParser { + return { + async *parse(): AsyncGenerator { + const meta = await readJson(source.path) + if (!meta?.id) return + const sessionId = meta.id + + const messageDir = join(dataDir, 'storage', 'message', sessionId) + let messageFiles: string[] + try { + messageFiles = await readdir(messageDir) + } catch { + return + } + + const messages: Array<{ id: string; data: FileMessageData }> = [] + for (const f of messageFiles) { + if (!f.endsWith('.json')) continue + const data = await readJson(join(messageDir, f)) + if (!data) continue + messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data }) + } + messages.sort((a, b) => { + const byTime = (a.data.time?.created ?? 0) - (b.data.time?.created ?? 0) + if (byTime !== 0) return byTime + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) + + let currentUserMessage = '' + for (const { id, data } of messages) { + if (data.role === 'user') { + const parts = await readParts(dataDir, id) + const text = parts + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .filter(Boolean) + .join(' ') + if (text) currentUserMessage = text + continue + } + + if (data.role !== 'assistant' && data.role !== 'model') continue + + const dedupKey = `${providerName}:${sessionId}:${id}` + if (seenKeys.has(dedupKey)) continue + + const parts = await readParts(dataDir, id) + const call = buildAssistantCall({ + providerName, + dedupKey, + sessionId, + data, + parts, + timeCreatedMs: data.time?.created ?? meta.time?.created ?? 0, + userMessage: currentUserMessage, + }) + if (!call) continue + + seenKeys.add(dedupKey) + yield call + } + }, + } +} diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index da5a27e..c00810a 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -5,6 +5,7 @@ import { homedir } from 'node:os' import { calculateCost, getShortModelName } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import { discoverOpenCodeFileSessions, createOpenCodeFileSessionParser } from './opencode-file-parser.js' import type { Provider, SessionSource, @@ -500,7 +501,15 @@ export function createOpenCodeProvider(dataDir?: string): Provider { return toolNameMap[rawTool] ?? rawTool }, + // OpenCode 1.1+ stores sessions as file-based JSON; older builds used a + // SQLite DB. Prefer file-based when present, otherwise fall back to the DB + // so pre-migration installs keep reporting. The file path does not need + // the native SQLite binding, so it is tried before the isSqliteAvailable + // guard. async discoverSessions(): Promise { + const fileSessions = await discoverOpenCodeFileSessions(dir, 'opencode') + if (fileSessions.length > 0) return fileSessions + if (!isSqliteAvailable()) return [] const dbPaths = await findDbFiles(dir) @@ -517,6 +526,11 @@ export function createOpenCodeProvider(dataDir?: string): Provider { source: SessionSource, seenKeys: Set, ): SessionParser { + // File-based sources carry a `.json` path; SQLite sources are encoded as + // `${dbPath}:${sessionId}` (session IDs are UUIDs with no `.json`). + if (source.path.endsWith('.json')) { + return createOpenCodeFileSessionParser(source, seenKeys, dir, 'opencode') + } return createParser(source, seenKeys) }, } diff --git a/src/providers/pi.ts b/src/providers/pi.ts index 694ad81..056f0b8 100644 --- a/src/providers/pi.ts +++ b/src/providers/pi.ts @@ -11,6 +11,7 @@ import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from const modelDisplayNames: Record = { 'gpt-5.4': 'GPT-5.4', 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.5': 'GPT-5.5', 'gpt-5': 'GPT-5', 'gpt-4o': 'GPT-4o', 'gpt-4o-mini': 'GPT-4o Mini', diff --git a/src/providers/session-message.ts b/src/providers/session-message.ts new file mode 100644 index 0000000..8f384d6 --- /dev/null +++ b/src/providers/session-message.ts @@ -0,0 +1,168 @@ +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { ParsedProviderCall } from './types.js' + +// The message/part shape shared by OpenCode-style stores (the OpenCode SQLite +// layout and the OpenCode 1.1+ file-based JSON layout). Token-bearing +// assistant messages carry either the normalized `tokens` object or a raw +// `usage` block. +export type MessageData = { + role: string + modelID?: string + model?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cache?: { read?: number; write?: number } + } + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } +} + +export type PartData = { + type: string + text?: string + tool?: string + state?: { input?: { command?: string } } +} + +const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + skill: 'Skill', + patch: 'Patch', +} + +/// Normalize an OpenCode tool name into either: +/// * the canonical built-in name (Bash, Read, Edit, ...) +/// * an already-prefixed `mcp__server__tool` name (left alone) +/// * a freshly-prefixed `mcp__server__tool` constructed from OpenCode's +/// own `_` storage convention +/// +/// Why: OpenCode stores MCP tool calls as `_` with no separate +/// server field, so without this normalization MCP usage was invisible to the +/// cross-provider MCP pipeline. Built-in names are checked first so a built-in +/// containing an `_` can never be misinterpreted as an MCP call. +export function normalizeToolName(rawTool?: string): string { + if (!rawTool) return '' + if (rawTool.startsWith('mcp__')) return rawTool + const builtIn = toolNameMap[rawTool] + if (builtIn) return builtIn + const serverSeparator = rawTool.indexOf('_') + if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) { + const server = rawTool.slice(0, serverSeparator) + const tool = rawTool.slice(serverSeparator + 1) + return `mcp__${server}__${tool}` + } + return rawTool +} + +export function sanitize(dir: string): string { + return dir.replace(/^\//, '').replace(/\//g, '-') +} + +export function parseTimestamp(raw: number): string { + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +// Build a ParsedProviderCall from one assistant message and its parts. Returns +// null when the message has no tokens, no cost, and no substantive parts (an +// empty or errored turn worth skipping). Shared so the SQLite and file-based +// OpenCode parsers attribute tokens, tools, and cost identically. +export function buildAssistantCall(opts: { + providerName: string + dedupKey: string + sessionId: string + data: MessageData + parts: PartData[] + timeCreatedMs: number + userMessage: string +}): ParsedProviderCall | null { + const { data, parts } = opts + + const tokens = { + input: data.tokens?.input ?? data.usage?.input_tokens ?? 0, + output: data.tokens?.output ?? data.usage?.output_tokens ?? 0, + reasoning: data.tokens?.reasoning ?? 0, + cacheRead: data.tokens?.cache?.read ?? data.usage?.cache_read_input_tokens ?? 0, + cacheWrite: data.tokens?.cache?.write ?? data.usage?.cache_creation_input_tokens ?? 0, + } + + const toolParts = parts.filter((p) => (p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call') && normalizeToolName(p.tool)) + const hasTextOutput = parts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0) + const hasToolOrTextParts = hasTextOutput || toolParts.length > 0 + const hasAnySubstantiveParts = parts.some((p) => + p.type === 'text' || p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call' || + p.type === 'tool-result' || p.type === 'tool_result' || p.type === 'reasoning' || p.type === 'file' + ) + const hasActivity = hasToolOrTextParts || hasAnySubstantiveParts + + const allZero = + tokens.input === 0 && + tokens.output === 0 && + tokens.reasoning === 0 && + tokens.cacheRead === 0 && + tokens.cacheWrite === 0 + // Keep entries where the model produced visible activity (tool calls or + // text) even when token usage is zero — typical of OpenCode router calls + // (e.g. a /model switch) that don't bill tokens but still represent + // activity the user expects to see. + if (allZero && (data.cost ?? 0) === 0 && !hasActivity) return null + + const tools = toolParts + .map((p) => normalizeToolName(p.tool)) + .filter(Boolean) + + const bashCommands = toolParts + .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') + .flatMap((p) => extractBashCommands(p.state!.input!.command!)) + + const model = data.modelID ?? data.model ?? 'unknown' + let costUSD = calculateCost( + model, + tokens.input, + tokens.output + tokens.reasoning, + tokens.cacheWrite, + tokens.cacheRead, + 0, + ) + + if (costUSD === 0 && typeof data.cost === 'number' && data.cost > 0) { + costUSD = data.cost + } + + return { + provider: opts.providerName, + model, + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheCreationInputTokens: tokens.cacheWrite, + cacheReadInputTokens: tokens.cacheRead, + cachedInputTokens: tokens.cacheRead, + reasoningTokens: tokens.reasoning, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: parseTimestamp(opts.timeCreatedMs), + speed: 'standard', + deduplicationKey: opts.dedupKey, + userMessage: opts.userMessage, + sessionId: opts.sessionId, + } +} diff --git a/src/providers/zcode.ts b/src/providers/zcode.ts new file mode 100644 index 0000000..d9eaf47 --- /dev/null +++ b/src/providers/zcode.ts @@ -0,0 +1,227 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +/// ZCode (CLI v0.14.x) records usage in a single SQLite database at +/// ~/.zcode/cli/db/db.sqlite. We read it because the other on-disk sources are +/// unusable for billing: the JSONL activity log redacts token counts, and no +/// source stores a dollar cost (GLM-5.2 runs on z.ai's start-plan subscription). +/// Tokens are exact; cost is computed from the pricing table. Schema verified +/// against db v0.14.8 on 2026-06-20. + +type SessionRow = { + id: string + directory: string +} + +type UsageRow = { + id: string + turn_id: string | null + model_id: string + input_tokens: number + output_tokens: number + reasoning_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number + started_at: number + completed_at: number | null +} + +type ToolRow = { + turn_id: string | null + tool_name: string +} + +function getDbPath(override?: string): string { + return override ?? join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite') +} + +function sanitizeProject(path: string): string { + return path.replace(/^\//, '').replace(/\//g, '-') +} + +function epochMsToIso(ms: number | null): string { + if (ms === null || !Number.isFinite(ms) || ms <= 0) return new Date(0).toISOString() + return new Date(ms).toISOString() +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM model_usage LIMIT 1') + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM session LIMIT 1') + return true + } catch { + return false + } +} + +function discover(dbPath: string): SessionSource[] { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + try { + if (!validateSchema(db)) return [] + const rows = db.query( + `SELECT DISTINCT s.id as id, s.directory as directory + FROM session s + JOIN model_usage m ON m.session_id = s.id + WHERE m.input_tokens > 0 OR m.output_tokens > 0 OR m.reasoning_tokens > 0 + OR m.cache_read_input_tokens > 0 OR m.cache_creation_input_tokens > 0`, + ) + return rows.map(row => ({ + path: `${dbPath}:${row.id}`, + project: sanitizeProject(row.directory), + provider: 'zcode', + })) + } catch { + return [] + } finally { + db.close() + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + // Source paths are `:`. Split from the right so a colon + // in the path (Windows drive letter) doesn't corrupt the session id. + const segments = source.path.split(':') + const sessionId = segments[segments.length - 1]! + const dbPath = segments.slice(0, -1).join(':') + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write( + `codeburn: cannot open ZCode database: ${err instanceof Error ? err.message : err}\n`, + ) + return + } + + try { + if (!validateSchema(db)) return + + // model_usage rows don't link to individual tool calls, only to a turn, + // so collect each turn's tools and attach them to one request per turn + // (below) to avoid double-counting across a turn's multiple requests. + const toolRows = db.query( + `SELECT turn_id, tool_name FROM tool_usage + WHERE session_id = ? AND turn_id IS NOT NULL + ORDER BY started_at ASC`, + [sessionId], + ) + const toolsByTurn = new Map() + for (const tool of toolRows) { + if (!tool.turn_id) continue + const list = toolsByTurn.get(tool.turn_id) ?? [] + list.push(tool.tool_name) + toolsByTurn.set(tool.turn_id, list) + } + + const rows = db.query( + `SELECT id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens, + cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at + FROM model_usage WHERE session_id = ? + ORDER BY started_at ASC`, + [sessionId], + ) + + const turnsWithToolsEmitted = new Set() + + for (const row of rows) { + const cacheRead = row.cache_read_input_tokens ?? 0 + const cacheCreation = row.cache_creation_input_tokens ?? 0 + const output = row.output_tokens ?? 0 + const reasoning = row.reasoning_tokens ?? 0 + // ZCode folds cached tokens into input_tokens (OpenAI-style). Split + // them back out so fresh input bills at the input rate and cached at + // the cache-read rate, matching the pricing table's Anthropic-style + // semantics. + const freshInput = Math.max(0, (row.input_tokens ?? 0) - cacheRead - cacheCreation) + + if (freshInput === 0 && output === 0 && reasoning === 0 && cacheRead === 0 && cacheCreation === 0) { + continue + } + + const dedupKey = `zcode:${row.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + let tools: string[] = [] + if (row.turn_id && !turnsWithToolsEmitted.has(row.turn_id)) { + const turnTools = toolsByTurn.get(row.turn_id) + if (turnTools && turnTools.length > 0) { + tools = turnTools + turnsWithToolsEmitted.add(row.turn_id) + } + } + + const model = row.model_id + const costUSD = calculateCost(model, freshInput, output, cacheCreation, cacheRead, 0) + + yield { + provider: 'zcode', + model, + inputTokens: freshInput, + outputTokens: output, + cacheCreationInputTokens: cacheCreation, + cacheReadInputTokens: cacheRead, + cachedInputTokens: 0, + reasoningTokens: reasoning, + webSearchRequests: 0, + costUSD, + tools, + bashCommands: [], + timestamp: epochMsToIso(row.completed_at ?? row.started_at), + speed: 'standard', + deduplicationKey: dedupKey, + turnId: row.turn_id ?? undefined, + userMessage: '', + sessionId, + } + } + } finally { + db.close() + } + }, + } +} + +export function createZcodeProvider(dbPathOverride?: string): Provider { + const dbPath = getDbPath(dbPathOverride) + return { + name: 'zcode', + displayName: 'ZCode', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + return discover(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const zcode = createZcodeProvider() diff --git a/src/providers/zed.ts b/src/providers/zed.ts new file mode 100644 index 0000000..30888db --- /dev/null +++ b/src/providers/zed.ts @@ -0,0 +1,233 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import zlib from 'zlib' + +import { calculateCost } from '../models.js' +import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +// Zed's built-in agent stores one row per thread in a single SQLite database; +// the `data` blob is zstd-compressed JSON carrying `request_token_usage` +// (per-request Anthropic-shaped token counts) and the thread's model. +// Format documented in issue #480. + +// zstd landed in node:zlib in 22.15 / 23.8; the package floor is 22.13, so the +// provider degrades with a notice instead of assuming the export exists. +const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync + +function getZedThreadsDbPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db') + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Local', 'Zed', 'threads', 'threads.db') + } + return join(homedir(), '.local', 'share', 'zed', 'threads', 'threads.db') +} + +const THREADS_QUERY = ` + SELECT id, CAST(summary AS BLOB) AS summary, updated_at, data_type, data + FROM threads + ORDER BY updated_at ASC +` + +type ThreadRow = { + id: string + // summary is Zed's auto-generated thread title (conversation-derived free + // text). Read as a BLOB and decode defensively: node:sqlite aborts the whole + // process (uncatchable V8 CHECK) if a TEXT column holds invalid UTF-8, which + // would kill every other provider in the same scan. See src/sqlite.ts. + summary: Uint8Array | string | null + updated_at: string | null + data_type: string | null + data: Uint8Array | null +} + +type TokenUsage = { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +type ThreadJson = { + model?: { provider?: string; model?: string } + request_token_usage?: Record + cumulative_token_usage?: TokenUsage +} + +function num(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +function usageIsEmpty(usage: TokenUsage): boolean { + return ( + num(usage.input_tokens) === 0 && + num(usage.output_tokens) === 0 && + num(usage.cache_creation_input_tokens) === 0 && + num(usage.cache_read_input_tokens) === 0 + ) +} + +function buildCall(opts: { + threadId: string + requestKey: string + usage: TokenUsage + model: string + timestamp: string + userMessage: string +}): ParsedProviderCall { + const input = num(opts.usage.input_tokens) + const output = num(opts.usage.output_tokens) + const cacheWrite = num(opts.usage.cache_creation_input_tokens) + const cacheRead = num(opts.usage.cache_read_input_tokens) + return { + provider: 'zed', + model: opts.model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(opts.model, input, output, cacheWrite, cacheRead, 0), + tools: [], + bashCommands: [], + timestamp: opts.timestamp, + speed: 'standard', + deduplicationKey: `zed:${opts.threadId}:${opts.requestKey}`, + userMessage: opts.userMessage, + sessionId: opts.threadId, + } +} + +function parseThreads(db: SqliteDatabase, seenKeys: Set): ParsedProviderCall[] { + const calls: ParsedProviderCall[] = [] + let skipped = 0 + + let rows: ThreadRow[] + try { + rows = db.query(THREADS_QUERY) + } catch { + return calls + } + + for (const row of rows) { + try { + // Zed's DataType enum is "zstd" (current save path) or "json" (legacy + // uncompressed rows); anything else is unknown. + if (!row.id || !row.data || (row.data_type !== 'zstd' && row.data_type !== 'json')) { + if (row.data != null) skipped++ + continue + } + const parsedAt = new Date(row.updated_at ?? '') + if (Number.isNaN(parsedAt.getTime())) continue + const timestamp = parsedAt.toISOString() + + const jsonText = row.data_type === 'zstd' + ? zstdDecompress!(Buffer.from(row.data)).toString('utf-8') + : Buffer.from(row.data).toString('utf-8') + const thread = JSON.parse(jsonText) as ThreadJson + const model = thread.model?.model || 'unknown' + const userMessage = blobToText(row.summary) + + const requests = Object.entries(thread.request_token_usage ?? {}).filter(([, usage]) => usage != null && !usageIsEmpty(usage)) + // The per-request map is keyed by user message and does not cover every + // request (verified on a real thread: cumulative was ~3x the map sum), + // so a remainder entry tops the thread up to the exact cumulative + // counter. Threads with an empty map degrade to one cumulative call. + const entries: Array<[string, TokenUsage]> = [...requests] + const cumulative = thread.cumulative_token_usage + if (cumulative && !usageIsEmpty(cumulative)) { + let sumIn = 0, sumOut = 0, sumWrite = 0, sumRead = 0 + for (const [, usage] of requests) { + sumIn += num(usage.input_tokens) + sumOut += num(usage.output_tokens) + sumWrite += num(usage.cache_creation_input_tokens) + sumRead += num(usage.cache_read_input_tokens) + } + const remainder: TokenUsage = { + input_tokens: Math.max(0, num(cumulative.input_tokens) - sumIn), + output_tokens: Math.max(0, num(cumulative.output_tokens) - sumOut), + cache_creation_input_tokens: Math.max(0, num(cumulative.cache_creation_input_tokens) - sumWrite), + cache_read_input_tokens: Math.max(0, num(cumulative.cache_read_input_tokens) - sumRead), + } + if (!usageIsEmpty(remainder)) entries.push(['cumulative-remainder', remainder]) + } + + for (const [requestKey, usage] of entries) { + const call = buildCall({ threadId: row.id, requestKey, usage, model, timestamp, userMessage }) + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + calls.push(call) + } + } catch { + skipped++ + } + } + + if (skipped > 0) { + process.stderr.write(`codeburn: skipped ${skipped} unreadable Zed threads\n`) + } + return calls +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + if (!zstdDecompress) { + process.stderr.write('codeburn: Zed threads need Node >= 22.15 (zstd support); skipping Zed usage.\n') + return + } + + let db: SqliteDatabase + try { + db = openDatabase(source.path) + } catch (err) { + process.stderr.write(`codeburn: cannot open Zed database: ${err instanceof Error ? err.message : err}\n`) + return + } + try { + for (const call of parseThreads(db, seenKeys)) { + yield call + } + } finally { + db.close() + } + }, + } +} + +export function createZedProvider(dbPathOverride?: string): Provider { + return { + name: 'zed', + displayName: 'Zed', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbPath = dbPathOverride ?? getZedThreadsDbPath() + if (!existsSync(dbPath)) return [] + return [{ path: dbPath, project: 'zed', provider: 'zed' }] + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const zed = createZedProvider() diff --git a/src/providers/zerostack.ts b/src/providers/zerostack.ts new file mode 100644 index 0000000..f23d82e --- /dev/null +++ b/src/providers/zerostack.ts @@ -0,0 +1,171 @@ +import { readdir } from 'fs/promises' +import { basename, join } from 'path' +import { homedir, platform } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// zerostack (https://github.com/gi-dellav/zerostack) is a minimal Rust coding +// agent. Each session is a single JSON file under /zerostack/sessions/. +// Token counts are stored as CUMULATIVE session totals (total_input_tokens, +// total_output_tokens, total_cost) — there is no per-call breakdown — so we emit +// one ParsedProviderCall per session. + +const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + write: 'Write', + edit: 'Edit', + grep: 'Grep', + glob: 'Glob', + fetch: 'WebFetch', + search: 'WebSearch', + task: 'Agent', +} + +type ZerostackMessage = { + role?: string + content?: string | Array<{ text?: string }> +} + +type ZerostackSession = { + id?: string + messages?: ZerostackMessage[] + created_at?: string + updated_at?: string + total_input_tokens?: number + total_output_tokens?: number + // Anthropic-native route (provider: 'anthropic' + prompt caching): zerostack + // keeps total_input_tokens as the RAW non-cache prompt and bills cache read/ + // write separately (0.10x / 1.25x). Dropping these undercounts Claude sessions + // ~10x. For OpenRouter/OpenAI/Gemini routes input already folds cache in and + // these are absent (default 0), so emitting them is correct for every route. + total_cached_input_tokens?: number + total_cache_creation_input_tokens?: number + model?: string + provider?: string + working_dir?: string +} + +// zerostack uses the platform data dir (Rust `dirs::data_dir`): macOS maps to +// ~/Library/Application Support, everything else to $XDG_DATA_HOME or +// ~/.local/share, then a `zerostack` subdir. ZS_DATA_DIR overrides the whole +// data dir (sessions live directly under it). Matches src/session/storage.rs. +function defaultSessionsDir(): string { + const override = process.env['ZS_DATA_DIR'] + if (override) return join(override, 'sessions') + const base = + platform() === 'darwin' + ? join(homedir(), 'Library', 'Application Support') + : process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return join(base, 'zerostack', 'sessions') +} + +function firstUserMessage(messages: ZerostackMessage[]): string { + const msg = messages.find(m => m.role === 'user') + if (!msg) return '' + if (typeof msg.content === 'string') return msg.content + return (msg.content ?? []).map(c => c.text ?? '').filter(Boolean).join(' ') +} + +async function readSession(path: string): Promise { + const content = await readSessionFile(path) + if (content === null) return null + try { + return JSON.parse(content) as ZerostackSession + } catch { + return null + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const session = await readSession(source.path) + if (!session) return + + const input = session.total_input_tokens ?? 0 + const output = session.total_output_tokens ?? 0 + const cacheRead = session.total_cached_input_tokens ?? 0 + const cacheCreation = session.total_cache_creation_input_tokens ?? 0 + if (input === 0 && output === 0 && cacheRead === 0 && cacheCreation === 0) return + + const timestamp = session.updated_at ?? session.created_at ?? '' + const sessionId = session.id ?? basename(source.path, '.json') + const dedupKey = `${source.provider}:${source.path}:${timestamp}:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const model = session.model ?? '' + + yield { + provider: source.provider, + model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheCreation, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(model, input, output, cacheCreation, cacheRead, 0), + // zerostack persists only final assistant text, not tool-call records, + // so there is nothing to extract here. + tools: [], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: firstUserMessage(session.messages ?? []), + sessionId, + project: source.project, + projectPath: session.working_dir, + } + }, + } +} + +export function createZerostackProvider(sessionsDir?: string): Provider { + const dir = sessionsDir ?? defaultSessionsDir() + + return { + name: 'zerostack', + displayName: 'Zerostack', + + modelDisplayName(model: string): string { + // OpenRouter routes arrive prefixed (e.g. "deepseek/deepseek-v4-pro"). + return getShortModelName(model.replace(/^[^/]+\//, '')) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + let files: string[] + try { + files = await readdir(dir) + } catch { + return [] + } + + const sources: SessionSource[] = [] + for (const file of files) { + if (!file.endsWith('.json')) continue + const path = join(dir, file) + const session = await readSession(path) + if (!session) continue + const project = session.working_dir ? basename(session.working_dir) : basename(file, '.json') + sources.push({ path, project, provider: 'zerostack' }) + } + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const zerostack = createZerostackProvider() diff --git a/src/sharing/client.ts b/src/sharing/client.ts new file mode 100644 index 0000000..fd96246 --- /dev/null +++ b/src/sharing/client.ts @@ -0,0 +1,111 @@ +import { request } from 'https' +import type { TLSSocket } from 'tls' + +import { certFingerprint } from './pairing.js' +import type { Identity } from './identity.js' +import type { UsageQuery } from './share-server.js' + +// req.setTimeout only arms once connected; it does not abort a stalled TCP +// connect, so an unreachable peer would otherwise ride the OS connect timeout +// (~75s on macOS). Cap the TCP-connect phase separately; it clears the instant +// the socket connects, so the TLS handshake and the read/approval timeouts +// (req.setTimeout) are unaffected even over a slow VPN link. +const CONNECT_TIMEOUT_MS = 3000 + +export type PeerEndpoint = { + identity: Identity // our own identity (we present our cert so the peer can bind a token to us) + host: string + port: number + // When set, the connection is aborted unless the peer's cert fingerprint matches. + expectedFingerprint?: string +} + +export type Response = { status: number; serverFingerprint: string; json: unknown } + +// One request to a peer. Self-signed certs are accepted at the TLS layer +// (rejectUnauthorized:false) but the peer is authenticated by pinning its cert +// fingerprint, the SSH/Syncthing trust-on-first-use model. +function call( + ep: PeerEndpoint, + method: string, + path: string, + headers: Record = {}, + body?: string, + timeoutMs = 15000, +): Promise { + return new Promise((resolve, reject) => { + const req = request( + { + host: ep.host, + port: ep.port, + method, + path, + key: ep.identity.key, + cert: ep.identity.cert, + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + // Fresh socket per request so the pinned-fingerprint check always reads + // this connection's certificate, never a pooled/keep-alive one. + agent: false, + headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) }, + }, + (res) => { + const cert = (res.socket as TLSSocket).getPeerCertificate?.() + const serverFingerprint = cert?.raw ? certFingerprint(cert.raw) : '' + if (ep.expectedFingerprint && serverFingerprint !== ep.expectedFingerprint) { + res.destroy() + reject(new Error('server fingerprint mismatch')) + return + } + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + res.on('end', () => resolve({ status: res.statusCode ?? 0, serverFingerprint, json: safeJson(data) })) + }, + ) + req.on('error', reject) + req.setTimeout(timeoutMs, () => req.destroy(new Error('peer timed out'))) + const connectTimer = setTimeout(() => req.destroy(new Error('peer unreachable')), CONNECT_TIMEOUT_MS) + connectTimer.unref() + req.once('socket', (socket) => { + const clear = () => clearTimeout(connectTimer) + socket.once('connect', clear) + socket.once('secureConnect', clear) + }) + req.once('close', () => clearTimeout(connectTimer)) + if (body) req.write(body) + req.end() + }) +} + +export function hello(ep: PeerEndpoint): Promise { + return call(ep, 'GET', '/api/peer/hello') +} + +export function pair(ep: PeerEndpoint, pin: string, name: string): Promise { + return call(ep, 'POST', '/api/peer/pair', {}, JSON.stringify({ pin, name })) +} + +// Approve-style pairing: no PIN. The peer prompts its user to approve; this +// request stays open until they accept or decline. +export function pairRequest(ep: PeerEndpoint, name: string): Promise { + // Stays open while the peer's user decides; give it longer than the server's + // 60s approval prompt. + return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }), 65_000) +} + +export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(query)) if (v) params.set(k, v) + const qs = params.toString() + return call(ep, 'GET', `/api/usage${qs ? `?${qs}` : ''}`, { authorization: `Bearer ${token}` }) +} + +function safeJson(s: string): unknown { + try { + return JSON.parse(s) + } catch { + return null + } +} diff --git a/src/sharing/discovery.ts b/src/sharing/discovery.ts new file mode 100644 index 0000000..0c8b33a --- /dev/null +++ b/src/sharing/discovery.ts @@ -0,0 +1,85 @@ +// bonjour-service ships `export = Bonjour` (a class merged with a namespace), +// so import the default and derive the Service instance type from the namespace +// rather than named type imports (which resolve to values under these typings). +import Bonjour from 'bonjour-service' + +type Service = InstanceType + +const SERVICE_TYPE = 'codeburn' + +export type DiscoveredDevice = { name: string; host: string; port: number; fingerprint: string } + +export type Advertiser = { stop: () => Promise } + +// Announce this device on the local network so others can find it without an IP. +export function advertise(opts: { name: string; port: number; fingerprint: string }): Advertiser { + const bonjour = new Bonjour() + bonjour.publish({ + name: opts.name, + type: SERVICE_TYPE, + port: opts.port, + txt: { fp: opts.fingerprint, dn: opts.name, v: '1' }, + }) + return { + stop: () => + new Promise((resolve) => { + bonjour.unpublishAll(() => bonjour.destroy(() => resolve())) + }), + } +} + +function pickAddress(service: Service): string | null { + const addrs = service.addresses ?? [] + const ipv4 = addrs.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) + if (ipv4) return ipv4 + if (service.host) return service.host + return addrs[0] ?? null +} + +// Browse the local network for sharing devices for `timeoutMs`. Resolves to the +// devices found, deduped by fingerprint. +export function browse(timeoutMs = 2500): Promise { + return new Promise((resolve) => { + const found = new Map() + let done = false + let timer: ReturnType | null = null + let browser: { stop: () => void } | null = null + let bonjour: Bonjour | null = null + + const finish = (devices: DiscoveredDevice[]) => { + if (done) return + done = true + if (timer) clearTimeout(timer) + browser?.stop() + if (!bonjour) { + resolve(devices) + return + } + try { + bonjour.destroy(() => resolve(devices)) + } catch { + resolve(devices) + } + } + + const finishWithError = (err?: unknown) => { + if (err) console.error(`codeburn devices scan: mDNS discovery failed: ${err instanceof Error ? err.message : String(err)}`) + else console.error('codeburn devices scan: mDNS discovery failed') + finish([...found.values()]) + } + + bonjour = new Bonjour({}, finishWithError) + const mdns = (bonjour as unknown as { server?: { mdns?: { on: (event: string, cb: () => void) => void } } }).server?.mdns + mdns?.on('error', finishWithError) + browser = bonjour.find({ type: SERVICE_TYPE }, (service) => { + const txt = (service.txt ?? {}) as Record + const fingerprint = txt['fp'] + const address = pickAddress(service) + if (!fingerprint || !address) return + const name = txt['dn'] || service.name || address + found.set(fingerprint, { name, host: address, port: service.port, fingerprint }) + }) + timer = setTimeout(() => finish([...found.values()]), timeoutMs) + timer.unref?.() + }) +} diff --git a/src/sharing/host.ts b/src/sharing/host.ts new file mode 100644 index 0000000..f5aaf3e --- /dev/null +++ b/src/sharing/host.ts @@ -0,0 +1,245 @@ +import { hello, pair, pairRequest, fetchUsage } from './client.js' +import { loadOrCreateIdentity } from './identity.js' +import { pairingCode } from './pairing.js' +import { sanitizeForSharing } from './sanitize.js' +import type { DiscoveredDevice } from './discovery.js' +import type { UsageQuery } from './share-server.js' +import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js' +import type { CombinedUsage, DeviceSummary, MenubarPayload } from '../menubar-json.js' +import { formatCost } from '../currency.js' +import { renderTable } from '../text-table.js' +import { Chalk } from 'chalk' + +export type { CombinedUsage, DeviceSummary } from '../menubar-json.js' + +// Minimal shape we read from a device's usage payload (the menubar payload). +// Cache read/write come from the period-scoped `current` (like input/output) +// when the peer sends them; older peers only carry cache in the daily history, +// so we fall back to summing that (scoped by `window` when provided). +type DevicePayload = { + current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number } + history?: { daily?: Array<{ date?: string; cacheReadTokens?: number; cacheWriteTokens?: number }> } +} + +type SummaryWindow = { + start: string + end: string +} + +export type DeviceUsage = { + id: string // stable unique id (cert fingerprint for remotes, 'local' for this device) + name: string + local: boolean + payload?: DevicePayload + error?: string +} + +const zeroUsage = { + cost: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, +} + +function num(n: number | undefined): number { + return n ?? 0 +} + +function summarizeOneDevice(d: DeviceUsage, window?: SummaryWindow): DeviceSummary { + const error = d.error !== undefined ? d.error : d.payload === undefined ? 'no usage payload' : undefined + if (error !== undefined || d.payload === undefined) { + return { + id: d.id, + name: d.name, + local: d.local, + error, + ...zeroUsage, + } + } + + const cur = d.payload.current + const daily = (d.payload.history?.daily ?? []).filter((e) => { + if (window === undefined) return true + return e.date !== undefined && window.start <= e.date && e.date <= window.end + }) + const inputTokens = num(cur?.inputTokens) + const outputTokens = num(cur?.outputTokens) + // Prefer the period-scoped `current` counts (issue #583); fall back to the + // windowed daily history for older peers that don't send them. `??` keeps a + // genuine 0 and only falls back when the field is absent. + const cacheCreateTokens = cur?.cacheWriteTokens ?? daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0) + const cacheReadTokens = cur?.cacheReadTokens ?? daily.reduce((s, e) => s + num(e.cacheReadTokens), 0) + return { + id: d.id, + name: d.name, + local: d.local, + cost: num(cur?.cost), + calls: num(cur?.calls), + sessions: num(cur?.sessions), + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens, + } +} + +export function summarizeDeviceUsage(results: DeviceUsage[], window?: SummaryWindow): CombinedUsage { + const perDevice = results.map((d) => summarizeOneDevice(d, window)) + const combined = perDevice.reduce( + (a, d) => { + if (d.error !== undefined) return a + return { + cost: a.cost + d.cost, + calls: a.calls + d.calls, + sessions: a.sessions + d.sessions, + inputTokens: a.inputTokens + d.inputTokens, + outputTokens: a.outputTokens + d.outputTokens, + cacheCreateTokens: a.cacheCreateTokens + d.cacheCreateTokens, + cacheReadTokens: a.cacheReadTokens + d.cacheReadTokens, + totalTokens: a.totalTokens + d.totalTokens, + deviceCount: a.deviceCount, + reachableCount: a.reachableCount + 1, + } + }, + { ...zeroUsage, deviceCount: perDevice.length, reachableCount: 0 }, + ) + return { perDevice, combined } +} + +function parseHostPort(input: string, defaultPort: number): { host: string; port: number } { + const idx = input.lastIndexOf(':') + if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) { + return { host: input.slice(0, idx), port: Number(input.slice(idx + 1)) } + } + return { host: input, port: defaultPort } +} + +// Pair with a device the user is currently sharing (PIN shown on that device), +// pin its fingerprint, store the issued token, and persist it. +export async function addRemote( + input: string, + pin: string, + opts: { defaultPort: number; dir?: string }, +): Promise { + const dir = opts.dir ?? getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const { host, port } = parseHostPort(input, opts.defaultPort) + + const h = await hello({ identity, host, port }) + if (h.status !== 200) throw new Error(`could not reach a CodeBurn device at ${host}:${port}`) + const info = h.json as { fingerprint: string; name: string } + + const pr = await pair({ identity, host, port, expectedFingerprint: info.fingerprint }, pin, identity.name) + if (pr.status !== 200) { + const err = (pr.json as { error?: string })?.error ?? `HTTP ${pr.status}` + throw new Error(`pairing failed: ${err}`) + } + const token = (pr.json as { token: string }).token + + const device: RemoteDevice = { name: info.name, host, port, fingerprint: info.fingerprint, token, addedAt: Date.now() } + const remotes = (await loadRemotes(dir)).filter((r) => r.fingerprint !== device.fingerprint) + remotes.push(device) + await saveRemotes(remotes, dir) + return device +} + +// Pair with a discovered device using approve-style pairing (no PIN). The owner +// of that device approves on their screen after confirming the matching code. +export async function linkRemote( + d: DiscoveredDevice, + opts: { dir?: string; onCode?: (code: string) => void } = {}, +): Promise { + const dir = opts.dir ?? getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const code = pairingCode(identity.fingerprint, d.fingerprint) + opts.onCode?.(code) + const r = await pairRequest({ identity, host: d.host, port: d.port, expectedFingerprint: d.fingerprint }, identity.name) + if (r.status !== 200) { + throw new Error(r.status === 403 ? 'the other device declined' : `pairing failed (HTTP ${r.status})`) + } + const token = (r.json as { token: string }).token + const device: RemoteDevice = { name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint, token, addedAt: Date.now() } + const remotes = (await loadRemotes(dir)).filter((x) => x.fingerprint !== device.fingerprint) + remotes.push(device) + await saveRemotes(remotes, dir) + return device +} + +// Pull this machine's usage plus every paired remote's, each kept separate. +export async function pullDevices( + localGetUsage: (q: UsageQuery) => Promise, + query: UsageQuery, + localName: string, + opts: { dir?: string } = {}, +): Promise { + const dir = opts.dir ?? getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const remotes = await loadRemotes(dir) + + const local: DeviceUsage = { id: 'local', name: localName, local: true, payload: await localGetUsage(query) } + // Pull every remote concurrently and isolate failures, so one slow or + // powered-off device degrades to an error row instead of blocking the rest. + const remoteResults = await Promise.all( + remotes.map(async (r): Promise => { + try { + const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query) + // Re-sanitize on receipt: do not trust the sender to have stripped its + // own project names/sessions (it may run an older build). Belt and + // suspenders alongside the sender-side sanitize. + if (res.status === 200) return { id: r.fingerprint, name: r.name, local: false, payload: sanitizeForSharing(res.json as MenubarPayload) } + return { id: r.fingerprint, name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` } + } catch (e) { + return { id: r.fingerprint, name: r.name, local: false, error: e instanceof Error ? e.message : String(e) } + } + }), + ) + return [local, ...remoteResults] +} + +// Joined "Totals by machine" report: one row per device plus a bold Combined +// row. Tokens are shown as full, comma-grouped numbers. +export function renderDevices(results: DeviceUsage[]): string { + const n = (x: number): string => Math.round(x).toLocaleString() + const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') + const summary = summarizeDeviceUsage(results) + const rows = summary.perDevice.map((d) => ({ + name: d.name + (d.local ? ' (this Mac)' : ''), + error: d.error, + cost: d.cost, + input: d.inputTokens, + output: d.outputTokens, + cacheCreate: d.cacheCreateTokens, + cacheRead: d.cacheReadTokens, + total: d.totalTokens, + })) + const combined = summary.combined + + const tableRows = [ + ...rows.map((r) => + r.error + ? [r.name, r.error, '-', '-', '-', '-', '-'] + : [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)], + ), + ['Combined', money(combined.cost), n(combined.totalTokens), n(combined.inputTokens), n(combined.outputTokens), n(combined.cacheCreateTokens), n(combined.cacheReadTokens)], + ] + const table = renderTable( + [ + { header: 'Host' }, + { header: 'Cost', right: true }, + { header: 'Total tokens', right: true }, + { header: 'Input', right: true }, + { header: 'Output', right: true }, + { header: 'Cache create', right: true }, + { header: 'Cache read', right: true }, + ], + tableRows, + { boldRows: new Set([tableRows.length - 1]) }, + ) + const heading = new Chalk({}).cyan('Totals by machine') + return heading + '\n' + table + '\n' +} diff --git a/src/sharing/identity.ts b/src/sharing/identity.ts new file mode 100644 index 0000000..0ae7399 --- /dev/null +++ b/src/sharing/identity.ts @@ -0,0 +1,58 @@ +import * as selfsigned from 'selfsigned' +import { X509Certificate } from 'crypto' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { hostname } from 'os' + +import { certFingerprint } from './pairing.js' + +// A device's stable identity: a self-signed TLS keypair whose certificate +// fingerprint is the trust anchor (trust-on-first-use). No CA. +export type Identity = { + key: string // private key PEM + cert: string // certificate PEM + fingerprint: string // SHA-256 hex of the certificate DER + name: string // human label (defaults to the hostname) +} + +export async function generateIdentity(name: string = hostname()): Promise { + const attrs = [{ name: 'commonName', value: 'codeburn-device' }] + // @types/selfsigned is missing `days`; the runtime accepts it. selfsigned >=5 + // resolves a Promise of { private, public, cert, fingerprint }. + const genOpts = { days: 3650, keySize: 2048, algorithm: 'sha256' } as unknown as Parameters< + typeof selfsigned.generate + >[1] + const pems = (await (selfsigned.generate(attrs, genOpts) as unknown as Promise<{ private: string; cert: string }>)) + const der = new X509Certificate(pems.cert).raw + return { key: pems.private, cert: pems.cert, fingerprint: certFingerprint(der), name } +} + +// Load the device identity from `dir`, creating and persisting it on first run. +export async function loadOrCreateIdentity(dir: string, name?: string): Promise { + const keyPath = join(dir, 'device-key.pem') + const certPath = join(dir, 'device-cert.pem') + const namePath = join(dir, 'device-name') + + if (existsSync(keyPath) && existsSync(certPath)) { + const [key, cert] = await Promise.all([readFile(keyPath, 'utf8'), readFile(certPath, 'utf8')]) + let resolvedName = name ?? hostname() + try { + const stored = (await readFile(namePath, 'utf8')).trim() + if (stored) resolvedName = name ?? stored + } catch { + /* no stored name yet */ + } + const der = new X509Certificate(cert).raw + return { key, cert, fingerprint: certFingerprint(der), name: resolvedName } + } + + const id = await generateIdentity(name) + await mkdir(dir, { recursive: true }) + await Promise.all([ + writeFile(keyPath, id.key, { mode: 0o600 }), + writeFile(certPath, id.cert), + writeFile(namePath, id.name), + ]) + return id +} diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts new file mode 100644 index 0000000..b912aad --- /dev/null +++ b/src/sharing/pairing.ts @@ -0,0 +1,120 @@ +import { randomBytes, createHash, timingSafeEqual } from 'crypto' + +// Device identity is the SHA-256 of its self-signed TLS certificate +// (trust-on-first-use, like SSH/Syncthing). No certificate authority involved: +// once two devices have each other's fingerprint, that pin is the trust anchor. +export function certFingerprint(cert: Buffer | string): string { + const buf = typeof cert === 'string' ? Buffer.from(cert) : cert + return createHash('sha256').update(buf).digest('hex') +} + +// Short, human-typed pairing PIN: 6 uniform digits. Rejection-sampled so the +// distribution is even (no modulo bias across 0..999999). +export function generatePin(): string { + const limit = Math.floor(0xffffffff / 1_000_000) * 1_000_000 + let n = randomBytes(4).readUInt32BE(0) + while (n >= limit) n = randomBytes(4).readUInt32BE(0) + return (n % 1_000_000).toString().padStart(6, '0') +} + +export function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +export function mintToken(): string { + return randomBytes(32).toString('base64url') +} + +// Short confirmation code shown on BOTH devices during an approve-style pairing. +// Derived from the two cert fingerprints, so a man-in-the-middle (whose cert +// differs) yields a different code; the user confirms the codes match. This is +// the Bluetooth/SAS "do these numbers match?" check, not a secret. +// +// 6 digits (1e6 values), not 3: a 3-digit code (1000 values) has a >50% birthday +// collision at only ~31 devices, so an attacker spawning many fake devices could +// land one whose code matches the legitimate peer's and fish for a mistaken +// approval. 6 digits pushes that threshold past ~1000 devices. Modulo bias is +// irrelevant here — the value is a deterministic comparison token, not a secret. +export function pairingCode(fingerprintA: string, fingerprintB: string): string { + const [lo, hi] = [fingerprintA, fingerprintB].sort() + const digest = createHash('sha256').update(`${lo}|${hi}`).digest() + return (digest.readUInt32BE(0) % 1_000_000).toString().padStart(6, '0') +} + +// An open pairing window on the device being added: a one-time PIN that expires. +// `now` is injectable so the lifecycle is deterministic in tests. +export class PairingWindow { + readonly pin: string + readonly openedAt: number + private used = false + private attempts = 0 + + constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin(), maxAttempts = 5) { + this.ttlMs = ttlMs + this.pin = pin + this.openedAt = now + this.maxAttempts = maxAttempts + } + + private readonly ttlMs: number + private readonly maxAttempts: number + + isOpen(now: number = Date.now()): boolean { + return !this.used && now - this.openedAt <= this.ttlMs + } + + // Verify a submitted PIN. A correct match consumes the window (one-time use). + // Wrong guesses are counted and the window closes after maxAttempts, so a + // 6-digit PIN cannot be brute-forced by a LAN peer within the TTL. + verify(pin: string, now: number = Date.now()): boolean { + if (!this.isOpen(now)) return false + if (!constantTimeEqual(pin, this.pin)) { + this.attempts += 1 + if (this.attempts >= this.maxAttempts) this.used = true + return false + } + this.used = true + return true + } +} + +export type PairedPeer = { + fingerprint: string + name: string + token: string + pairedAt: number +} + +// The devices this device trusts. A pull is authorized only when BOTH the +// bearer token AND the TLS peer fingerprint match the same paired peer, so a +// token stolen and replayed from a different device is useless on its own. +export class PeerStore { + private byFingerprint = new Map() + + constructor(peers: PairedPeer[] = []) { + for (const p of peers) this.byFingerprint.set(p.fingerprint, p) + } + + list(): PairedPeer[] { + return [...this.byFingerprint.values()] + } + + pair(fingerprint: string, name: string, now: number = Date.now()): PairedPeer { + const peer: PairedPeer = { fingerprint, name, token: mintToken(), pairedAt: now } + this.byFingerprint.set(fingerprint, peer) + return peer + } + + authorize(token: string, fingerprint: string): boolean { + const peer = this.byFingerprint.get(fingerprint) + if (!peer) return false + return constantTimeEqual(token, peer.token) + } + + unpair(fingerprint: string): boolean { + return this.byFingerprint.delete(fingerprint) + } +} diff --git a/src/sharing/prompt.ts b/src/sharing/prompt.ts new file mode 100644 index 0000000..4f58a30 --- /dev/null +++ b/src/sharing/prompt.ts @@ -0,0 +1,30 @@ +import { createInterface } from 'readline' + +export function promptYesNo(question: string, timeoutMs?: number): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + let settled = false + const finish = (value: boolean): void => { + if (settled) return + settled = true + rl.close() + resolve(value) + } + if (timeoutMs) { + const t = setTimeout(() => finish(false), timeoutMs) + t.unref?.() + } + rl.question(`${question} [Y/n] `, (answer) => finish(!/^\s*n/i.test(answer))) + }) +} + +export function promptChoice(question: string, max: number): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + rl.question(`${question} `, (answer) => { + rl.close() + const n = Number.parseInt(answer.trim(), 10) + resolve(Number.isInteger(n) && n >= 1 && n <= max ? n : -1) + }) + }) +} diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts new file mode 100644 index 0000000..f8aa123 --- /dev/null +++ b/src/sharing/sanitize.ts @@ -0,0 +1,45 @@ +import type { MenubarPayload } from '../menubar-json.js' + +// The fork's menubar payload is aggregate-only: cost/token/call totals plus +// fixed category labels, vendor model names, and provider names — none of which +// identify what you are working on. Unlike upstream's payload it carries no +// topProjects / topSessions / history.timeline.sessionSeries, so there is +// structurally nothing project- or session-identifying to strip today. +// +// This function is nonetheless the single ENFORCED egress boundary for usage +// leaving the device. It is applied sender-side before transmit AND again on +// receipt (an older/newer peer is not trusted to have stripped its own data). +// It defensively neutralizes the upstream identifying shapes should they ever +// be ported into the fork payload, so introducing one of those fields can never +// silently start leaking it off the machine. On today's payload it is a no-op. +type Identifying = MenubarPayload & { + current: MenubarPayload['current'] & { topProjects?: unknown; topSessions?: unknown } + history: MenubarPayload['history'] & { + timeline?: { sessionSeries?: unknown; points?: Array<{ sessions?: unknown }> } & Record + } +} + +export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { + const p = payload as Identifying + const current: Identifying['current'] = { ...p.current } + // Only touch fields that are actually present, so this stays a clean no-op on + // the fork payload and a real scrub if the identifying shapes are ever ported. + if ('topProjects' in current) current.topProjects = [] + if ('topSessions' in current) current.topSessions = [] + + const timeline = p.history?.timeline + const history: Identifying['history'] = { + ...p.history, + ...(timeline + ? { + timeline: { + ...timeline, + sessionSeries: [], + points: (timeline.points ?? []).map((pt) => ({ ...pt, sessions: [] })), + }, + } + : {}), + } + + return { ...p, current, history } +} diff --git a/src/sharing/share-controller.ts b/src/sharing/share-controller.ts new file mode 100644 index 0000000..b6c1917 --- /dev/null +++ b/src/sharing/share-controller.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'crypto' + +import { loadOrCreateIdentity, type Identity } from './identity.js' +import { PeerStore } from './pairing.js' +import { ShareServer, type PairRequest, type UsageQuery } from './share-server.js' +import { advertise } from './discovery.js' +import { getSharingDir, loadPeers, savePeers } from './store.js' + +export type PendingPairing = { id: string; name: string; code: string } +export type ShareStatus = { + sharing: boolean + name: string + port: number + always: boolean + peers: number + pending: PendingPairing[] +} + +const IDLE_TIMEOUT_MS = 10 * 60_000 + +// Runs the secure share server inside the dashboard process so the user can +// turn sharing on/off from the browser. Incoming approve-style pairings are +// queued and surfaced to the UI instead of prompting a terminal. +export class ShareController { + private server: ShareServer | null = null + private ad: ReturnType | null = null + private peers: PeerStore | null = null + private identity: Identity | null = null + private always = false + private idleTimer: ReturnType | null = null + private lastActivity = 0 + private readonly dir = getSharingDir() + private readonly pending = new Map< + string, + { name: string; code: string; fingerprint: string; resolve: (ok: boolean) => void; timer: ReturnType } + >() + + constructor( + private readonly getUsage: (q: UsageQuery) => Promise, + private readonly port = 7777, + ) {} + + private async getIdentity(): Promise { + if (!this.identity) this.identity = await loadOrCreateIdentity(this.dir) + return this.identity + } + + isSharing(): boolean { + return !!this.server + } + + async start(always: boolean): Promise { + if (this.server) { + this.always = always + this.refreshIdleWatch() + return + } + const identity = await this.getIdentity() + this.peers = new PeerStore(await loadPeers(this.dir)) + const server = new ShareServer({ + identity, + peers: this.peers, + getUsage: this.getUsage, + onPaired: () => { + if (this.peers) void savePeers(this.peers.list(), this.dir) + }, + approve: (req) => this.enqueueApproval(req), + }) + // listen() can reject (e.g. EADDRINUSE); only commit state after it binds, + // so a failed start never leaves us reporting always/sharing incorrectly. + await server.listen(this.port, '0.0.0.0') + this.always = always + this.server = server + this.ad = advertise({ name: identity.name, port: this.port, fingerprint: identity.fingerprint }) + this.lastActivity = Date.now() + server.server.on('request', () => { + this.lastActivity = Date.now() + }) + this.refreshIdleWatch() + } + + private refreshIdleWatch(): void { + if (this.idleTimer) { + clearInterval(this.idleTimer) + this.idleTimer = null + } + if (this.always) return + this.idleTimer = setInterval(() => { + if (Date.now() - this.lastActivity > IDLE_TIMEOUT_MS) void this.stop() + }, 30_000) + this.idleTimer.unref?.() + } + + async stop(): Promise { + if (this.idleTimer) { + clearInterval(this.idleTimer) + this.idleTimer = null + } + for (const p of this.pending.values()) { + clearTimeout(p.timer) + p.resolve(false) + } + this.pending.clear() + await this.ad?.stop().catch(() => {}) + await this.server?.close().catch(() => {}) + this.ad = null + this.server = null + } + + private enqueueApproval(req: PairRequest): Promise { + // One outstanding request per device, and a hard cap, so a LAN peer cannot + // flood the approval prompt or bury a legitimate request. + for (const p of this.pending.values()) if (p.fingerprint === req.fingerprint) return Promise.resolve(false) + if (this.pending.size >= 8) return Promise.resolve(false) + return new Promise((resolve) => { + const id = randomUUID() + const timer = setTimeout(() => { + this.pending.delete(id) + resolve(false) + }, 60_000) + timer.unref?.() + this.pending.set(id, { name: req.name, code: req.code, fingerprint: req.fingerprint, resolve, timer }) + }) + } + + listPending(): PendingPairing[] { + return [...this.pending.entries()].map(([id, p]) => ({ id, name: p.name, code: p.code })) + } + + resolvePending(id: string, approve: boolean): boolean { + const p = this.pending.get(id) + if (!p) return false + clearTimeout(p.timer) + this.pending.delete(id) + p.resolve(approve) + return true + } + + async status(): Promise { + const identity = await this.getIdentity() + const peers = this.peers ? this.peers.list().length : (await loadPeers(this.dir)).length + return { + sharing: this.isSharing(), + name: identity.name, + port: this.port, + always: this.always, + peers, + pending: this.listPending(), + } + } +} diff --git a/src/sharing/share-run.ts b/src/sharing/share-run.ts new file mode 100644 index 0000000..6192f15 --- /dev/null +++ b/src/sharing/share-run.ts @@ -0,0 +1,94 @@ +import { networkInterfaces } from 'os' + +import { loadOrCreateIdentity } from './identity.js' +import { PeerStore } from './pairing.js' +import { ShareServer, type UsageQuery } from './share-server.js' +import { advertise } from './discovery.js' +import { promptYesNo } from './prompt.js' +import { sanitizeForSharing } from './sanitize.js' +import { getSharingDir, loadPeers, savePeers } from './store.js' +import { loadPricing } from '../models.js' +// Fork seam: the shared payload builder lives in usage-payload.ts (extracted +// from main.ts), not upstream's usage-aggregator.ts. +import { buildMenubarPayloadForRange } from '../usage-payload.js' +import { periodInfoFromQuery } from '../cli-date.js' + +function lanAddress(): string | null { + for (const list of Object.values(networkInterfaces())) { + for (const ni of list ?? []) { + if (ni.family === 'IPv4' && !ni.internal) return ni.address + } + } + return null +} + +const IDLE_TIMEOUT_MS = 10 * 60_000 + +// Run the secure share server. On-demand by default: it stops after 10 minutes +// of no requests. `--always` keeps it up until Ctrl+C (the opt-in persistent +// mode). `--pair` opens a one-time pairing window and prints the PIN + command. +export async function runShareServer(opts: { port: number; pair: boolean; always: boolean }): Promise { + await loadPricing() + const dir = getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const peers = new PeerStore(await loadPeers(dir)) + + const getUsage = async (q: UsageQuery): Promise => { + const periodInfo = periodInfoFromQuery(q, 'month') + return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })) + } + + const server = new ShareServer({ + identity, + peers, + getUsage, + onPaired: () => { + void savePeers(peers.list(), dir) + }, + approve: async (req) => { + process.stdout.write(`\n "${req.name}" wants your usage.\n`) + process.stdout.write(` Confirm this code matches on that device: ${req.code}\n`) + const ok = await promptYesNo(' Approve?', 60_000) + process.stdout.write(ok ? ` Approved "${req.name}".\n\n` : ` Declined "${req.name}".\n\n`) + return ok + }, + }) + + const port = await server.listen(opts.port, '0.0.0.0') + const ip = lanAddress() ?? '127.0.0.1' + const ad = advertise({ name: identity.name, port, fingerprint: identity.fingerprint }) + + const shutdown = async (): Promise => { + await ad.stop().catch(() => {}) + await server.close().catch(() => {}) + process.exit(0) + } + process.on('SIGINT', () => void shutdown()) + + process.stdout.write(`\n Sharing "${identity.name}" - discoverable on your network.\n`) + process.stdout.write(` On your other Mac, run: codeburn devices add\n`) + if (opts.pair) { + const pin = server.openPairing(120_000) + process.stdout.write(`\n Manual fallback (if discovery is blocked):\n`) + process.stdout.write(` codeburn devices add ${ip}:${port} --pin ${pin}\n`) + } + process.stdout.write(`\n ${peers.list().length} paired device(s). Press Ctrl+C to stop.\n\n`) + + if (!opts.always) { + let last = Date.now() + server.server.on('request', () => { + last = Date.now() + }) + const timer = setInterval(() => { + if (Date.now() - last > IDLE_TIMEOUT_MS) { + process.stdout.write('\n Idle, stopping share. Run `codeburn share` again when you need it.\n') + process.exit(0) + } + }, 30_000) + timer.unref() + } + + await new Promise(() => { + /* run until interrupted */ + }) +} diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts new file mode 100644 index 0000000..f56cce3 --- /dev/null +++ b/src/sharing/share-server.ts @@ -0,0 +1,193 @@ +import { createServer, type Server } from 'https' +import type { IncomingMessage, ServerResponse } from 'http' +import type { TLSSocket } from 'tls' +import type { AddressInfo } from 'net' + +import { UsageQueryError } from '../cli-date.js' +import { certFingerprint, pairingCode, PeerStore, PairingWindow } from './pairing.js' +import type { Identity } from './identity.js' + +export type UsageQuery = { period?: string; from?: string; to?: string } + +// An approve-style pairing request, surfaced to the user on the sharing device. +export type PairRequest = { name: string; fingerprint: string; code: string } + +export type ShareServerOptions = { + identity: Identity + peers: PeerStore + getUsage: (query: UsageQuery) => Promise + // Called after a successful pairing so the caller can persist the peer list. + onPaired?: () => void + // Enables the interactive approve flow (POST /api/peer/pair-request): return + // true to accept. The user confirms the matching `code` shown on both devices. + approve?: (req: PairRequest) => Promise +} + +// A device's HTTPS sharing endpoint. Mutual TLS: the server presents its own +// self-signed cert (clients pin its fingerprint) and requests the client's cert +// so it can bind tokens to the caller's fingerprint. A pull is served only when +// the bearer token AND the client cert fingerprint match the same paired peer. +export class ShareServer { + readonly server: Server + private pairing: PairingWindow | null = null + + constructor(private readonly opts: ShareServerOptions) { + this.server = createServer( + { key: opts.identity.key, cert: opts.identity.cert, requestCert: true, rejectUnauthorized: false }, + (req, res) => { + void this.handle(req, res) + }, + ) + // Swallow server-level socket/TLS errors (e.g. a malformed handshake from a + // LAN peer) so they can never crash the host process. `listen()` attaches + // its own one-time handler for bind failures. + this.server.on('error', () => {}) + this.server.on('tlsClientError', () => {}) + } + + // Open a one-time pairing window and return the PIN to show the user. + openPairing(ttlMs = 60_000): string { + this.pairing = new PairingWindow(ttlMs) + return this.pairing.pin + } + + closePairing(): void { + this.pairing = null + } + + listen(port: number, host = '0.0.0.0'): Promise { + return new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(port, host, () => resolve((this.server.address() as AddressInfo).port)) + }) + } + + close(): Promise { + return new Promise((resolve) => this.server.close(() => resolve())) + } + + private clientFingerprint(req: IncomingMessage): string | null { + const cert = (req.socket as TLSSocket).getPeerCertificate?.() + if (!cert || !cert.raw) return null + return certFingerprint(cert.raw) + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'https://localhost') + const json = (code: number, body: unknown): void => { + res.writeHead(code, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } + try { + await this.route(url, req, res, json) + } catch (err) { + // Never leave a request hanging (a hung peer makes the caller time out + // and drop this device); always answer, even on an internal error. + if (!res.headersSent) { + const message = err instanceof Error ? err.message : String(err) + json(err instanceof UsageQueryError ? 400 : 500, { error: message }) + } + } + } + + private async route( + url: URL, + req: IncomingMessage, + res: ServerResponse, + json: (code: number, body: unknown) => void, + ): Promise { + + // Unauthenticated: just enough for a joiner to learn who this is and whether + // pairing is currently open. No usage data here. + if (url.pathname === '/api/peer/hello' && req.method === 'GET') { + json(200, { + fingerprint: this.opts.identity.fingerprint, + name: this.opts.identity.name, + pairingOpen: !!this.pairing?.isOpen(), + }) + return + } + + if (url.pathname === '/api/peer/pair' && req.method === 'POST') { + const clientFp = this.clientFingerprint(req) + if (!clientFp) { + json(400, { error: 'client certificate required' }) + return + } + const body = safeJson(await readBody(req)) as { pin?: unknown; name?: unknown } | null + const pin = typeof body?.pin === 'string' ? body.pin : '' + const name = typeof body?.name === 'string' ? body.name : 'device' + if (!this.pairing || !this.pairing.verify(pin)) { + json(401, { error: 'invalid or expired PIN' }) + return + } + this.pairing = null + const peer = this.opts.peers.pair(clientFp, name) + this.opts.onPaired?.() + json(200, { token: peer.token, name: this.opts.identity.name, fingerprint: this.opts.identity.fingerprint }) + return + } + + if (url.pathname === '/api/peer/pair-request' && req.method === 'POST') { + const clientFp = this.clientFingerprint(req) + if (!clientFp) { + json(400, { error: 'client certificate required' }) + return + } + if (!this.opts.approve) { + json(403, { error: 'this device is not accepting new pairings' }) + return + } + const body = safeJson(await readBody(req)) as { name?: unknown } | null + const name = typeof body?.name === 'string' ? body.name : 'device' + const code = pairingCode(this.opts.identity.fingerprint, clientFp) + const approved = await this.opts.approve({ name, fingerprint: clientFp, code }) + if (!approved) { + json(403, { error: 'pairing declined' }) + return + } + const peer = this.opts.peers.pair(clientFp, name) + this.opts.onPaired?.() + json(200, { token: peer.token, name: this.opts.identity.name, fingerprint: this.opts.identity.fingerprint, code }) + return + } + + if (url.pathname === '/api/usage' && req.method === 'GET') { + const clientFp = this.clientFingerprint(req) + const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '') + if (!clientFp || !token || !this.opts.peers.authorize(token, clientFp)) { + json(401, { error: 'unauthorized' }) + return + } + const payload = await this.opts.getUsage({ + period: url.searchParams.get('period') ?? undefined, + from: url.searchParams.get('from') ?? undefined, + to: url.searchParams.get('to') ?? undefined, + }) + json(200, payload) + return + } + + json(404, { error: 'not found' }) + } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let data = '' + req.on('data', (chunk) => { + data += chunk + if (data.length > 1_000_000) req.destroy() // guard against oversized bodies + }) + req.on('end', () => resolve(data)) + req.on('error', () => resolve(data)) + }) +} + +function safeJson(s: string): unknown { + try { + return JSON.parse(s) + } catch { + return null + } +} diff --git a/src/sharing/store.ts b/src/sharing/store.ts new file mode 100644 index 0000000..2f52178 --- /dev/null +++ b/src/sharing/store.ts @@ -0,0 +1,64 @@ +import { readFile, writeFile, mkdir, chmod } from 'fs/promises' +import { join, dirname } from 'path' + +import { getConfigFilePath } from '../config.js' +import type { PairedPeer } from './pairing.js' + +// A device this host can pull FROM: its address, the pinned server-cert +// fingerprint, and the token issued to us during pairing. +export type RemoteDevice = { + name: string + host: string + port: number + fingerprint: string + token: string + addedAt: number +} + +// Sharing state lives next to the main config file. +export function getSharingDir(): string { + return join(dirname(getConfigFilePath()), 'sharing') +} + +async function readJson(path: string, fallback: T): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as T + } catch { + return fallback + } +} + +// These files hold bearer tokens, so keep them owner-only (0600) like the TLS +// private key. mkdir/writeFile modes only apply on creation, so chmod enforces +// it on files that already exist from an earlier version. +async function writeJson(path: string, data: unknown): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 }) + await chmod(path, 0o600).catch(() => {}) +} + +// Peers allowed to pull from this device (the sharing side, used by ShareServer). +export function loadPeers(dir: string = getSharingDir()): Promise { + return readJson(join(dir, 'paired-peers.json'), [] as PairedPeer[]) +} +export function savePeers(peers: PairedPeer[], dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'paired-peers.json'), peers) +} + +// Devices this host pulls from (the host side, used by `codeburn devices`). +export function loadRemotes(dir: string = getSharingDir()): Promise { + return readJson(join(dir, 'remote-devices.json'), [] as RemoteDevice[]) +} +export function saveRemotes(remotes: RemoteDevice[], dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'remote-devices.json'), remotes) +} + +// Whether the dashboard should keep sharing on (opt-in always-live). Persisted +// so `codeburn web` resumes the chosen state on launch. +export async function loadShareAlways(dir: string = getSharingDir()): Promise { + const s = await readJson(join(dir, 'web-share.json'), { always: false } as { always?: boolean }) + return !!s.always +} +export function saveShareAlways(always: boolean, dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'web-share.json'), { always }) +} diff --git a/src/text-table.ts b/src/text-table.ts new file mode 100644 index 0000000..c257dbd --- /dev/null +++ b/src/text-table.ts @@ -0,0 +1,43 @@ +import chalk from 'chalk' + +export type TableColumn = { + header: string + // Right-align the cell contents (numbers/costs). Left-aligned otherwise. + right?: boolean +} + +/** + * Render a fixed-width, whitespace-padded text table for terminal output. + * Each column is sized to the widest cell (header or body). `right` columns + * pad with padStart, everything else with padEnd. Rows whose index is in + * `opts.boldRows` are emphasized with chalk.bold (e.g. a Totals row). + */ +export function renderTable( + columns: TableColumn[], + body: string[][], + opts?: { boldRows?: Set }, +): string { + const boldRows = opts?.boldRows ?? new Set() + const widths = columns.map((col, i) => { + let w = col.header.length + for (const row of body) { + const cell = row[i] ?? '' + if (cell.length > w) w = cell.length + } + return w + }) + + const pad = (text: string, i: number): string => + columns[i]?.right ? text.padStart(widths[i]!) : text.padEnd(widths[i]!) + + const headerLine = columns.map((col, i) => pad(col.header, i)).join(' ') + const sepLine = widths.map((w) => '-'.repeat(w)).join(' ') + + const lines = [chalk.bold(headerLine), chalk.dim(sepLine)] + body.forEach((row, r) => { + const line = columns.map((_, i) => pad(row[i] ?? '', i)).join(' ') + lines.push(boldRows.has(r) ? chalk.bold(line) : line) + }) + + return lines.join('\n') +} diff --git a/src/usage-payload.ts b/src/usage-payload.ts new file mode 100644 index 0000000..065681e --- /dev/null +++ b/src/usage-payload.ts @@ -0,0 +1,197 @@ +// Shared period-aggregation + menubar-payload building, extracted from main.ts +// so non-CLI consumers (the device-sharing host/server) can build the same +// MenubarPayload for a date range without importing the CLI entrypoint (which +// runs program.parse() on import). buildMenubarPayloadForRange is the fork's +// stand-in for upstream's usage-aggregator.ts equivalent. (#532/#567) +import { parseAllSessions } from './parser.js' +import { type PeriodData, type ProviderCost, type MenubarPayload, type WebEnrichment, buildMenubarPayload } from './menubar-json.js' +import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import { codexCredits } from './codex-credits.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { getShortModelName } from './models.js' +import { buildTimeline } from './usage-timeline.js' + +const SYNTHETIC_MODEL = '' +const WEB_TOP_LIMIT = 20 +const WEB_PROJECT_LIMIT = 10 +const WEB_MODEL_EFFICIENCY_LIMIT = 15 + +/** + * The extra per-`current` breakdowns the web dashboard renders, aggregated from + * the fully-parsed sessions. Kept out of buildPeriodData/buildMenubarPayload so + * the menubar hot path and the device-sharing egress surface stay lean — these + * are attached only on the web path (buildMenubarPayloadForRange enrich:true). + * Every value is raw USD, matching the rest of the payload (the SPA formats it). + */ +export function buildWebEnrichment(projects: ProjectSummary[]): WebEnrichment { + const sessions = projects.flatMap(p => p.sessions) + + const toolCalls: Record = Object.create(null) + const mcpCalls: Record = Object.create(null) + const skillAgg: Record = Object.create(null) + let agentSpawnCalls = 0 + let agentSpawnCost = 0 + + for (const sess of sessions) { + for (const [tool, d] of Object.entries(sess.toolBreakdown)) toolCalls[tool] = (toolCalls[tool] ?? 0) + d.calls + for (const [server, d] of Object.entries(sess.mcpBreakdown)) mcpCalls[server] = (mcpCalls[server] ?? 0) + d.calls + for (const [skill, d] of Object.entries(sess.skillBreakdown)) { + if (!skillAgg[skill]) skillAgg[skill] = { turns: 0, cost: 0 } + skillAgg[skill].turns += d.turns + skillAgg[skill].cost += d.costUSD + } + for (const turn of sess.turns) { + for (const call of turn.assistantCalls ?? []) { + if (call.hasAgentSpawn) { agentSpawnCalls += 1; agentSpawnCost += Number.isFinite(call.costUSD) ? call.costUSD : 0 } + } + } + } + + const tools = Object.entries(toolCalls) + .sort(([, a], [, b]) => b - a) + .slice(0, WEB_TOP_LIMIT) + .map(([name, calls]) => ({ name, calls })) + + const mcpServers = Object.entries(mcpCalls) + .sort(([, a], [, b]) => b - a) + .slice(0, WEB_TOP_LIMIT) + .map(([name, calls]) => ({ name, calls })) + + const skills = Object.entries(skillAgg) + .sort(([, a], [, b]) => b.cost - a.cost) + .slice(0, WEB_TOP_LIMIT) + .map(([name, d]) => ({ name, turns: d.turns, cost: d.cost })) + + // The fork tracks whether a call spawned an agent (hasAgentSpawn) but not the + // subagent TYPE (that lives in the Task tool input, which the parser doesn't + // retain). So we surface one honest aggregate row rather than fabricating + // per-type names. Empty when no agents were spawned (panel shows "No data"). + const subagents = agentSpawnCalls > 0 + ? [{ name: 'Agent tasks', calls: agentSpawnCalls, cost: agentSpawnCost }] + : [] + + const topProjects = [...projects] + .sort((a, b) => b.totalCostUSD - a.totalCostUSD) + .slice(0, WEB_PROJECT_LIMIT) + .map(p => ({ + name: p.project, + cost: p.totalCostUSD, + sessions: p.sessions.length, + avgCostPerSession: p.sessions.length > 0 ? p.totalCostUSD / p.sessions.length : 0, + })) + + const modelEfficiency = [...aggregateModelEfficiency(projects).values()] + .filter(m => getShortModelName(m.model) !== SYNTHETIC_MODEL && m.editTurns > 0) + .map(m => ({ + name: getShortModelName(m.model), + costPerEdit: m.costPerEditUSD ?? 0, + // aggregateModelEfficiency.oneShotRate is a 0–100 percentage, but the SPA + // renders modelEfficiency as `Math.round(oneShotRate * 100)%` (matching + // the top-level current.oneShotRate, which IS a 0–1 fraction). Normalize + // to a fraction here so it doesn't display double-scaled (e.g. 1670%). + oneShotRate: (m.oneShotRate ?? 0) / 100, + })) + .sort((a, b) => b.costPerEdit - a.costPerEdit) + .slice(0, WEB_MODEL_EFFICIENCY_LIMIT) + + return { topProjects, tools, subagents, skills, mcpServers, modelEfficiency } +} + +export function computeProviderCosts(projects: ProjectSummary[]): ProviderCost[] { + const providerCosts = new Map() + for (const proj of projects) { + for (const session of proj.sessions) { + for (const turn of session.turns) { + for (const call of turn.assistantCalls ?? []) { + providerCosts.set(call.provider, (providerCosts.get(call.provider) ?? 0) + call.costUSD) + } + } + } + } + return Array.from(providerCosts.entries()).map(([name, cost]) => ({ name, cost })) +} + +export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { + const sessions = projects.flatMap(p => p.sessions) + // Null-prototype maps: model names (and categories) are untrusted transcript + // strings, so a "__proto__"/"constructor" key must not bind to Object.prototype + // and pollute it via `if (!map[k]) …; map[k].x += …`. See daily-cache.safeRecord. + const catTotals: Record = Object.create(null) + const modelTotals: Record = Object.create(null) + // Per-model token split, kept alongside modelTotals only to compute Codex + // credits (the fork's PeriodData.models carries {name,cost,calls}, no tokens). (#510) + const modelTokenTotals: Record = Object.create(null) + let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 + + for (const sess of sessions) { + inputTokens += sess.totalInputTokens + outputTokens += sess.totalOutputTokens + cacheReadTokens += sess.totalCacheReadTokens + cacheWriteTokens += sess.totalCacheWriteTokens + for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { + if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } + catTotals[cat].turns += d.turns + catTotals[cat].cost += d.costUSD + catTotals[cat].editTurns += d.editTurns + catTotals[cat].oneShotTurns += d.oneShotTurns + } + for (const [model, d] of Object.entries(sess.modelBreakdown)) { + if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } + modelTotals[model].calls += d.calls + modelTotals[model].cost += d.costUSD + if (!modelTokenTotals[model]) modelTokenTotals[model] = { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0 } + modelTokenTotals[model].inputTokens += d.tokens.inputTokens + modelTokenTotals[model].cacheReadTokens += d.tokens.cacheReadInputTokens + modelTokenTotals[model].outputTokens += d.tokens.outputTokens + } + } + + const codexCreditsTotal = Object.entries(modelTokenTotals).reduce((sum, [model, t]) => { + const c = codexCredits(model, { inputTokens: t.inputTokens, cachedReadTokens: t.cacheReadTokens, outputTokens: t.outputTokens }) + return sum + (c ?? 0) + }, 0) + + return { + label, + cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), + calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), + sessions: projects.reduce((s, p) => s + p.sessions.length, 0), + inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, + codexCredits: codexCreditsTotal, + categories: Object.entries(catTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), + models: Object.entries(modelTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([name, d]) => ({ name, ...d })), + } +} + +/// Build a MenubarPayload for an arbitrary period + provider filter. The device +/// sharing host/server call this to produce the same shape the menubar renders, +/// which is then sanitized before transmission. Uses a straightforward full +/// parse of the range (the live-menubar today-parse-once fast path in the CLI's +/// status command is a spawn-latency optimization the sharing path doesn't need). +export async function buildMenubarPayloadForRange( + periodInfo: { range: DateRange; label: string }, + opts: { provider?: string; optimize?: boolean; enrich?: boolean } = {}, +): Promise { + const provider = opts.provider ?? 'all' + const projects = await parseAllSessions(periodInfo.range, provider) + const currentData = buildPeriodData(periodInfo.label, projects) + const providers = computeProviderCosts(projects) + // optimize findings are omitted for the shared payload (opts.optimize is + // accepted for signature parity but shared summaries don't carry findings). + const base = buildMenubarPayload(currentData, providers, null) + // enrich is set ONLY by the web dashboard's local /api/usage path. The + // menubar status path, `codeburn devices`, and the device-sharing server all + // leave it off, so the extra breakdowns + granular timeline never touch the + // hot menubar path nor the (separately reviewed) sharing egress surface. + if (!opts.enrich) return base + const timeline = buildTimeline(projects) + return { + ...base, + current: { ...base.current, ...buildWebEnrichment(projects) }, + history: { ...base.history, ...(timeline ? { timeline } : {}) }, + } +} diff --git a/src/usage-timeline.ts b/src/usage-timeline.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa4a92b052ee88419092b89d9f99664e65c242f0 GIT binary patch literal 5687 zcma)A+in}l5zVu{qIP3IGi!2mS=$T9mJF}0wTXbTVaiDshGF+`n&Mbc0`#9Oz+oNaLc!iyco_m=Wxv_P- zqR1U#s9w;#HukQqFLh-`l(zhZviiEBbVX&I>4H?1u{t~(ZA@NQF)iA<6l)sYj_0Z< z$ii}CRoURYqSY!}QPXOpD@%1n`by*2MzuzZF0Uw|zvy(_do!-9Vs#{XOI>xTYWJR> zy_}xz8Mu~L7f`S&YLywPSRu_-+pgHO+$MC6*vwu%p-dwp8QiutJkL~Kz;3G$(rgTk zK1|n3xS3nZVQ$J+j>yzFIX9xuP}8*)j3-d*8i-EPSaYS56bwXsjH#d8@kYT zwuE_8PS}|$H+JzmXU5LFu8gJG&u8Z^PR}P#>6msOnN>o-R4#&^t?l~p!h=NoJvKdDnJcoPZxE{z@+QkF6m62(IVGHW)i4!$*g4vE+C;2#YC?uLQ|;pU z;^mvy0vGy@rU5^Fw4Xis8HO~&xeO1JVhe!TUdKZ!RH}=h5_CAwNKl}EkrvVeF75InOnm!Jd}Ffkgy?Or^fgUY^T-8YL<7%GqPX8A5uJfp zkI{9nA!Ypz7J?MdC6POxJ>XOZPhv4`LxfETB8j&Lm|*7QKc|P^BR4ql!<`#emUxY~ zf4FnQi(%&^yZzvI4$Ke|f*B&o4}$|XjFG_wf-I9Lj5g?Jm4M68((2yuNExBU<`lGeTFoAf2P3qGBP>M{e-ox{hMvVWd-q9 z4q#m3fbDH!Kd-%+J!8rqJ_~yXGee$Si9i!ex)$L~mBDprCJUIP>C7^^AvSO1Diu?g z%k@Z#1sz?wBi#-+2LvV<##|_es|BE0x@u%yXiALl1fxLYbq}RE8{e?_W@>{+hKHz0 z2A2dpUg0E53SC{GX`Jw^unq%%d(0r+k~T#?*U|on9>ll5|NUQISRdbud-w*OAdd}v zZye^`45Skx=UHdFR_H(fCYD`t%nspqJ zGY;H9bhwSz5<%8o00JoL<${#^-mjkCCNFDE|UtRms^b7 zrTPWWD9ke?~9Dw!0d2ozPDS ztmQMa0~K6qF=Mg1)zekfWnjH5w*dM|Nl!i+4YgP-_fvJNQ@ zpLvtuF9&E?JXd^yGg165G2oRbQn+DDq*7NYV{#=ECp}|)dO_j9xR`p?Gl9hanZb=c zFmYh8%OI%uOWE^#2l?C323iz$NH7CI$rm#SPD=zQo2h&_-XUYF1xXl?{`=RjMy>Ud&>S5ufUD-e|D!*n12R%%ify;x+O}{WfUe#QZn?-ts zBT8wNA$!b{SD;L~+Tiksc=oLs>>ir5gaN-TXeePMaLwL$#+tw~e&&F0wsj%@uTr(A z*mDlmRM};+DC)Wm?2TWd+!YOD-FN+Wv4z`2icL$ttDgI#L|1H12Z2SSk@-g! zTog*Fb1@^-y|^_WPMYi*1Y4^mc2)E2G#4Vn6uu4~ znnr(u--0vn_VZ^*p4~C-R=2*z$nRY4b^^u>LF?I4+~1~5Dxn2PXOu=A^efNQK}WNkd3O hOEBA#x3RRfzUO&dyPqCB_FU3?)P+YEQQ+=&=f4AcCH(*Z literal 0 HcmV?d00001 diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts new file mode 100644 index 0000000..aa78071 --- /dev/null +++ b/src/web-dashboard.ts @@ -0,0 +1,419 @@ +import { createServer, type Server } from 'http' +import { exec } from 'child_process' +import { readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join, normalize, extname, dirname, sep } from 'path' +import { fileURLToPath } from 'url' +import { AddressInfo } from 'net' + +import { getDeviceName } from './device-name.js' + +import { loadPricing } from './models.js' +import { buildMenubarPayloadForRange } from './usage-payload.js' +import type { MenubarPayload } from './menubar-json.js' +import { periodInfoFromQuery, UsageQueryError, type Period } from './cli-date.js' +import { pullDevices, linkRemote } from './sharing/host.js' +import { browse } from './sharing/discovery.js' +import { loadOrCreateIdentity } from './sharing/identity.js' +import { pairingCode } from './sharing/pairing.js' +import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js' +import { ShareController } from './sharing/share-controller.js' +import { sanitizeForSharing } from './sharing/sanitize.js' +import { buildContextTree, findClaudeSession, listRecentTitledSessions, snapshotRows, type ContextTreeResult, type SessionRef } from './context-tree.js' +import { buildCodexContextTree, findCodexSession, listRecentCodexSessions } from './context-tree-codex.js' + +function readBody(req: import('http').IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + req.on('data', (c) => { + body += c + if (body.length > 1_000_000) reject(new Error('request body too large')) + }) + req.on('end', () => resolve(body)) + req.on('error', reject) + }) +} + +function writeJsonError(res: import('http').ServerResponse, status: number, error: string): void { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ error })) +} + +// Cap on the cached local payload, matched to the parser's own session cache +// (parser.ts) so the assembled payload is never staler than its source data. +const LOCAL_PAYLOAD_TTL_MS = 180_000 + +const HERE = dirname(fileURLToPath(import.meta.url)) + +// Locate the built React dashboard (dist/dash). Works both when running from a +// published package (dist/dash next to the bundled CLI) and from source. +function resolveDashDir(): string | null { + const candidates = [ + process.env['CODEBURN_DASH_DIR'], + join(HERE, 'dash'), + join(HERE, '..', 'dist', 'dash'), + join(HERE, '..', 'dash', 'dist'), + ].filter(Boolean) as string[] + for (const dir of candidates) { + if (existsSync(join(dir, 'index.html'))) return dir + } + return null +} + +const CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.json': 'application/json; charset=utf-8', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.map': 'application/json', +} + +const NOT_BUILT_PAGE = + '' + + '' + + '

Dashboard not built yet

' + + '

Build the web UI once, then reload:

' + + '
cd dash && npm install && npm run build
' + + '

The CLI keeps serving the live data API in the meantime.

' + +function openBrowser(url: string): void { + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open' + try { + exec(`${cmd} ${url}`) + } catch { + /* user can open it manually */ + } +} + +export async function runWebDashboard(opts: { + period: string + provider: string + from?: string + to?: string + project: string[] + exclude: string[] + port: number + open: boolean +}): Promise { + await loadPricing() + const dashDir = resolveDashDir() + + // Sharing this device serves the SANITIZED aggregate (no project names/paths + // or per-session detail), unlike the local /api/usage which shows everything. + const shareGetUsage = async (q: { period?: string; from?: string; to?: string }) => { + // opts.period is a valid Period at runtime (the CLI validates it before + // calling runWebDashboard); the fork narrows defaultPeriod to Period. + const periodInfo = periodInfoFromQuery(q, opts.period as Period) + return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })) + } + const share = new ShareController(shareGetUsage) + if (await loadShareAlways()) await share.start(true).catch(() => {}) + + // The server is long-lived, so cache this machine's parsed payload (the CLI + // process cannot). Store the promise before any await so concurrent identical + // requests collapse into one parse instead of racing. + const localPayloadCache = new Map }>() + const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise => { + const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}` + const hit = localPayloadCache.get(key) + if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) return hit.payload + const periodInfo = periodInfoFromQuery({ period, from, to }, opts.period as Period) + // enrich:true is what makes the SPA panels (topProjects/tools/subagents/ + // skills/mcpServers/modelEfficiency + history.timeline) non-empty. This is + // the ONLY call site that sets it — the menubar/sharing paths leave it off. + const payload = buildMenubarPayloadForRange(periodInfo, { provider, optimize: false, enrich: true }) + const now = Date.now() + localPayloadCache.set(key, { at: now, payload }) + for (const [k, v] of localPayloadCache) if (now - v.at >= LOCAL_PAYLOAD_TTL_MS) localPayloadCache.delete(k) + void payload.catch(() => localPayloadCache.delete(key)) + return payload + } + + // Context trees re-read a whole transcript (up to 100MB), so cache each by + // file version. Keyed on mtime: an active session invalidates itself. + const contextTreeCache = new Map>() + const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise => { + const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}` + const hit = contextTreeCache.get(key) + if (hit) { + // Re-insert so eviction is LRU rather than insertion order. + contextTreeCache.delete(key) + contextTreeCache.set(key, hit) + return hit + } + const tree = provider === 'claude' ? buildContextTree(ref) : buildCodexContextTree(ref) + contextTreeCache.set(key, tree) + void tree.catch(() => contextTreeCache.delete(key)) + while (contextTreeCache.size > 12) { + const oldest = contextTreeCache.keys().next().value + if (oldest === undefined) break + contextTreeCache.delete(oldest) + } + return tree + } + + // Embed this machine's prewarmed payload in index.html for an instant first + // paint with no data round-trip. Only the local device is inlined: no remote + // network wait, and paired devices stream in via the live fetch right after. + const serveIndexHtml = async (res: import('http').ServerResponse, filePath: string): Promise => { + const html = await readFile(filePath, 'utf8') + const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to) + const devices = [{ id: 'local', name: getDeviceName(), local: true, payload }] + // Escape every '<' so a device/model/project name can't close the \n

sbPw4-@K1TOP)OMO!Utpvp>Xz{;g3}*S+ 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 DmY7=T literal 0 HcmV?d00001 diff --git a/dash/src/App.tsx b/dash/src/App.tsx new file mode 100644 index 0000000..12d135b --- /dev/null +++ b/dash/src/App.tsx @@ -0,0 +1,717 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' + +import { + approvePairing, + fetchDevices, + PERIODS, + shareStatus, + startShare, + stopShare, + type DeviceUsage, + type Payload, + type Period, +} from '@/lib/api' +import { cn, fmtNum, fmtTokens, usd } from '@/lib/utils' +import { Card } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { MetricCard } from '@/components/MetricCard' +import { BarList, type BarItem } from '@/components/BarList' +import { DataTable } from '@/components/DataTable' +import { GranularUsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart' +import { DeviceSearchModal } from '@/components/DeviceSearchModal' +import { ContextExplorer } from '@/components/ContextExplorer' + +const n = (v: number | undefined): number => v ?? 0 + +function Panel({ title, children }: { title: string; children: ReactNode }) { + return ( + +